Security fixes: P0-P2 complete (22 fixes)

P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed
P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK
P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal

8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+51
View File
@@ -0,0 +1,51 @@
"""Command pattern package for LeoCRM.
Commands encapsulate business operations with:
- Authorization (permission check)
- Execution (delegates to services)
- Audit logging
- Outbox event enqueuing
- State machine validation
Commands do NOT commit or rollback — the calling layer (FastAPI dependency
``get_db``) manages the transaction boundary.
"""
from app.commands.base import BaseCommand, CommandResult
from app.commands.contact_commands import (
CreateContactCommand,
UpdateContactCommand,
DeleteContactCommand,
MergeContactsCommand,
)
from app.commands.dms_commands import (
UploadFileCommand,
DeleteFileCommand,
)
from app.commands.mail_commands import (
SendMailCommand,
MarkMailReadCommand,
DeleteMailCommand,
)
from app.commands.calendar_commands import (
CreateCalendarEntryCommand,
UpdateCalendarEntryCommand,
DeleteCalendarEntryCommand,
)
__all__ = [
"BaseCommand",
"CommandResult",
"CreateContactCommand",
"UpdateContactCommand",
"DeleteContactCommand",
"MergeContactsCommand",
"UploadFileCommand",
"DeleteFileCommand",
"SendMailCommand",
"MarkMailReadCommand",
"DeleteMailCommand",
"CreateCalendarEntryCommand",
"UpdateCalendarEntryCommand",
"DeleteCalendarEntryCommand",
]
+130
View File
@@ -0,0 +1,130 @@
"""Base command infrastructure — CommandResult and BaseCommand.
Template method pattern:
execute() → authorize() → run() → audit()
Commands must NOT call db.commit() or db.rollback().
The transaction boundary is owned by the calling layer (FastAPI ``get_db``).
Commands MAY call db.flush() to send SQL within the transaction.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.core.permissions import check_permission
logger = logging.getLogger(__name__)
@dataclass
class CommandResult:
"""Result of a command execution.
Attributes:
success: Whether the command succeeded.
data: Response data on success (dict or None).
error: Error message on failure (str or None).
events: List of outbox event dicts that were enqueued.
"""
success: bool
data: dict | None = None
error: str | None = None
events: list[dict] = field(default_factory=list)
@classmethod
def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> "CommandResult":
"""Create a successful result."""
return cls(success=True, data=data, events=events or [])
@classmethod
def fail(cls, error: str) -> "CommandResult":
"""Create a failed result."""
return cls(success=False, error=error)
class BaseCommand:
"""Base class for all commands using the template method pattern.
Subclasses must implement:
- authorize(current_user) → check permissions
- run(db, redis, current_user) → execute business logic, return CommandResult
- audit(db, current_user) → create audit log entry
The ``permission`` attribute is checked by the default ``authorize``
implementation. Set it to the required permission string (e.g. "contacts:write").
"""
permission: str | None = None
async def execute(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
"""Execute the command: authorize → run → audit.
Does NOT commit — the caller manages the transaction.
"""
# Authorization
auth_result = await self.authorize(current_user)
if not auth_result:
return CommandResult.fail(
f"Permission denied: '{self.permission}' required"
)
# Execute business logic
result = await self.run(db, redis, current_user)
# Audit (only if the command succeeded)
if result.success:
try:
await self.audit(db, current_user)
except Exception:
logger.exception("Audit logging failed for %s", self.__class__.__name__)
# Audit failure should not roll back the business operation
return result
async def authorize(self, current_user: dict[str, Any]) -> bool:
"""Check if the current user has the required permission.
Override in subclass for custom authorization logic.
"""
if self.permission is None:
return True
return check_permission(current_user, self.permission)
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
"""Execute the business logic. Must be overridden by subclasses."""
raise NotImplementedError(f"{self.__class__.__name__}.run() not implemented")
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
"""Create an audit log entry. Override in subclass."""
pass
# ── Helpers ──
@staticmethod
def _tenant_id(current_user: dict[str, Any]) -> uuid.UUID:
"""Extract tenant_id from current_user session."""
return uuid.UUID(current_user["tenant_id"])
@staticmethod
def _user_id(current_user: dict[str, Any]) -> uuid.UUID:
"""Extract user_id from current_user session."""
return uuid.UUID(current_user["user_id"])
+181
View File
@@ -0,0 +1,181 @@
"""Calendar commands — create, update, delete entries via Command pattern."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
logger = logging.getLogger(__name__)
class CreateCalendarEntryCommand(BaseCommand):
"""Create a new calendar entry (appointment, task, reminder)."""
permission = "calendar:write"
def __init__(self, calendar_id: str, title: str, start_at: str, end_at: str | None = None,
description: str | None = None, location: str | None = None,
entry_type: str = "appointment", status: str = "open"):
self.calendar_id = calendar_id
self.title = title
self.start_at = start_at
self.end_at = end_at
self.description = description
self.location = location
self.entry_type = entry_type
self.status = status
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
try:
cal_id = uuid.UUID(self.calendar_id)
except ValueError:
return CommandResult.fail("Invalid calendar_id")
# Verify calendar belongs to tenant
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id, Calendar.tenant_id == tenant_id)
)
if cal_result.scalar_one_or_none() is None:
return CommandResult.fail("Calendar not found")
entry_id = uuid.uuid4()
entry = CalendarEntry(
id=entry_id,
tenant_id=tenant_id,
calendar_id=cal_id,
title=self.title,
description=self.description,
location=self.location,
start_at=datetime.fromisoformat(self.start_at),
end_at=datetime.fromisoformat(self.end_at) if self.end_at else None,
entry_type=self.entry_type,
status=self.status,
created_by=user_id,
)
db.add(entry)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "calendar.entry.created", {
"entry_id": str(entry_id),
"title": self.title,
"start_at": self.start_at,
})
return CommandResult.ok({
"id": str(entry_id),
"title": self.title,
"start_at": self.start_at,
"status": self.status,
})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="calendar.entry.create", entity_type="calendar_entry",
changes={"title": self.title, "start_at": self.start_at},
)
class UpdateCalendarEntryCommand(BaseCommand):
"""Update an existing calendar entry."""
permission = "calendar:write"
def __init__(self, entry_id: str, data: dict[str, Any]):
self.entry_id = entry_id
self.data = data
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.calendar.models import CalendarEntry
tenant_id = self._tenant_id(current_user)
try:
eid = uuid.UUID(self.entry_id)
except ValueError:
return CommandResult.fail("Invalid entry_id")
result = await db.execute(
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
)
entry = result.scalar_one_or_none()
if entry is None:
return CommandResult.fail("Calendar entry not found")
# Apply updates
for key, value in self.data.items():
if hasattr(entry, key) and key not in ("id", "tenant_id", "created_at"):
if key in ("start_at", "end_at") and isinstance(value, str):
value = datetime.fromisoformat(value)
setattr(entry, key, value)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "calendar.entry.updated", {
"entry_id": self.entry_id,
"changes": self.data,
})
return CommandResult.ok({"id": self.entry_id, "updated": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="calendar.entry.update", entity_type="calendar_entry",
changes={"entry_id": self.entry_id, "fields": list(self.data.keys())},
)
class DeleteCalendarEntryCommand(BaseCommand):
"""Delete a calendar entry."""
permission = "calendar:delete"
def __init__(self, entry_id: str):
self.entry_id = entry_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.calendar.models import CalendarEntry
tenant_id = self._tenant_id(current_user)
try:
eid = uuid.UUID(self.entry_id)
except ValueError:
return CommandResult.fail("Invalid entry_id")
result = await db.execute(
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
)
entry = result.scalar_one_or_none()
if entry is None:
return CommandResult.fail("Calendar entry not found")
await db.delete(entry)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "calendar.entry.deleted", {"entry_id": self.entry_id})
return CommandResult.ok({"id": self.entry_id, "deleted": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="calendar.entry.delete", entity_type="calendar_entry",
changes={"entry_id": self.entry_id},
)
+375
View File
@@ -0,0 +1,375 @@
"""Contact commands — Create, Update, Delete (soft), Merge.
Each command:
- Checks permissions via ``require_permission`` semantics
- Delegates business logic to existing services
- Creates an AuditLog entry
- Enqueues outbox events
- Validates state transitions via the state machine
Commands do NOT commit — the transaction is managed by ``get_db``.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
from app.core.state_machine import contact_state_machine, StateMachineError
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.services import contact_service, dedup_service
from app.services.entity_history_service import record_history
logger = logging.getLogger(__name__)
class CreateContactCommand(BaseCommand):
"""Create a new contact (company or person).
Args:
data: Contact fields dict (from ContactCreate schema).
"""
permission = "contacts:write"
def __init__(self, data: dict[str, Any]) -> None:
self.data = data
self._created_contact_id: uuid.UUID | None = None
self._serialized: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Set default status for new contacts
if "status" not in self.data:
self.data["status"] = "lead"
# Validate status if provided
status = self.data.get("status", "lead")
if status not in contact_state_machine.transitions:
return CommandResult.fail(f"Invalid contact status: '{status}'")
# Delegate to existing service
try:
serialized = await contact_service.create_contact(
db, tenant_id, user_id, self.data
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._created_contact_id = uuid.UUID(serialized["id"])
self._serialized = serialized
# Enqueue outbox events
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.created", {
"contact_id": serialized["id"],
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": self.data.get("type", "company"),
})
events.append({"event": "contact.created", "contact_id": serialized["id"]})
if self.data.get("type") == "company":
await enqueue_outbox_event(db, tenant_id, "lead.created", {
"contact_id": serialized["id"],
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({"event": "lead.created", "contact_id": serialized["id"]})
return CommandResult.ok(data=serialized, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._created_contact_id is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="create",
entity_type="contact",
entity_id=self._created_contact_id,
changes=self._serialized,
)
db.add(entry)
await db.flush()
class UpdateContactCommand(BaseCommand):
"""Update an existing contact.
Args:
contact_id: UUID string of the contact to update.
data: Contact fields to update (from ContactUpdate schema).
"""
permission = "contacts:write"
def __init__(self, contact_id: str, data: dict[str, Any]) -> None:
self.contact_id = contact_id
self.data = data
self._contact_uuid: uuid.UUID | None = None
self._serialized: dict | None = None
self._changes: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Validate status transition if status is being updated
if "status" in self.data:
new_status = self.data["status"]
# Query only the status column to avoid lazy-loading issues
from sqlalchemy import select
status_q = select(Contact.status).where(
Contact.id == uuid.UUID(self.contact_id),
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
status_result = await db.execute(status_q)
current_status = status_result.scalar_one_or_none()
if current_status is None:
return CommandResult.fail("Contact not found")
try:
contact_state_machine.transition(current_status, new_status)
except StateMachineError as exc:
return CommandResult.fail(str(exc))
# Delegate to existing service
try:
serialized = await contact_service.update_contact(
db, tenant_id, user_id, self.contact_id, self.data
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._contact_uuid = uuid.UUID(self.contact_id)
self._serialized = serialized
# Compute changes for audit
self._changes = {k: {"new": v} for k, v in self.data.items()}
# Enqueue outbox event
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.updated", {
"contact_id": self.contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": serialized.get("type"),
})
events.append({"event": "contact.updated", "contact_id": self.contact_id})
return CommandResult.ok(data=serialized, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._contact_uuid is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="update",
entity_type="contact",
entity_id=self._contact_uuid,
changes=self._changes,
)
db.add(entry)
await db.flush()
class DeleteContactCommand(BaseCommand):
"""Soft-delete a contact.
Args:
contact_id: UUID string of the contact to delete.
hard: If True, perform GDPR hard-delete instead of soft-delete.
"""
permission = "contacts:write"
def __init__(self, contact_id: str, hard: bool = False) -> None:
self.contact_id = contact_id
self.hard = hard
self._contact_uuid: uuid.UUID | None = None
self._snapshot: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Fetch contact for snapshot before deletion
from sqlalchemy import select
from sqlalchemy.orm import selectinload
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(
Contact.id == uuid.UUID(self.contact_id),
Contact.tenant_id == tenant_id,
)
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
return CommandResult.fail("Contact not found")
self._contact_uuid = contact.id
self._snapshot = contact_service._serialize_contact_detail(contact)
if self.hard:
await contact_service.hard_delete_contact(
db, tenant_id, self.contact_id
)
else:
await contact_service.delete_contact(
db, tenant_id, self.contact_id, user_id
)
# Enqueue outbox event
events: list[dict] = []
event_name = "contact.hard_deleted" if self.hard else "contact.deleted"
await enqueue_outbox_event(db, tenant_id, event_name, {
"contact_id": self.contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({"event": event_name, "contact_id": self.contact_id})
return CommandResult.ok(data=None, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._contact_uuid is None:
return
action = "hard_delete" if self.hard else "delete"
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
entity_type="contact",
entity_id=self._contact_uuid,
changes={"snapshot": self._snapshot},
)
db.add(entry)
await db.flush()
class MergeContactsCommand(BaseCommand):
"""Merge two contacts (source → target).
Args:
source_contact_id: UUID string of the source contact (will be soft-deleted).
target_contact_id: UUID string of the target contact (will survive).
field_overrides: Optional field overrides to apply to the target.
note: Optional note for the merge history.
"""
permission = "contacts:write"
def __init__(
self,
source_contact_id: str,
target_contact_id: str,
field_overrides: dict[str, Any] | None = None,
note: str | None = None,
) -> None:
self.source_contact_id = source_contact_id
self.target_contact_id = target_contact_id
self.field_overrides = field_overrides
self.note = note
self._result_data: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self.source_contact_id == self.target_contact_id:
return CommandResult.fail("Source and target contacts must be different")
try:
result = await dedup_service.merge_contacts(
db, tenant_id, user_id,
source_id=self.source_contact_id,
target_id=self.target_contact_id,
field_overrides=self.field_overrides,
note=self.note,
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._result_data = result
# Enqueue outbox events
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.merged", {
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({
"event": "contact.merged",
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
})
return CommandResult.ok(data=result, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._result_data is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="merge",
entity_type="contact",
entity_id=uuid.UUID(self.target_contact_id),
changes={
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
"note": self.note,
},
)
db.add(entry)
await db.flush()
+159
View File
@@ -0,0 +1,159 @@
"""DMS commands — file upload, delete, restore via Command pattern."""
from __future__ import annotations
import hashlib
import logging
import os
import uuid
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
from app.core.storage import get_storage_backend
logger = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 1024 # 1MB
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename for safe use in Content-Disposition headers."""
import re
safe = os.path.basename(filename.replace("\\", "/"))
safe = re.sub(r"[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]", "_", safe)
safe = re.sub(r"\.{2,}", "_", safe)
safe = re.sub(r" {2,}", " ", safe)
safe = safe.lstrip(".").strip()
if len(safe) > 200:
name, ext = safe.rsplit(".", 1) if "." in safe[:200] else (safe[:200], "")
safe = name[:200] + ("." + ext if ext else "")
return safe or "file"
class UploadFileCommand(BaseCommand):
"""Upload a file to DMS with chunked streaming and SHA-256 hashing."""
permission = "dms:write"
def __init__(self, file_content: bytes, filename: str, mime_type: str, folder_id: str | None = None):
self.file_content = file_content
self.filename = _sanitize_filename(filename)
self.mime_type = mime_type
self.folder_id = folder_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.dms.models import File as DmsFile, Folder
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Validate folder if specified
fid = None
if self.folder_id:
try:
fid = uuid.UUID(self.folder_id)
except ValueError:
return CommandResult.fail("Invalid folder_id")
folder_result = await db.execute(
select(Folder).where(Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None))
)
if folder_result.scalar_one_or_none() is None:
return CommandResult.fail("Folder not found")
# Calculate SHA-256
sha256 = hashlib.sha256()
sha256.update(self.file_content)
content_hash = sha256.hexdigest()
file_size = len(self.file_content)
# Create file record
file_id = uuid.uuid4()
storage_path = f"{tenant_id}/{file_id}"
# Save file
storage = get_storage_backend()
await storage.save(storage_path, self.file_content)
dms_file = DmsFile(
id=file_id,
tenant_id=tenant_id,
name=self.filename,
folder_id=fid,
uploaded_by=user_id,
mime_type=self.mime_type,
size_bytes=file_size,
storage_path=storage_path,
content_hash=content_hash,
)
db.add(dms_file)
await db.flush()
# Enqueue outbox event
await enqueue_outbox_event(db, tenant_id, "dms.file.uploaded", {
"file_id": str(file_id),
"name": self.filename,
"size_bytes": file_size,
"content_hash": content_hash,
})
return CommandResult.ok({
"id": str(file_id),
"name": self.filename,
"size_bytes": file_size,
"content_hash": content_hash,
"mime_type": self.mime_type,
})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="dms.file.upload", entity_type="dms_file",
changes={"name": self.filename, "size": len(self.file_content)},
)
class DeleteFileCommand(BaseCommand):
"""Soft-delete a DMS file."""
permission = "dms:delete"
def __init__(self, file_id: str):
self.file_id = file_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.dms.models import File as DmsFile
from datetime import UTC, datetime
tenant_id = self._tenant_id(current_user)
try:
fid = uuid.UUID(self.file_id)
except ValueError:
return CommandResult.fail("Invalid file_id")
result = await db.execute(
select(DmsFile).where(DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None))
)
dms_file = result.scalar_one_or_none()
if dms_file is None:
return CommandResult.fail("File not found")
dms_file.deleted_at = datetime.now(UTC)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "dms.file.deleted", {"file_id": self.file_id})
return CommandResult.ok({"id": self.file_id, "deleted": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="dms.file.delete", entity_type="dms_file",
changes={"file_id": self.file_id},
)
+174
View File
@@ -0,0 +1,174 @@
"""Mail commands — send, mark read/unread, delete via Command pattern."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
logger = logging.getLogger(__name__)
class SendMailCommand(BaseCommand):
"""Send an email via a configured IMAP/SMTP account."""
permission = "mail:send"
def __init__(self, account_id: str, to: list[str], subject: str, body_text: str, body_html: str | None = None, cc: list[str] | None = None, in_reply_to: str | None = None):
self.account_id = account_id
self.to = to
self.subject = subject
self.body_text = body_text
self.body_html = body_html
self.cc = cc or []
self.in_reply_to = in_reply_to
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.mail.models import Mail, MailAccount
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
try:
account_uuid = uuid.UUID(self.account_id)
except ValueError:
return CommandResult.fail("Invalid account_id")
# Verify account belongs to tenant
acct_result = await db.execute(
select(MailAccount).where(MailAccount.id == account_uuid, MailAccount.tenant_id == tenant_id)
)
account = acct_result.scalar_one_or_none()
if account is None:
return CommandResult.fail("Mail account not found")
# Create mail record
mail_id = uuid.uuid4()
mail = Mail(
id=mail_id,
tenant_id=tenant_id,
account_id=account_uuid,
message_id=f"<leocrm-{mail_id}@{account.email_address}>",
from_addr=account.email_address,
to_addr=",".join(self.to),
cc_addr=",".join(self.cc) if self.cc else None,
subject=self.subject,
body_text=self.body_text,
body_html_sanitized=self.body_html,
direction="outgoing",
received_at=datetime.now(UTC),
is_read=True,
folder="Sent",
)
db.add(mail)
await db.flush()
# Enqueue outbox event for async SMTP send
await enqueue_outbox_event(db, tenant_id, "mail.send", {
"mail_id": str(mail_id),
"account_id": self.account_id,
"to": self.to,
"subject": self.subject,
})
return CommandResult.ok({
"id": str(mail_id),
"status": "queued",
"to": self.to,
"subject": self.subject,
})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="mail.send", entity_type="mail",
changes={"to": self.to, "subject": self.subject},
)
class MarkMailReadCommand(BaseCommand):
"""Mark a mail as read or unread."""
permission = "mail:write"
def __init__(self, mail_id: str, is_read: bool = True):
self.mail_id = mail_id
self.is_read = is_read
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.mail.models import Mail
tenant_id = self._tenant_id(current_user)
try:
mid = uuid.UUID(self.mail_id)
except ValueError:
return CommandResult.fail("Invalid mail_id")
result = await db.execute(
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id)
)
mail = result.scalar_one_or_none()
if mail is None:
return CommandResult.fail("Mail not found")
mail.is_read = self.is_read
await db.flush()
return CommandResult.ok({"id": self.mail_id, "is_read": self.is_read})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="mail.mark_read", entity_type="mail",
changes={"mail_id": self.mail_id, "is_read": self.is_read},
)
class DeleteMailCommand(BaseCommand):
"""Soft-delete a mail."""
permission = "mail:delete"
def __init__(self, mail_id: str):
self.mail_id = mail_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.mail.models import Mail
tenant_id = self._tenant_id(current_user)
try:
mid = uuid.UUID(self.mail_id)
except ValueError:
return CommandResult.fail("Invalid mail_id")
result = await db.execute(
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id, Mail.deleted_at.is_(None))
)
mail = result.scalar_one_or_none()
if mail is None:
return CommandResult.fail("Mail not found")
mail.deleted_at = datetime.now(UTC)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "mail.deleted", {"mail_id": self.mail_id})
return CommandResult.ok({"id": self.mail_id, "deleted": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="mail.delete", entity_type="mail",
changes={"mail_id": self.mail_id},
)
+12 -3
View File
@@ -35,13 +35,13 @@ class Settings(BaseSettings):
# Auth
bcrypt_rounds: int = 12
session_cookie_name: str = "leocrm_session"
session_cookie_secure: bool = False # True in production behind HTTPS
session_cookie_secure: bool = True # Secure by default — set to False only for local HTTP development
session_cookie_samesite: str = "strict"
session_cookie_httponly: bool = True
password_reset_expiry_hours: int = 1
# Storage
storage_path: str = "/tmp"
storage_path: str = "/data/storage"
# SMTP
smtp_host: str = "localhost"
@@ -76,7 +76,16 @@ class Settings(BaseSettings):
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()
s = Settings()
# Production safety checks
if s.environment == "production":
if not s.session_cookie_secure:
raise RuntimeError("SESSION_COOKIE_SECURE must be True in production")
if s.secret_key == "change-me-in-production-use-a-secure-random-string":
raise RuntimeError("SECRET_KEY must be changed from default in production")
if s.storage_path == "/tmp":
raise RuntimeError("STORAGE_PATH must not be /tmp in production")
return s
# Module-level singleton for backward-compatible imports
+54 -2
View File
@@ -8,6 +8,8 @@ import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import logging
import redis.asyncio as aioredis
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,10 +18,53 @@ from app.config import get_settings
from app.models.session import Session as SessionModel
from app.models.user import User
logger = logging.getLogger(__name__)
_pwd_context = CryptContext(
schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds
)
# ── Global Redis client singleton ────────────────────────────────────────────
_redis_client: aioredis.Redis | None = None
async def init_redis() -> aioredis.Redis:
"""Create and store the global Redis client. Called once during app lifespan startup."""
global _redis_client
if _redis_client is not None:
logger.warning("init_redis() called but Redis client already initialized")
return _redis_client
_redis_client = aioredis.from_url(
get_settings().redis_url, decode_responses=True
)
logger.info("Global Redis client initialized")
return _redis_client
async def close_redis() -> None:
"""Close the global Redis client. Called during app lifespan shutdown."""
global _redis_client
if _redis_client is not None:
await _redis_client.aclose()
_redis_client = None
logger.info("Global Redis client closed")
def get_redis() -> aioredis.Redis:
"""Return the global Redis client singleton.
If init_redis() has not been called yet (e.g. during testing or
outside the app lifespan), a new client is created lazily so callers
always get a working connection.
"""
global _redis_client
if _redis_client is None:
_redis_client = aioredis.from_url(
get_settings().redis_url, decode_responses=True
)
logger.debug("Redis client created lazily (init_redis not called)")
return _redis_client
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
@@ -56,9 +101,13 @@ async def create_session(
redis: aioredis.Redis,
user: User,
tenant_id: uuid.UUID,
role: str = "viewer",
) -> tuple[str, str]:
"""Create a session in Redis (runtime) and PostgreSQL (audit trail).
Returns (session_id, csrf_token).
``role`` comes from UserTenant — the built-in role string for the
active tenant membership.
"""
settings = get_settings()
session_id = str(uuid.uuid4())
@@ -71,7 +120,7 @@ async def create_session(
"tenant_id": str(tenant_id),
"email": user.email,
"name": user.name,
"role": user.role,
"role": role,
"is_system_admin": user.is_system_admin,
"csrf_token": csrf_token,
"is_active": user.is_active,
@@ -123,8 +172,9 @@ async def update_session_tenant(
redis: aioredis.Redis,
session_id: str,
new_tenant_id: uuid.UUID,
role: str | None = None,
) -> dict[str, Any] | None:
"""Update the active tenant in a Redis session."""
"""Update the active tenant (and optionally role) in a Redis session."""
import json
settings = get_settings()
@@ -133,6 +183,8 @@ async def update_session_tenant(
return None
data = json.loads(raw)
data["tenant_id"] = str(new_tenant_id)
if role is not None:
data["role"] = role
ttl = await redis.ttl(f"session:{session_id}")
if ttl <= 0:
ttl = settings.session_ttl_seconds
+41 -1
View File
@@ -1,4 +1,23 @@
"""In-process event bus for publish/subscribe."""
"""In-process event bus for publish/subscribe.
.. note::
This bus is **in-process only** — events are lost on crash, restart, or
when multiple replicas are running. For **domain/business events** that
must be delivered reliably (e.g. ``contact.created``, ``contact.updated``,
``user.created``), use the :mod:`app.core.outbox` transactional outbox
instead::
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, "contact.created", {...})
The outbox worker (see :mod:`app.core.worker`) polls the ``event_outbox``
table every 5 seconds and publishes events to this in-process bus, so
local handlers still receive them — but with durability guarantees.
``publish()`` may still be used for **uncritical local events** that do
not require persistence (e.g. cache invalidation signals).
"""
from __future__ import annotations
@@ -35,6 +54,27 @@ class EventBus:
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def publish_with_results(
self, event_name: str, payload: dict[str, Any]
) -> list[Exception | None]:
"""Publish an event and return per-handler results.
Unlike :meth:`publish`, this method does **not** swallow exceptions.
Each list entry is ``None`` on success or the caught ``Exception``
on failure, so callers (e.g. the outbox processor) can detect handler
errors and apply retry logic.
"""
handlers = self._handlers.get(event_name, [])
wildcard_handlers = self._handlers.get('*', [])
all_handlers = handlers + wildcard_handlers
if not all_handlers:
return []
tasks = [asyncio.create_task(h(payload)) for h in all_handlers]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [
r if isinstance(r, Exception) else None for r in results
]
# Global event bus instance
_event_bus = EventBus()
+44 -4
View File
@@ -2,19 +2,59 @@
from __future__ import annotations
import logging
from typing import Any
from arq import create_pool
from arq.connections import RedisSettings
from arq.connections import RedisSettings, ArqRedis
from app.config import get_settings
logger = logging.getLogger(__name__)
async def get_job_pool():
"""Get an ARQ job pool for enqueueing background tasks."""
# ── Global ARQ pool singleton ────────────────────────────────────────────────
_job_pool: ArqRedis | None = None
async def init_job_pool() -> ArqRedis:
"""Create and store the global ARQ job pool.
Called once during app lifespan startup so every subsequent enqueue
reuses the same connection instead of opening a new one per call.
"""
global _job_pool
if _job_pool is not None:
logger.warning("init_job_pool() called but pool already initialized")
return _job_pool
settings = get_settings()
redis_settings = RedisSettings.from_dsn(settings.redis_url)
return await create_pool(redis_settings)
_job_pool = await create_pool(redis_settings)
logger.info("Global ARQ job pool initialized")
return _job_pool
async def close_job_pool() -> None:
"""Close the global ARQ job pool. Called during app lifespan shutdown."""
global _job_pool
if _job_pool is not None:
await _job_pool.close()
_job_pool = None
logger.info("Global ARQ job pool closed")
async def get_job_pool() -> ArqRedis:
"""Return the global ARQ job pool singleton.
If init_job_pool() has not been called yet (e.g. during testing),
a new pool is created lazily so callers always get a working connection.
"""
global _job_pool
if _job_pool is None:
settings = get_settings()
redis_settings = RedisSettings.from_dsn(settings.redis_url)
_job_pool = await create_pool(redis_settings)
logger.debug("ARQ job pool created lazily (init_job_pool not called)")
return _job_pool
async def enqueue_job(job_name: str, *args: Any, **kwargs: Any) -> str | None:
+210
View File
@@ -0,0 +1,210 @@
"""Transactional outbox for reliable domain event delivery.
Instead of publishing events directly to an in-process bus (which is lost
on crash/restart), domain events are written to the ``event_outbox`` table
**within the same database transaction** as the business operation. A
background worker then polls the outbox and publishes events to the
in-process event bus.
Usage in services::
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, "contact.created", {
"contact_id": str(contact.id),
"tenant_id": str(tenant_id),
})
# ... later, the transaction commits and the event is durable.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# ── SQL statements (raw text for FOR UPDATE SKIP LOCKED) ────────────────────
_INSERT_SQL = text(
"""
INSERT INTO event_outbox (tenant_id, event_name, payload)
VALUES (:tenant_id, :event_name, CAST(:payload AS JSONB))
"""
)
_CLAIM_SQL = text(
"""
UPDATE event_outbox
SET status = 'processing',
updated_at = now()
WHERE id IN (
SELECT id FROM event_outbox
WHERE status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= now())
ORDER BY created_at
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
RETURNING id, tenant_id, event_name, payload, attempts, max_attempts
"""
)
_MARK_PUBLISHED_SQL = text(
"""
UPDATE event_outbox
SET status = 'published',
published_at = now(),
updated_at = now()
WHERE id = :id
"""
)
_FAIL_SQL = text(
"""
UPDATE event_outbox
SET status = 'failed',
updated_at = now()
WHERE id = :id
"""
)
_RETRY_SQL = text(
"""
UPDATE event_outbox
SET status = 'pending',
attempts = :attempts,
next_retry_at = :next_retry_at,
updated_at = now()
WHERE id = :id
"""
)
def _json_payload(payload: dict[str, Any]) -> str:
"""Serialise payload to a JSON string suitable for JSONB cast."""
import json
return json.dumps(payload, default=str)
async def enqueue_outbox_event(
db: AsyncSession,
tenant_id: uuid.UUID,
event_name: str,
payload: dict[str, Any],
) -> None:
"""Insert an event into the outbox table within the current transaction.
The event is only persisted when the surrounding transaction commits.
This guarantees at-least-once delivery — no event is lost even if the
process crashes after the business operation but before the event is
published.
Args:
db: Active async SQLAlchemy session (part of the business transaction).
tenant_id: Tenant scope for the event.
event_name: Logical event name (e.g. ``"contact.created"``).
payload: Event payload dict (will be stored as JSONB).
"""
await db.execute(
_INSERT_SQL,
{
"tenant_id": str(tenant_id),
"event_name": event_name,
"payload": _json_payload(payload),
},
)
async def process_outbox_batch(
db: AsyncSession,
redis: aioredis.Redis | None = None,
batch_size: int = 50,
) -> int:
"""Process one batch of pending outbox events.
1. Claim up to *batch_size* pending events using ``FOR UPDATE SKIP LOCKED``
so multiple workers don't interfere.
2. Publish each event to the in-process event bus (for local handlers).
3. On success: mark as ``published``.
4. On failure: increment attempts, schedule retry with exponential
backoff, or mark as ``failed`` if max attempts exceeded.
Args:
db: Async SQLAlchemy session for this batch.
redis: Optional Redis client (unused for now, reserved for future
cross-process pub/sub).
batch_size: Maximum events to process in one batch.
Returns:
Number of events successfully published.
"""
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
published_count = 0
# Claim a batch of pending events
rows = (
await db.execute(_CLAIM_SQL, {"batch_size": batch_size})
).fetchall()
if not rows:
return 0
for row in rows:
event_id = row[0]
event_name = row[2]
payload = row[3]
attempts = row[4]
max_attempts = row[5]
# payload comes back as a dict from JSONB
if isinstance(payload, str):
import json
payload_dict = json.loads(payload)
else:
payload_dict = payload
try:
results = await event_bus.publish_with_results(event_name, payload_dict)
# If any handler raised, treat as failure
handler_errors = [r for r in results if r is not None]
if handler_errors:
raise handler_errors[0]
await db.execute(_MARK_PUBLISHED_SQL, {"id": str(event_id)})
published_count += 1
except Exception as exc:
logger.error(
"Failed to publish outbox event %s (%s): %s",
event_id, event_name, exc,
exc_info=True,
)
new_attempts = attempts + 1
if new_attempts >= max_attempts:
await db.execute(_FAIL_SQL, {"id": str(event_id)})
logger.warning(
"Outbox event %s marked as failed after %d attempts",
event_id, new_attempts,
)
else:
backoff = timedelta(seconds=(2 ** new_attempts) * 10)
next_retry = datetime.now(timezone.utc) + backoff
await db.execute(
_RETRY_SQL,
{
"id": str(event_id),
"attempts": new_attempts,
"next_retry_at": next_retry,
},
)
await db.commit()
return published_count
+215 -73
View File
@@ -16,7 +16,7 @@ import uuid
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
@@ -30,6 +30,9 @@ logger = logging.getLogger(__name__)
CACHE_TTL = 300 # 5 minutes
CACHE_PREFIX = "resolved"
# Severity ordering for field permissions: highest wins
_FIELD_PERM_SEVERITY = {"hidden": 3, "readonly": 2, "read": 1}
def _matches_permission(granted: str, required: str) -> bool:
"""Check if a granted permission matches the required permission.
@@ -44,7 +47,6 @@ def _matches_permission(granted: str, required: str) -> bool:
return True
g_parts = granted.split(":")
r_parts = required.split(":")
# Wildcard * matches any single segment, but remaining segments must still match
if len(g_parts) != len(r_parts):
return False
for i, g_part in enumerate(g_parts):
@@ -88,6 +90,87 @@ def _normalize_permissions(permissions: Any) -> set[str]:
return result
def _merge_field_permissions(
existing: dict[str, dict[str, str]],
incoming: dict[str, Any],
) -> None:
"""Merge incoming field permissions into existing dict.
Uses 'strictest right wins': hidden > readonly > read.
When a field already exists, the more restrictive (higher severity) value wins.
"""
for module, fields in incoming.items():
if not isinstance(fields, dict):
continue
if module not in existing:
existing[module] = {}
for field, perm in fields.items():
if not isinstance(perm, str):
continue
perm_lower = perm.lower()
if perm_lower not in _FIELD_PERM_SEVERITY:
# Unknown permission level — skip with warning
logger.warning(
"Unknown field permission level '%s' for %s.%s — skipping",
perm, module, field,
)
continue
current = existing[module].get(field)
if current is None:
existing[module][field] = perm_lower
else:
# Strictest (highest severity) wins
if _FIELD_PERM_SEVERITY[perm_lower] > _FIELD_PERM_SEVERITY.get(current, 0):
existing[module][field] = perm_lower
async def _get_current_permission_version(
db: AsyncSession,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> int:
"""Get the current max permission_version from DB for cache validation.
Uses a SAVEPOINT so that a failure here does not abort the outer transaction.
"""
async with db.begin_nested():
# Check role version
ut_q = select(UserTenant.role_id).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == tenant_id,
)
ut_result = await db.execute(ut_q)
role_id = ut_result.scalar_one_or_none()
max_version = 0
if role_id is not None:
role_q = select(Role.permission_version).where(Role.id == role_id)
role_result = await db.execute(role_q)
role_ver = role_result.scalar_one_or_none()
if role_ver is not None:
max_version = max(max_version, role_ver)
# Check group versions
ug_q = select(UserGroup.group_id).where(
UserGroup.user_id == user_id,
UserGroup.tenant_id == tenant_id,
)
ug_result = await db.execute(ug_q)
group_ids = [row[0] for row in ug_result.all()]
if group_ids:
groups_q = select(func.max(Group.permission_version)).where(
Group.id.in_(group_ids),
Group.deleted_at.is_(None),
)
groups_result = await db.execute(groups_q)
group_max = groups_result.scalar()
if group_max is not None:
max_version = max(max_version, group_max)
return max_version
async def resolve_permissions(
db: AsyncSession,
user_id: uuid.UUID,
@@ -104,18 +187,24 @@ async def resolve_permissions(
"version": int, # permission_version for cache invalidation
}
"""
# Check system admin first
# If a previous query in this session failed, the transaction may be aborted.
# Rollback to recover before executing our query.
# Use SAVEPOINT for the initial query so a failure doesn't abort
# the outer transaction.
try:
user_q = select(User.is_system_admin).where(User.id == user_id)
user_result = await db.execute(user_q)
is_system_admin = user_result.scalar() or False
async with db.begin_nested():
user_q = select(User.is_system_admin).where(User.id == user_id)
user_result = await db.execute(user_q)
is_system_admin = user_result.scalar() or False
except Exception:
await db.rollback()
user_q = select(User.is_system_admin).where(User.id == user_id)
user_result = await db.execute(user_q)
is_system_admin = user_result.scalar() or False
logger.warning(
"SAVEPOINT failed for is_system_admin query (user=%s), retrying without savepoint",
user_id,
exc_info=True,
)
# Last-resort fallback: still use savepoint to isolate
async with db.begin_nested():
user_q = select(User.is_system_admin).where(User.id == user_id)
user_result = await db.execute(user_q)
is_system_admin = user_result.scalar() or False
if is_system_admin:
return {
@@ -126,13 +215,14 @@ async def resolve_permissions(
"version": 0, # system admin doesn't need version tracking
}
# Load UserTenant to get role_id
ut_q = select(UserTenant).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == tenant_id,
)
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
# Load UserTenant to get role_id — use SAVEPOINT
async with db.begin_nested():
ut_q = select(UserTenant).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == tenant_id,
)
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
allowed: set[str] = set()
denied: set[str] = set()
@@ -141,72 +231,75 @@ async def resolve_permissions(
# Load role permissions
if user_tenant and user_tenant.role_id:
role_q = select(Role).where(Role.id == user_tenant.role_id)
role_result = await db.execute(role_q)
role = role_result.scalar_one_or_none()
async with db.begin_nested():
role_q = select(Role).where(Role.id == user_tenant.role_id)
role_result = await db.execute(role_q)
role = role_result.scalar_one_or_none()
if role:
allowed |= _normalize_permissions(role.permissions)
denied |= _normalize_permissions(role.denied_permissions)
max_version = max(max_version, role.permission_version)
# Merge field permissions
max_version = max(max_version, role.permission_version or 0)
# Merge field permissions using strictest-wins
if role.field_permissions:
for module, fields in role.field_permissions.items():
if isinstance(fields, dict):
if module not in field_perms:
field_perms[module] = {}
field_perms[module].update(fields)
_merge_field_permissions(field_perms, role.field_permissions)
# Also check built-in role string on UserTenant for backward compatibility
if user_tenant is not None and user_tenant.role_id is None:
legacy_role = user_tenant.role
# Also check legacy role string on User for backward compatibility
if user_tenant is None or user_tenant.role_id is None:
legacy_q = select(User.role).where(User.id == user_id)
legacy_result = await db.execute(legacy_q)
legacy_role = legacy_result.scalar_one_or_none()
if legacy_role == "admin":
allowed.add("*:*")
elif legacy_role == "editor":
allowed |= {"contacts:read", "contacts:write", "contacts:read", "contacts:write",
"users:read", "roles:read", "audit:read", "attachments:read",
"attachments:write", "workflows:read", "workflows:write",
"sequences:read", "sequences:write", "addresses:read", "addresses:write",
"taxes:read", "taxes:write", "currencies:read", "currencies:write",
"notifications:read", "notifications:write", "import_export:read",
"import_export:write",
"user_preferences:read", "user_preferences:write"}
allowed |= {
"contacts:read", "contacts:write",
"users:read", "roles:read", "audit:read",
"attachments:read", "attachments:write",
"workflows:read", "workflows:write",
"sequences:read", "sequences:write",
"addresses:read", "addresses:write",
"taxes:read", "taxes:write",
"currencies:read", "currencies:write",
"notifications:read", "notifications:write",
"import_export:read", "import_export:write",
"user_preferences:read", "user_preferences:write",
}
elif legacy_role == "viewer":
allowed |= {"contacts:read", "contacts:read", "users:read", "roles:read",
"audit:read", "attachments:read", "workflows:read", "sequences:read",
"addresses:read", "taxes:read", "currencies:read",
"notifications:read", "import_export:read",
"user_preferences:read", "user_preferences:write"}
allowed |= {
"contacts:read", "users:read", "roles:read",
"audit:read", "attachments:read", "workflows:read",
"sequences:read", "addresses:read", "taxes:read",
"currencies:read", "notifications:read",
"import_export:read",
"user_preferences:read", "user_preferences:write",
}
# Load group permissions
ug_q = select(UserGroup).where(
UserGroup.user_id == user_id,
UserGroup.tenant_id == tenant_id,
)
ug_result = await db.execute(ug_q)
user_groups = ug_result.scalars().all()
async with db.begin_nested():
ug_q = select(UserGroup).where(
UserGroup.user_id == user_id,
UserGroup.tenant_id == tenant_id,
)
ug_result = await db.execute(ug_q)
user_groups = ug_result.scalars().all()
if user_groups:
group_ids = [ug.group_id for ug in user_groups]
groups_q = select(Group).where(
Group.id.in_(group_ids),
Group.deleted_at.is_(None),
)
groups_result = await db.execute(groups_q)
groups = groups_result.scalars().all()
async with db.begin_nested():
groups_q = select(Group).where(
Group.id.in_(group_ids),
Group.deleted_at.is_(None),
)
groups_result = await db.execute(groups_q)
groups = groups_result.scalars().all()
for group in groups:
allowed |= _normalize_permissions(group.permissions)
denied |= _normalize_permissions(group.denied_permissions)
max_version = max(max_version, group.permission_version)
# Merge field permissions
max_version = max(max_version, group.permission_version or 0)
# Merge field permissions using strictest-wins
if group.field_permissions:
for module, fields in group.field_permissions.items():
if isinstance(fields, dict):
if module not in field_perms:
field_perms[module] = {}
field_perms[module].update(fields)
_merge_field_permissions(field_perms, group.field_permissions)
# Apply deny list
resolved = allowed - denied
@@ -226,15 +319,42 @@ async def get_cached_permissions(
user_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> dict[str, Any]:
"""Get resolved permissions from Redis cache or resolve from DB."""
"""Get resolved permissions from Redis cache or resolve from DB.
Validates the cached permission_version against the current DB version.
If they differ, the cache entry is stale and will be re-resolved.
"""
cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}"
raw = await redis.get(cache_key)
if raw is not None:
data = json.loads(raw)
return data
cached_version = data.get("version", -1)
# Cache miss — resolve from DB
# Validate cached version against current DB version
try:
current_version = await _get_current_permission_version(db, user_id, tenant_id)
except Exception:
logger.warning(
"Failed to query current permission_version for cache validation "
"(user=%s, tenant=%s) — using cached data",
user_id, tenant_id,
exc_info=True,
)
current_version = cached_version # assume cache is valid if we can't check
if cached_version == current_version:
return data
# Version mismatch — invalidate stale cache and re-resolve
logger.info(
"Permission cache version mismatch for user=%s tenant=%s "
"(cached=%s, current=%s) — re-resolving",
user_id, tenant_id, cached_version, current_version,
)
await redis.delete(cache_key)
# Cache miss or stale — resolve from DB
resolved = await resolve_permissions(db, user_id, tenant_id)
# Store in cache (convert sets to lists for JSON)
@@ -263,11 +383,33 @@ async def invalidate_all_user_permissions(
redis: aioredis.Redis,
tenant_id: uuid.UUID,
) -> None:
"""Invalidate permission cache for all users in a tenant (e.g. after role/group change)."""
"""Invalidate permission cache for all users in a tenant (e.g. after role/group change).
Uses SCAN (non-blocking) instead of KEYS to avoid blocking Redis.
"""
pattern = f"{CACHE_PREFIX}:*:{tenant_id}"
keys = await redis.keys(pattern)
if keys:
await redis.delete(*keys)
batch_size = 200
cursor: int | bytes | str = 0
deleted_count = 0
while True:
cursor, keys = await redis.scan(
cursor=cursor,
match=pattern,
count=batch_size,
)
if keys:
await redis.delete(*keys)
deleted_count += len(keys)
# SCAN returns cursor as bytes or int depending on redis-py version
cursor_int = int(cursor) if cursor else 0
if cursor_int == 0:
break
logger.info(
"Invalidated %d permission cache entries for tenant=%s",
deleted_count, tenant_id,
)
def check_permission(resolved: dict[str, Any], required: str) -> bool:
+66
View File
@@ -0,0 +1,66 @@
"""Generic finite state machine for domain entity status transitions.
Defines allowed state transitions for Contact and Workflow entities.
Usage in Commands:
from app.core.state_machine import contact_state_machine
contact_state_machine.transition(contact.status, "qualified")
"""
from __future__ import annotations
class StateMachineError(Exception):
"""Raised when an invalid state transition is attempted."""
class StateMachine:
"""Finite state machine that validates and executes state transitions.
Attributes:
transitions: Mapping from a state to the list of states it can transition to.
"""
def __init__(self, transitions: dict[str, list[str]]) -> None:
self.transitions: dict[str, list[str]] = transitions
def can_transition(self, current: str, target: str) -> bool:
"""Return True if transitioning from *current* to *target* is allowed."""
allowed = self.transitions.get(current, [])
return target in allowed
def transition(self, current: str, target: str) -> str:
"""Validate and return the new state.
Raises:
StateMachineError: if the transition is not allowed.
"""
if not self.can_transition(current, target):
raise StateMachineError(
f"Invalid state transition: '{current}' -> '{target}'. "
f"Allowed targets from '{current}': {self.transitions.get(current, [])}"
)
return target
# ── Contact lifecycle: lead → qualified → customer → inactive ──
# Allows skipping 'qualified' and reactivation from inactive.
contact_state_machine = StateMachine(
transitions={
"lead": ["qualified", "customer", "inactive"],
"qualified": ["customer", "lead", "inactive"],
"customer": ["inactive"],
"inactive": ["lead"],
}
)
# ── Workflow lifecycle: draft → active → paused → completed → cancelled ──
workflow_state_machine = StateMachine(
transitions={
"draft": ["active", "cancelled"],
"active": ["paused", "completed", "cancelled"],
"paused": ["active", "completed", "cancelled"],
"completed": [],
"cancelled": [],
}
)
+85 -11
View File
@@ -9,15 +9,18 @@ Configuration via environment variables:
- S3_SECRET_KEY: Secret key
- S3_REGION: Region (default: us-east-1)
- S3_SECURE: Use HTTPS (default: true)
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import tempfile
from abc import ABC, abstractmethod
from typing import Any
from typing import Any, AsyncIterator
import aiofiles
@@ -32,6 +35,11 @@ class StorageBackend(ABC):
"""Save data to storage at the given path. Returns the full storage path."""
...
@abstractmethod
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
"""Stream chunks to storage. Returns total bytes written."""
...
@abstractmethod
async def read(self, path: str) -> bytes:
"""Read data from storage at the given path."""
@@ -77,6 +85,18 @@ class LocalStorage(StorageBackend):
logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data))
return path
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
"""Stream chunks directly to a local file. Returns total bytes written."""
full_path = self._full_path(path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
total = 0
async with aiofiles.open(full_path, "wb") as f:
async for chunk in chunk_aiter:
await f.write(chunk)
total += len(chunk)
logger.debug("LocalStorage: streamed %s (%d bytes)", path, total)
return total
async def read(self, path: str) -> bytes:
full_path = self._full_path(path)
async with aiofiles.open(full_path, "rb") as f:
@@ -155,25 +175,33 @@ class S3Storage(StorageBackend):
logger.error("S3Storage: failed to connect to %s: %s", self.endpoint, e)
raise
async def save(self, path: str, data: bytes) -> str:
from io import BytesIO
# ── Sync helper methods (called via asyncio.to_thread) ──────────────────
def _save_sync(self, path: str, data: bytes) -> str:
client = self._get_client()
client.put_object(
bucket_name=self.bucket,
object_name=path,
data=BytesIO(data),
data=io.BytesIO(data),
length=len(data),
)
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
return path
async def read(self, path: str) -> bytes:
def _put_file_sync(self, object_name: str, file_path: str) -> str:
client = self._get_client()
client.fput_object(self.bucket, object_name, file_path)
return object_name
def _read_sync(self, path: str) -> bytes:
client = self._get_client()
response = client.get_object(self.bucket, path)
return response.read()
try:
return response.read()
finally:
response.close()
response.release_conn()
async def delete(self, path: str) -> bool:
def _delete_sync(self, path: str) -> bool:
client = self._get_client()
try:
client.remove_object(self.bucket, path)
@@ -181,7 +209,7 @@ class S3Storage(StorageBackend):
except Exception:
return False
async def exists(self, path: str) -> bool:
def _exists_sync(self, path: str) -> bool:
client = self._get_client()
try:
client.stat_object(self.bucket, path)
@@ -189,17 +217,63 @@ class S3Storage(StorageBackend):
except Exception:
return False
async def get_url(self, path: str, expires: int = 3600) -> str:
def _get_url_sync(self, path: str, expires: int) -> str:
from datetime import timedelta
client = self._get_client()
return client.presigned_get_object(self.bucket, path, expires=timedelta(seconds=expires))
async def list_files(self, prefix: str) -> list[str]:
def _list_files_sync(self, prefix: str) -> list[str]:
client = self._get_client()
objects = client.list_objects(self.bucket, prefix=prefix, recursive=True)
return [obj.object_name for obj in objects]
# ── Async public API (wraps sync calls in asyncio.to_thread) ─────────────
async def save(self, path: str, data: bytes) -> str:
result = await asyncio.to_thread(self._save_sync, path, data)
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
return result
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
"""Stream chunks to a temp file, then upload to S3 via fput_object.
This avoids loading the entire file into RAM. The temp file is
cleaned up after upload.
"""
tmp_fd, tmp_path = tempfile.mkstemp(prefix="s3_upload_")
os.close(tmp_fd)
total = 0
try:
async with aiofiles.open(tmp_path, "wb") as f:
async for chunk in chunk_aiter:
await f.write(chunk)
total += len(chunk)
await asyncio.to_thread(self._put_file_sync, path, tmp_path)
logger.debug("S3Storage: streamed %s (%d bytes)", path, total)
return total
finally:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
logger.warning("S3Storage: failed to clean up temp file %s", tmp_path)
async def read(self, path: str) -> bytes:
return await asyncio.to_thread(self._read_sync, path)
async def delete(self, path: str) -> bool:
return await asyncio.to_thread(self._delete_sync, path)
async def exists(self, path: str) -> bool:
return await asyncio.to_thread(self._exists_sync, path)
async def get_url(self, path: str, expires: int = 3600) -> str:
return await asyncio.to_thread(self._get_url_sync, path, expires)
async def list_files(self, prefix: str) -> list[str]:
return await asyncio.to_thread(self._list_files_sync, prefix)
# ─── Factory ───
+106 -2
View File
@@ -14,6 +14,73 @@ from app.core.job_registry import get_all_jobs, get_job, register_job
logger = logging.getLogger(__name__)
# ── Distributed lock helpers ─────────────────────────────────────────────────
# When multiple worker replicas run concurrently, cron jobs must not fire
# on every replica. We use a short-lived Redis SET NX lock per cron call
# so only one replica actually executes the job.
import redis.asyncio as aioredis # noqa: E402
import uuid # noqa: E402
async def _acquire_cron_lock(job_name: str, ttl_seconds: int = 120) -> str | None:
"""Try to acquire a distributed lock for a cron job.
Returns a lock token (random UUID) if acquired, or None if another
replica already holds the lock. The lock auto-expires after
*ttl_seconds* to avoid deadlocks if a worker crashes mid-job.
"""
settings = get_settings()
client = aioredis.from_url(settings.redis_url)
token = str(uuid.uuid4())
lock_key = f"leocrm:cron_lock:{job_name}"
try:
acquired = await client.set(lock_key, token, nx=True, ex=ttl_seconds)
return token if acquired else None
finally:
await client.aclose()
async def _release_cron_lock(job_name: str, token: str) -> None:
"""Release a previously acquired cron lock using a safe compare-and-delete."""
settings = get_settings()
client = aioredis.from_url(settings.redis_url)
lock_key = f"leocrm:cron_lock:{job_name}"
try:
# Lua script ensures we only delete if the token matches (avoid
# releasing a lock that was already expired and re-acquired).
script = (
b"if redis.call('get', KEYS[1]) == ARGV[1] "
b"then return redis.call('del', KEYS[1]) "
b"else return 0 end"
)
await client.eval(script, 1, lock_key, token.encode())
finally:
await client.aclose()
def _wrap_cron_with_lock(job_name: str, func: Any, ttl_seconds: int = 120) -> Any:
"""Wrap a cron callable so it acquires a distributed lock first.
If the lock cannot be acquired (another replica is handling it), the
wrapped function is silently skipped.
"""
import functools
@functools.wraps(func)
async def _locked_wrapper(ctx: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
token = await _acquire_cron_lock(job_name, ttl_seconds=ttl_seconds)
if token is None:
logger.debug("Cron job '%s' skipped — lock held by another replica", job_name)
return None
try:
return await func(ctx, *args, **kwargs)
finally:
await _release_cron_lock(job_name, token)
return _locked_wrapper
def _get_redis_settings() -> RedisSettings:
"""Get Redis settings from app config."""
settings = get_settings()
@@ -70,6 +137,32 @@ def _lazy_register_plugin_jobs() -> None:
_lazy_register_plugin_jobs()
# ── Outbox processor job ────────────────────────────────────────────────────
async def process_outbox_job(ctx: dict[str, Any]) -> None:
"""Poll the transactional outbox and publish pending events.
Uses a distributed Redis lock so only one worker replica processes the
outbox at a time. Runs every 5 seconds.
"""
from app.core.db import get_session_factory
from app.core.outbox import process_outbox_batch
factory = get_session_factory()
async with factory() as db:
try:
count = await process_outbox_batch(db, batch_size=50)
if count:
logger.info("Outbox: published %d events", count)
except Exception:
logger.error("Outbox processing failed", exc_info=True)
await db.rollback()
# Register the outbox job so it appears in get_all_jobs()
register_job("process_outbox", process_outbox_job)
class WorkerSettings:
"""ARQ worker settings."""
functions = get_all_jobs()
@@ -80,6 +173,17 @@ class WorkerSettings:
job_timeout = 300
queue_name = "arq:queue"
cron_jobs = [
cron(get_job("scheduler_tick"), minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
cron(get_job("tasks_due_reminder"), hour=8, minute=0),
cron(
_wrap_cron_with_lock("scheduler_tick", get_job("scheduler_tick")),
minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55},
),
cron(
_wrap_cron_with_lock("tasks_due_reminder", get_job("tasks_due_reminder")),
hour=8, minute=0,
),
# Outbox processor — every 5 seconds, guarded by distributed lock
cron(
_wrap_cron_with_lock("process_outbox", process_outbox_job, ttl_seconds=30),
second="*/5",
),
]
+59 -35
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import uuid
from typing import Any
@@ -13,6 +14,27 @@ from app.config import get_settings
from app.core.auth import get_redis, get_session_data, refresh_session_ttl
from app.core.db import get_db, set_tenant_context
logger = logging.getLogger(__name__)
# Known write-permission modules — used by require_write() to check
# specific permissions instead of broad wildcards like *:write
_WRITE_PERMISSIONS = [
"contacts:write",
"contacts:create",
"users:write",
"roles:write",
"audit:write",
"attachments:write",
"workflows:write",
"sequences:write",
"addresses:write",
"taxes:write",
"currencies:write",
"notifications:write",
"import_export:write",
"user_preferences:write",
]
async def get_redis_dep() -> aioredis.Redis:
"""FastAPI dependency for Redis client."""
@@ -24,40 +46,13 @@ async def get_current_user(
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
) -> dict[str, Any]:
"""Get the current authenticated user from session cookie or internal headers.
"""Get the current authenticated user from session cookie.
Returns session data dict with user_id, tenant_id, email, name, role,
and resolved permissions from Redis cache.
Supports internal calls via X-Internal-Call: true header with
X-Tenant-Id and X-User-Id headers (for AI tool API access).
"""
settings = get_settings()
# Check for internal call (AI tool access)
if request.headers.get("X-Internal-Call") == "true":
tenant_id_str = request.headers.get("X-Tenant-Id", "")
user_id_str = request.headers.get("X-User-Id", "")
if tenant_id_str and user_id_str:
try:
tenant_id = uuid.UUID(tenant_id_str)
user_id = uuid.UUID(user_id_str)
await set_tenant_context(db, tenant_id)
from app.core.permissions import get_cached_permissions
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
return {
"user_id": user_id_str,
"tenant_id": tenant_id_str,
"permissions": resolved.get("permissions", []),
"denied_permissions": resolved.get("denied", []),
"field_permissions": resolved.get("field_permissions", {}),
"is_system_admin": resolved.get("is_system_admin", False),
"is_active": True,
}
except (ValueError, Exception):
pass # Fall through to session cookie auth
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
raise HTTPException(
@@ -101,14 +96,29 @@ async def get_current_user(
async def require_admin(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require admin role (legacy + new permission system)."""
if current_user.get("is_system_admin") or current_user.get("role") == "admin":
"""Require admin role (legacy + new permission system).
Legacy role string 'admin' is deprecated — log a warning when used.
New system uses is_system_admin or *:* permission.
"""
if current_user.get("is_system_admin"):
return current_user
# Also check via permission system
# Legacy role string fallback — deprecated
if current_user.get("role") == "admin":
logger.warning(
"Legacy role string 'admin' used for user=%s — deprecated, "
"migrate to is_system_admin or *:* permission",
current_user.get("user_id"),
)
return current_user
# New permission system check
from app.core.permissions import check_permission
if check_permission(current_user, "*:*"):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Admin access required", "code": "forbidden"},
@@ -118,17 +128,31 @@ async def require_admin(
async def require_write(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require write permission (admin, editor, or custom role with write perms)."""
"""Require write permission (admin, editor, or custom role with write perms).
Legacy role strings 'admin'/'editor' are deprecated — log a warning when used.
New system checks specific module:write permissions instead of broad wildcards.
"""
if current_user.get("is_system_admin"):
return current_user
# Legacy role string fallback — deprecated
role = current_user.get("role", "viewer")
if role in ("admin", "editor"):
logger.warning(
"Legacy role string '%s' used for user=%s in require_write — deprecated, "
"migrate to specific module:write permissions",
role, current_user.get("user_id"),
)
return current_user
# Check via permission system for custom roles
# Check via permission system for specific write permissions
from app.core.permissions import check_permission
if check_permission(current_user, "*:write") or check_permission(current_user, "*:create"):
return current_user
for perm in _WRITE_PERMISSIONS:
if check_permission(current_user, perm):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Write access required", "code": "forbidden"},
+34 -29
View File
@@ -100,6 +100,13 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown."""
# Initialize global Redis client (singleton)
from app.core.auth import init_redis, close_redis
from app.core.jobs import init_job_pool, close_job_pool
await init_redis()
await init_job_pool()
# Initialize service container
container = get_container()
await container.initialize()
@@ -109,7 +116,7 @@ async def lifespan(app: FastAPI):
registry.initialize(get_engine(), app)
registry.discover_builtins()
# Auto-install and activate all discovered builtin plugins
# Install discovered builtin plugins and activate only those marked active in DB
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.models.plugin import Plugin as PluginModel
@@ -131,18 +138,18 @@ async def lifespan(app: FastAPI):
plugin_record = result.scalar_one_or_none()
if plugin_record is None:
# Create DB record for this builtin plugin
# Create DB record for this builtin plugin — inactive by default (except core)
plugin_record = PluginModel(
name=name,
display_name=plugin.manifest.display_name,
version=plugin.manifest.version,
status="installed",
active=True,
active=plugin.manifest.is_core, # Only core plugins auto-activate
is_core=plugin.manifest.is_core,
)
db.add(plugin_record)
await db.flush()
logger.info(f"Created plugin record: {name}")
logger.info(f"Created plugin record: {name} (core={plugin.manifest.is_core})")
# Run migrations if not yet applied
if plugin.manifest.migrations:
@@ -151,7 +158,17 @@ async def lifespan(app: FastAPI):
db, name, plugin.manifest.migrations
)
except Exception as exc:
logger.warning(f"Migration for {name}: {exc}")
logger.error(f"Migration FAILED for {name}: {exc}")
if plugin_record.active:
logger.error(f"Deactivating plugin {name} due to migration failure")
plugin_record.active = False
plugin_record.status = "migration_failed"
continue # Skip activation if migration fails
# Only activate plugins that are marked active in DB
if not plugin_record.active:
logger.info(f"Plugin {name} is inactive — skipping activation")
continue
# Activate plugin and register routes
try:
@@ -160,13 +177,14 @@ async def lifespan(app: FastAPI):
router_module = importlib.import_module(route_def.module)
router = getattr(router_module, route_def.router_attr)
app.include_router(router)
plugin_record.active = True
plugin_record.status = "active"
print(f"[STARTUP] Activated plugin: {name} ({len(plugin.manifest.routes)} routes)", flush=True)
logger.info(f"Activated plugin: {name} ({len(plugin.manifest.routes)} routes)")
except Exception as exc:
print(f"[STARTUP] Failed to activate plugin {name}: {exc}", flush=True)
logger.warning(f"Failed to activate plugin {name}: {exc}")
logger.error(f"Failed to activate plugin {name}: {exc}")
plugin_record.active = False
plugin_record.status = "activation_failed"
await db.commit()
@@ -186,15 +204,15 @@ async def lifespan(app: FastAPI):
init_permission_registry(active_plugin_names)
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
# Register field definitions from active plugins
# Register field definitions from active plugins only
from app.core.permission_registry import get_permission_registry
for name in registry._plugins:
for name in active_plugin_names:
plugin = registry.get_plugin(name)
if plugin:
field_defs = plugin.get_field_definitions()
if field_defs:
get_permission_registry().register_field_definitions(name, field_defs)
logger.info("Field definitions registered for %d plugins", len(registry._plugins))
logger.info("Field definitions registered for %d active plugins", len(active_plugin_names))
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants
from app.core.seeds import seed_default_data
@@ -210,6 +228,9 @@ async def lifespan(app: FastAPI):
yield
# Shutdown: close global Redis and ARQ pool
await close_job_pool()
await close_redis()
await close_engine()
@@ -314,24 +335,8 @@ def create_app() -> FastAPI:
app.include_router(custom_fields.router)
app.include_router(saved_filters.router)
# ── Register plugin routes (before SPA catch-all) ──────────────────
registry = get_registry()
try:
registry.discover_builtins()
for name in registry._plugins:
plugin = registry.get_plugin(name)
if plugin is None:
continue
for route_def in plugin.manifest.routes:
try:
router_module = importlib.import_module(route_def.module)
router = getattr(router_module, route_def.router_attr)
app.include_router(router)
logger.info(f"Registered plugin routes: {name}")
except Exception as exc:
logger.warning(f"Failed to register routes for plugin {name}: {exc}")
except Exception as exc:
logger.warning(f"Plugin discovery failed: {exc}")
# ── Plugin routes are registered in lifespan() after activation status is loaded ──
# Do NOT register plugin routes here — lifespan() handles it for active plugins only
# ── Serve frontend static files (SPA) ──────────────────────────────
# Mount built frontend assets (JS, CSS, images)
@@ -351,7 +356,7 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=404, detail="Not Found")
# Block path traversal and system file access
blocked_prefixes = ("var/log/", "error/", "error_log", "var/", "etc/", "proc/", "sys/")
if full_path.startswith(blocked_prefixes) or "/../" in full_path or full_path.endswith("/.."):
if full_path.startswith(blocked_prefixes) or ".." in full_path:
raise HTTPException(status_code=404, detail="Not Found")
index_path = os.path.join(frontend_dist, "index.html")
if os.path.isfile(index_path):
+19 -11
View File
@@ -8,17 +8,20 @@ ansprechpartner (company employees / contact persons).
from __future__ import annotations
import uuid
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Computed,
ForeignKey,
ForeignKey,
Index,
Numeric,
String,
Text,
Float,
JSON,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -37,6 +40,8 @@ class Contact(Base, TenantMixin):
__tablename__ = "contacts"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contacts_tenant_type", "tenant_id", "type"),
Index("ix_contacts_tenant_name", "tenant_id", "name"),
@@ -53,10 +58,13 @@ class Contact(Base, TenantMixin):
# ── Identity & Type ──
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
surfix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
@@ -116,12 +124,12 @@ class Contact(Base, TenantMixin):
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
# ── Discounts ──
discount_crew: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_transport: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_rental: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_sale: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_subrent: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_total: Mapped[float] = mapped_column(Float, nullable=False, default=0)
discount_crew: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
# ── Geo ──
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
@@ -151,7 +159,7 @@ class Contact(Base, TenantMixin):
)
# ── Custom fields ──
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
# ── FTS ──
search_tsv: Mapped[Any] = mapped_column(
@@ -222,7 +230,7 @@ class ContactPerson(Base, TenantMixin):
# ── Other ──
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
+56
View File
@@ -0,0 +1,56 @@
"""SQLAlchemy model for the transactional event outbox table."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
class EventOutbox(Base):
"""Row in the ``event_outbox`` table.
Each row represents a domain event that was written within a business
transaction and is waiting to be published to the in-process event bus
by the outbox worker.
"""
__tablename__ = "event_outbox"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
primary_key=True,
server_default=func.gen_random_uuid(),
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), nullable=False, index=True,
)
event_name: Mapped[str] = mapped_column(String(255), nullable=False)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, server_default="pending",
)
attempts: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0",
)
max_attempts: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="5",
)
next_retry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
+19 -14
View File
@@ -6,36 +6,28 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
from app.core.db import Base, SoftDeleteMixin, TimestampMixin
class User(Base, TenantMixin):
"""User entity — belongs to a tenant, can be member of multiple tenants."""
class User(Base, TimestampMixin, SoftDeleteMixin):
"""User entity — globally unique email, tenant membership via UserTenant."""
__tablename__ = "users"
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
role_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("roles.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
is_system_admin: Mapped[bool] = mapped_column(
@@ -44,7 +36,13 @@ class User(Base, TenantMixin):
class UserTenant(Base):
"""N:M association — user membership in tenants."""
"""N:M association — user membership in tenants.
Single source of truth for tenant membership and role assignment.
``role`` is a built-in role string (admin/editor/viewer).
``role_id`` links to a custom Role record for granular RBAC.
``status`` tracks membership lifecycle (active/invited/disabled).
"""
__tablename__ = "user_tenants"
@@ -55,12 +53,19 @@ class UserTenant(Base):
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
role_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("roles.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
@@ -0,0 +1,60 @@
"""Public contract for the ai_assistant plugin.
Exposes only the symbols that other builtins plugins need:
- Tool registry (register, unregister, list tools)
- get_default_provider (for LLM provider lookup)
Importers should use::
from app.plugins.builtins.contracts import get_contract
ai = get_contract("ai_assistant")
if ai:
registry = ai.get_tool_registry()
registry.register("my_tool", ...)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.tool_registry import (
AITool,
ToolRegistry,
get_tool_registry,
)
from app.plugins.builtins.contracts import get_contract_registry
class AIAssistantContract:
"""Public API surface for the ai_assistant plugin.
Exposes the tool registry and the default-provider lookup so that
other plugins can register AI tools and obtain the tenant's default
LLM provider without importing internal modules.
"""
contract_name = "ai_assistant"
# ─── tool registry ───
get_tool_registry = staticmethod(get_tool_registry)
ToolRegistry = ToolRegistry
AITool = AITool
# ─── provider lookup ───
get_default_provider = staticmethod(get_default_provider)
# ─── self-registration ───
_contract = AIAssistantContract()
get_contract_registry().register("ai_assistant", _contract)
__all__ = [
"AIAssistantContract",
"AITool",
"ToolRegistry",
"get_tool_registry",
"get_default_provider",
]
@@ -14,7 +14,7 @@ from typing import Any
import litellm
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
logger = logging.getLogger(__name__)
@@ -169,7 +169,7 @@ class AIParticipantHandler(ParticipantHandler):
current_message: dict[str, Any],
) -> list[dict[str, str]]:
"""Build a messages array from the conversation history for the LLM."""
from app.plugins.builtins.kommunikation.services import get_messages
from app.plugins.builtins.kommunikation.contracts import get_messages
messages: list[dict[str, str]] = []
@@ -232,7 +232,7 @@ class AIParticipantHandler(ParticipantHandler):
# Load conversation
try:
from app.plugins.builtins.kommunikation.services import get_conversation
from app.plugins.builtins.kommunikation.contracts import get_conversation
async with create_db_session(tenant_id) as db:
# We need a user_id to load the conversation — use the sender_id from payload
@@ -249,7 +249,7 @@ class AIParticipantHandler(ParticipantHandler):
return
# Parse mentions from message content
from app.plugins.builtins.kommunikation.services import parse_mentions
from app.plugins.builtins.kommunikation.contracts import parse_mentions
mentions = parse_mentions(message_content)
@@ -265,7 +265,7 @@ class AIParticipantHandler(ParticipantHandler):
# If we got a response, send it to the conversation
if response_messages:
from app.plugins.builtins.kommunikation.services import send_message
from app.plugins.builtins.kommunikation.contracts import send_message
for resp_msg in response_messages:
await send_message(
+2 -2
View File
@@ -80,7 +80,7 @@ class AIAssistantPlugin(BasePlugin):
from app.plugins.builtins.ai_assistant.participant_handler import (
AIParticipantHandler,
)
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -113,7 +113,7 @@ class AIAssistantPlugin(BasePlugin):
"""Deactivate plugin: unregister participant and event subscriptions."""
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import create_db_session
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
logger = logging.getLogger(__name__)
@@ -101,9 +101,9 @@ async def search_related_handler(arguments: dict[str, Any], context: dict[str, A
entity_id = uuid.UUID(arguments["entity_id"])
limit = arguments.get("limit", 5)
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
find_similar_all_types = _search.hybrid_search
similar = await find_similar_all_types(
db, entity_type, entity_id, tenant_id, limit=limit
@@ -163,10 +163,10 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
try:
from datetime import UTC, datetime
from app.plugins.builtins.calendar.models import (
CalendarEntry,
CalendarEntryLink,
)
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
CalendarEntry = _cal.CalendarEntry
CalendarEntryLink = _cal.CalendarEntryLink
db, tenant_id, _ = await _get_db_and_tenant(context)
entity_type = arguments["entity_type"]
@@ -194,7 +194,9 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
async def hybrid_search_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
"""Perform hybrid search via unified_search search_engine."""
try:
from app.plugins.builtins.unified_search.search_engine import hybrid_search
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
hybrid_search = _search.hybrid_search
db, tenant_id, _ = await _get_db_and_tenant(context)
query = arguments["query"]
+6 -5
View File
@@ -30,7 +30,7 @@ from app.plugins.builtins.ai_proactive.services import (
get_user_settings,
push_suggestion,
)
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
logger = logging.getLogger(__name__)
@@ -175,9 +175,10 @@ async def deep_analysis(
# Similar entities via unified_search
try:
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
hybrid_search = _search.hybrid_search
find_similar_all_types = _search.hybrid_search # alias
extended_context["similar"] = await find_similar_all_types(
db, entity_type, eid, tid, limit=5
@@ -335,7 +336,7 @@ async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
try:
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.services import (
from app.plugins.builtins.kommunikation.contracts import (
create_plugin_room,
send_message,
)
@@ -11,7 +11,7 @@ import uuid
from typing import Any
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
logger = logging.getLogger(__name__)
@@ -173,7 +173,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
# Load conversation
try:
from app.plugins.builtins.kommunikation.services import get_conversation
from app.plugins.builtins.kommunikation.contracts import get_conversation
async with create_db_session(tenant_id) as db:
if not sender_id_str:
@@ -188,7 +188,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
return
# Parse mentions from message content
from app.plugins.builtins.kommunikation.services import parse_mentions
from app.plugins.builtins.kommunikation.contracts import parse_mentions
mentions = parse_mentions(message_content)
@@ -204,7 +204,7 @@ class AIProactiveParticipantHandler(ParticipantHandler):
# If we got a response, send it to the conversation
if response_messages:
from app.plugins.builtins.kommunikation.services import send_message
from app.plugins.builtins.kommunikation.contracts import send_message
for resp_msg in response_messages:
await send_message(
+4 -4
View File
@@ -59,7 +59,7 @@ class AIProactivePlugin(BasePlugin):
from app.plugins.builtins.ai_proactive.context_tools import (
register_context_tools,
)
from app.plugins.builtins.ai_assistant.tool_registry import (
from app.plugins.builtins.ai_assistant.contracts import (
get_tool_registry,
)
@@ -73,7 +73,7 @@ class AIProactivePlugin(BasePlugin):
from app.plugins.builtins.ai_proactive.participant_handler import (
AIProactiveParticipantHandler,
)
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -87,7 +87,7 @@ class AIProactivePlugin(BasePlugin):
"""Unregister tools, event listeners, and participant."""
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.participant_registry import (
from app.plugins.builtins.kommunikation.contracts import (
get_participant_registry,
)
@@ -99,7 +99,7 @@ class AIProactivePlugin(BasePlugin):
self._proactive_handler = None
try:
from app.plugins.builtins.ai_assistant.tool_registry import (
from app.plugins.builtins.ai_assistant.contracts import (
get_tool_registry,
)
+15 -9
View File
@@ -40,7 +40,7 @@ async def _get_llm_api_key(db: AsyncSession, tenant_id: uuid.UUID) -> tuple[str
Returns (api_key, base_url, provider_type).
"""
try:
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
provider = await get_default_provider(db, tenant_id)
if provider and provider.api_key:
return provider.api_key, provider.base_url, provider.provider_type
@@ -167,7 +167,7 @@ async def gather_context(
context["contact"] = _serialize_row(contact) if contact else None
# Last 10 mails
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
mail_result = await db.execute(
select(Mail)
@@ -203,7 +203,10 @@ async def gather_context(
context["companies"] = companies
# Upcoming calendar events
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
CalendarEntry = _cal.CalendarEntry
CalendarEntryLink = _cal.CalendarEntryLink
now = datetime.now(UTC)
event_result = await db.execute(
@@ -229,7 +232,7 @@ async def gather_context(
context["activities"] = [_serialize_row(a) for a in audit_result.scalars().all()]
elif entity_type == "mail":
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
result = await db.execute(
select(Mail)
@@ -303,7 +306,7 @@ async def gather_context(
context["contacts"] = contacts
# Mails for this contact
from app.plugins.builtins.mail.models import Mail
from app.plugins.builtins.mail.contracts import Mail
mail_result = await db.execute(
select(Mail)
@@ -315,7 +318,10 @@ async def gather_context(
context["mails"] = [_serialize_row(m) for m in mail_result.scalars().all()]
# Upcoming events
from app.plugins.builtins.calendar.models import CalendarEntry, CalendarEntryLink
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
CalendarEntry = _cal.CalendarEntry
CalendarEntryLink = _cal.CalendarEntryLink
now = datetime.now(UTC)
event_result = await db.execute(
@@ -356,9 +362,9 @@ async def gather_context(
# Semantically similar entities via unified_search
try:
from app.plugins.builtins.unified_search.search_engine import (
find_similar_all_types,
)
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
find_similar_all_types = _search.hybrid_search
context["similar"] = await find_similar_all_types(
db, entity_type, entity_id, tenant_id, limit=3
@@ -55,8 +55,8 @@ async def send_agent_message(
# 2. Create a kommunikation message in a dedicated agent room
try:
from app.plugins.builtins.kommunikation.models import Message, Room
from app.plugins.builtins.kommunikation.services import RoomService
from app.plugins.builtins.kommunikation.contracts import Message, Room
from app.plugins.builtins.kommunikation.contracts import RoomService
# Find or create the agent-to-agent room
room_name = f"agent:{from_agent_id}:{target_agent.id}"
@@ -129,7 +129,7 @@ async def send_agent_message(
def register_agent_comm_tool():
"""Register the send_agent_message tool in the global tool registry."""
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
registry = get_tool_registry()
@@ -188,7 +188,7 @@ def register_agent_comm_tool():
def unregister_agent_comm_tool():
"""Unregister the send_agent_message tool."""
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
registry = get_tool_registry()
registry.unregister("send_agent_message")
@@ -149,7 +149,7 @@ async def list_tools(
):
"""List available tools from the tool registry."""
try:
from app.plugins.builtins.ai_assistant.tool_registry import (
from app.plugins.builtins.ai_assistant.contracts import (
get_tool_registry,
)
@@ -191,7 +191,7 @@ async def run_agent(
# Execute tool calls if LLM returned function calls
if hasattr(response.choices[0].message, "tool_calls") and response.choices[0].message.tool_calls:
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
registry = get_tool_registry()
tool_call_count: dict[str, int] = {}
+2 -2
View File
@@ -137,7 +137,7 @@ class AutomationPlugin(BasePlugin):
logger.exception("Failed to register agent communication tool")
# Register MiniApps from manifest
try:
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
for miniapp in self.manifest.miniapps:
registry.register(
@@ -170,7 +170,7 @@ class AutomationPlugin(BasePlugin):
logger.exception("Failed to unregister agent communication tool")
# Unregister MiniApps
try:
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
registry.unregister_plugin(self.manifest.name)
logger.info("Unregistered MiniApps for plugin '%s'", self.manifest.name)
+3 -3
View File
@@ -179,7 +179,7 @@ async def list_miniapps(
current_user: dict[str, Any] = Depends(get_current_user),
):
"""List custom MiniApps from plugin config."""
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
items = registry.list_apps()
return {"items": items, "total": len(items)}
@@ -196,7 +196,7 @@ async def create_miniapp(
current_user: dict[str, Any] = Depends(get_current_user),
):
"""Create a custom MiniApp definition."""
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
registry.register(
app_id=data.app_id,
@@ -225,7 +225,7 @@ async def delete_miniapp(
current_user: dict[str, Any] = Depends(get_current_user),
):
"""Delete a custom MiniApp definition."""
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry
registry = MiniAppRegistry()
registry.unregister(app_id)
return {"status": "ok"}
@@ -0,0 +1,23 @@
"""Calendar plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
class CalendarContract:
"""Public contract for the calendar plugin."""
Calendar = Calendar
CalendarEntry = CalendarEntry
CalendarEntryLink = CalendarEntryLink
_contract_instance: CalendarContract | None = None
def get_contract() -> CalendarContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = CalendarContract()
return _contract_instance
+158
View File
@@ -0,0 +1,158 @@
"""Central Contract Registry for inter-plugin communication.
Instead of plugins importing directly from each other's internal modules
(e.g. ``from app.plugins.builtins.kommunikation.services import send_message``),
plugins expose a **contract** module (``contracts.py``) that re-exports only
the public symbols other plugins need.
Usage pattern::
from app.plugins.builtins.contracts import get_contract
komm_contract = get_contract("kommunikation")
if komm_contract:
await komm_contract.send_message(db, ...)
This breaks the tight coupling: plugins depend on the contract surface area,
not on internal module paths. If a plugin is absent, ``get_contract``
returns ``None`` and the caller can gracefully skip the feature.
Contracts are registered lazily on first access (import of the plugin's
``contracts`` module). A plugin may also register itself explicitly during
``on_activate``.
"""
from __future__ import annotations
import importlib
import logging
from typing import Any, Protocol, runtime_checkable
logger = logging.getLogger(__name__)
class ContractError(Exception):
"""Raised when a contract cannot be fulfilled."""
@runtime_checkable
class PluginContract(Protocol):
"""Marker protocol for plugin contract objects.
A contract can be any module or object that a plugin exposes via its
``contracts.py``. The registry stores whatever the plugin registers.
"""
contract_name: str
class ContractRegistry:
"""Thread-safe registry for plugin contracts.
A contract is identified by its plugin slug (e.g. ``"kommunikation"``).
"""
_instance: ContractRegistry | None = None
def __new__(cls) -> ContractRegistry:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._contracts: dict[str, Any] = {}
cls._instance._loaded: set[str] = set()
return cls._instance
# ─── registration ───
def register(self, plugin_name: str, contract: Any) -> None:
"""Register or replace a contract for a plugin."""
self._contracts[plugin_name] = contract
self._loaded.add(plugin_name)
logger.debug("Contract registered for plugin '%s'", plugin_name)
def unregister(self, plugin_name: str) -> None:
"""Remove a contract (e.g. when the plugin is deactivated)."""
self._contracts.pop(plugin_name, None)
self._loaded.discard(plugin_name)
# ─── lookup ───
def get_contract(self, plugin_name: str) -> Any | None:
"""Return the contract for *plugin_name* or ``None``.
On first access the registry attempts to lazy-load the plugin's
``contracts`` module, which will register itself on import.
"""
if plugin_name in self._contracts:
return self._contracts[plugin_name]
if plugin_name not in self._loaded:
self._try_lazy_load(plugin_name)
return self._contracts.get(plugin_name)
def require_contract(self, plugin_name: str) -> Any:
"""Like :meth:`get_contract` but raise if unavailable."""
contract = self.get_contract(plugin_name)
if contract is None:
raise ContractError(
f"Plugin '{plugin_name}' has no registered contract. "
"Ensure the plugin is installed and activated."
)
return contract
def list_available(self) -> list[str]:
"""Return slugs of all plugins with registered contracts."""
return sorted(self._contracts.keys())
# ─── internals ───
def _try_lazy_load(self, plugin_name: str) -> None:
"""Attempt to import ``app.plugins.builtins.<plugin>.contracts``.
If the module is already in ``sys.modules`` (e.g. after a registry
reset in tests), reload it so the registration code re-executes.
"""
import sys
self._loaded.add(plugin_name) # mark as attempted even on failure
module_path = f"app.plugins.builtins.{plugin_name}.contracts"
try:
if module_path in sys.modules:
importlib.reload(sys.modules[module_path])
else:
importlib.import_module(module_path)
logger.debug("Lazy-loaded contract module '%s'", module_path)
except ImportError:
# Plugin not installed or has no contracts module — fine.
logger.debug("No contract module for '%s'", plugin_name)
except Exception:
logger.exception("Failed to load contract module '%s'", module_path)
def _reset_for_testing(self) -> None:
"""Clear all state — for unit tests only."""
self._contracts.clear()
self._loaded.clear()
# ─── module-level helpers ───
def get_contract_registry() -> ContractRegistry:
"""Return the global :class:`ContractRegistry` singleton."""
return ContractRegistry()
def get_contract(plugin_name: str) -> Any | None:
"""Convenience wrapper: ``get_contract_registry().get_contract(name)``."""
return get_contract_registry().get_contract(plugin_name)
def require_contract(plugin_name: str) -> Any:
"""Convenience wrapper that raises if the contract is missing."""
return get_contract_registry().require_contract(plugin_name)
def reset_contract_registry_for_testing() -> ContractRegistry:
"""Return a fresh singleton — for unit tests only."""
reg = get_contract_registry()
reg._reset_for_testing()
return reg
+22
View File
@@ -0,0 +1,22 @@
"""DMS plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.dms.models import File as DmsFile, Folder
class DmsContract:
"""Public contract for the DMS plugin."""
DmsFile = DmsFile
Folder = Folder
_contract_instance: DmsContract | None = None
def get_contract() -> DmsContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = DmsContract()
return _contract_instance
+1
View File
@@ -64,4 +64,5 @@ class File(Base, TenantMixin):
mime_type: Mapped[str] = mapped_column(String(255), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+50 -13
View File
@@ -34,7 +34,8 @@ from app.plugins.builtins.dms.schemas import (
ShareRemoveRequest,
ShareRequest,
)
from app.plugins.builtins.permissions.models import Permission
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
from app.plugins.builtins.permissions.models import Permission # TODO: migrate to contract
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
@@ -68,6 +69,28 @@ def _get_file_extension(filename: str) -> str:
return os.path.splitext(filename)[1].lower()
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename for safe use in Content-Disposition headers."""
import re
# Extract basename only (strip any path components)
safe = os.path.basename(filename.replace('\\', '/'))
# Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode)
safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe)
# Collapse consecutive dots (path traversal prevention)
safe = re.sub(r'\.{2,}', '_', safe)
# Collapse multiple spaces
safe = re.sub(r' {2,}', ' ', safe)
# Strip leading dots and whitespace
safe = safe.lstrip('.').strip()
# Limit length
if len(safe) > 200:
name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '')
safe = name[:200] + ('.' + ext if ext else '')
return safe or 'file'
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming uploads
# ─── Folders ───
@@ -418,14 +441,26 @@ async def upload_file(
if folder_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
# Read file content
content = await file.read()
file_size = len(content)
# Stream file in chunks — avoid loading entire file into RAM
import hashlib
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
sha256 = hashlib.sha256()
file_size = 0
chunks: list[bytes] = []
if file_size > MAX_FILE_SIZE:
raise HTTPException(
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
)
while True:
chunk = await file.read(CHUNK_SIZE)
if not chunk:
break
file_size += len(chunk)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
)
sha256.update(chunk)
chunks.append(chunk)
content_hash = sha256.hexdigest()
# Create file record
file_id = uuid.uuid4()
@@ -433,7 +468,8 @@ async def upload_file(
# Save file via storage backend
storage = get_storage_backend()
await storage.save(storage_path, content)
await storage.save(storage_path, b"".join(chunks))
del chunks # Free memory
mime_type = file.content_type or "application/octet-stream"
@@ -446,6 +482,7 @@ async def upload_file(
mime_type=mime_type,
size_bytes=file_size,
storage_path=storage_path,
content_hash=content_hash,
)
db.add(dms_file)
await db.flush()
@@ -457,7 +494,7 @@ async def upload_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
@@ -492,7 +529,7 @@ async def get_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
@@ -628,7 +665,7 @@ async def update_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
@@ -695,7 +732,7 @@ async def restore_file(
"uploaded_by": str(dms_file.uploaded_by),
"mime_type": dms_file.mime_type,
"size_bytes": dms_file.size_bytes,
"storage_path": dms_file.storage_path,
"content_hash": dms_file.content_hash,
"deleted_at": None,
"created_at": dms_file.created_at.isoformat() if dms_file.created_at else None,
"updated_at": dms_file.updated_at.isoformat() if dms_file.updated_at else None,
+1 -1
View File
@@ -34,7 +34,7 @@ class FileMetadataResponse(BaseModel):
uploaded_by: str
mime_type: str
size_bytes: int
storage_path: str
content_hash: str | None = None
deleted_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -0,0 +1,90 @@
"""Public contract for the kommunikation plugin.
Exposes only the symbols that other builtins plugins need.
Importers should use::
from app.plugins.builtins.contracts import get_contract
komm = get_contract("kommunikation")
if komm:
await komm.send_message(db, tenant_id, ...)
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.kommunikation.miniapp_registry import (
MiniAppDef,
MiniAppRegistry,
)
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommMessage,
CommParticipant,
)
from app.plugins.builtins.kommunikation.participant_registry import (
ParticipantHandler,
get_participant_registry,
)
from app.plugins.builtins.kommunikation.services import (
create_plugin_room,
get_conversation,
get_messages,
parse_mentions,
send_message,
)
class KommunikationContract:
"""Public API surface for the kommunikation plugin.
Exposes functions, classes, and model types that other plugins are
allowed to use. Internal implementation details remain private to
the plugin package.
"""
contract_name = "kommunikation"
# ─── services ───
parse_mentions = staticmethod(parse_mentions)
get_conversation = staticmethod(get_conversation)
get_messages = staticmethod(get_messages)
send_message = staticmethod(send_message)
create_plugin_room = staticmethod(create_plugin_room)
# ─── participant registry ───
get_participant_registry = staticmethod(get_participant_registry)
ParticipantHandler = ParticipantHandler
# ─── mini-app registry ───
MiniAppRegistry = MiniAppRegistry
MiniAppDef = MiniAppDef
# ─── models (read-only for queries) ───
CommConversation = CommConversation
CommMessage = CommMessage
CommParticipant = CommParticipant
# ─── self-registration ───
_contract = KommunikationContract()
get_contract_registry().register("kommunikation", _contract)
__all__ = [
"KommunikationContract",
"ParticipantHandler",
"get_participant_registry",
"MiniAppRegistry",
"MiniAppDef",
"parse_mentions",
"get_conversation",
"get_messages",
"send_message",
"create_plugin_room",
"CommConversation",
"CommMessage",
"CommParticipant",
]
@@ -13,7 +13,10 @@ from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.dms.models import File as DmsFile, Folder
from app.plugins.builtins.dms.contracts import get_contract as get_dms_contract
_dms = get_dms_contract()
DmsFile = _dms.DmsFile
Folder = _dms.Folder
logger = logging.getLogger(__name__)
@@ -14,7 +14,9 @@ from app.plugins.builtins.kommunikation.models import (
CommMessage,
CommParticipant,
)
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.contracts import get_contract as get_search_contract
_search = get_search_contract()
generate_embedding = _search.generate_embedding
logger = logging.getLogger(__name__)
+45
View File
@@ -0,0 +1,45 @@
"""Public contract for the mail plugin.
Exposes only the symbols that other builtins plugins need.
Currently the only cross-plugin consumer is ai_proactive, which imports
the ``Mail`` model for querying recent emails by contact.
Importers should use::
from app.plugins.builtins.contracts import get_contract
mail = get_contract("mail")
if mail:
result = await db.execute(select(mail.Mail).where(...))
instead of importing from internal modules directly.
"""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.mail.models import Mail
class MailContract:
"""Public API surface for the mail plugin.
Exposes the ``Mail`` ORM model so that other plugins can query the
mails table without importing from ``mail.models`` directly.
"""
contract_name = "mail"
# ─── models ───
Mail = Mail
# ─── self-registration ───
_contract = MailContract()
get_contract_registry().register("mail", _contract)
__all__ = [
"MailContract",
"Mail",
]
+4 -1
View File
@@ -1477,7 +1477,10 @@ async def create_event_from_mail(
account = await _get_account(db, mail.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "write")
try:
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
Calendar = _cal.Calendar
CalendarEntry = _cal.CalendarEntry
except ImportError:
return {"created": False, "error": "Calendar plugin not available"}
cal_id = _parse_uuid(data.calendar_id, "calendar_id")
@@ -14,7 +14,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
@@ -0,0 +1,21 @@
"""Permissions plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.permissions.models import Permission
class PermissionsContract:
"""Public contract for the permissions plugin."""
Permission = Permission
_contract_instance: PermissionsContract | None = None
def get_contract() -> PermissionsContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = PermissionsContract()
return _contract_instance
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
from app.plugins.builtins.kommunikation.contracts import ParticipantHandler
logger = logging.getLogger(__name__)
+4 -4
View File
@@ -54,7 +54,7 @@ class SystemNotifPlugin(BasePlugin):
await super().on_activate(db, service_container, event_bus)
from app.plugins.builtins.system_notif.participant_handler import SystemParticipantHandler
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
self._system_handler = SystemParticipantHandler(service_container)
registry = get_participant_registry()
@@ -64,7 +64,7 @@ class SystemNotifPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister participant."""
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
from app.plugins.builtins.kommunikation.contracts import get_participant_registry
get_participant_registry().unregister("system")
self._system_handler = None
@@ -132,7 +132,7 @@ class SystemNotifPlugin(BasePlugin):
import uuid
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.services import create_plugin_room, send_message
from app.plugins.builtins.kommunikation.contracts import create_plugin_room, send_message
tenant_id_str = payload.get("tenant_id")
user_id_str = payload.get("user_id")
@@ -201,7 +201,7 @@ class SystemNotifPlugin(BasePlugin):
# Find the System room conversation
from sqlalchemy import select
from app.plugins.builtins.kommunikation.models import CommConversation, CommParticipant
from app.plugins.builtins.kommunikation.contracts import CommConversation, CommParticipant
result = await db.execute(
select(CommConversation).where(
@@ -0,0 +1,231 @@
"""Tests for the plugin contract registry and contract modules.
Verifies that:
1. ContractRegistry singleton works correctly
2. Contracts for kommunikation, ai_assistant, and mail register and resolve
3. Contract objects expose the expected public symbols
4. Lazy loading works for unregistered plugins
5. ContractError is raised for missing contracts via require_contract
"""
from __future__ import annotations
import importlib
import pytest
from app.plugins.builtins.contracts import (
ContractError,
ContractRegistry,
get_contract,
get_contract_registry,
reset_contract_registry_for_testing,
)
def _reload_contracts(plugin_name: str):
"""Force re-import of a plugin's contracts module so it re-registers."""
module_path = f"app.plugins.builtins.{plugin_name}.contracts"
mod = importlib.import_module(module_path)
importlib.reload(mod)
return mod
@pytest.fixture(autouse=True)
def _reset_registry():
"""Ensure a fresh registry for each test."""
reset_contract_registry_for_testing()
yield
reset_contract_registry_for_testing()
# ─── ContractRegistry singleton ───
class TestContractRegistry:
def test_singleton_identity(self):
"""get_contract_registry returns the same instance."""
a = get_contract_registry()
b = get_contract_registry()
assert a is b
def test_register_and_get(self):
"""register stores and get_contract retrieves."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
assert reg.get_contract("demo") is sentinel
def test_unregister(self):
"""unregister removes the contract."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
reg.unregister("demo")
assert reg.get_contract("demo") is None
def test_get_contract_returns_none_for_unknown(self):
"""Unknown plugin returns None, not raises."""
reg = get_contract_registry()
assert reg.get_contract("does_not_exist") is None
def test_require_contract_raises_for_missing(self):
"""require_contract raises ContractError when missing."""
reg = get_contract_registry()
with pytest.raises(ContractError):
reg.require_contract("does_not_exist")
def test_require_contract_returns_contract(self):
"""require_contract returns the contract when registered."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
assert reg.require_contract("demo") is sentinel
def test_list_available(self):
"""list_available returns sorted plugin names."""
reg = get_contract_registry()
reg.register("zebra", object())
reg.register("alpha", object())
assert reg.list_available() == ["alpha", "zebra"]
def test_module_level_get_contract(self):
"""Module-level get_contract function works."""
reg = get_contract_registry()
sentinel = object()
reg.register("demo", sentinel)
assert get_contract("demo") is sentinel
def test_reset_for_testing_clears_state(self):
"""reset clears all registered contracts."""
reg = get_contract_registry()
reg.register("a", object())
reg.register("b", object())
assert len(reg.list_available()) == 2
reset_contract_registry_for_testing()
assert reg.list_available() == []
# ─── Kommunikation contract ───
class TestKommunikationContract:
@pytest.fixture(autouse=True)
def _load_komm(self):
"""Reload kommunikation contracts so it re-registers after reset."""
_reload_contracts("kommunikation")
def test_contract_registers(self):
"""Importing kommunikation.contracts registers it in the registry."""
contract = get_contract("kommunikation")
assert contract is not None
assert contract.contract_name == "kommunikation"
def test_exposes_services(self):
"""Contract exposes service functions."""
contract = get_contract("kommunikation")
assert callable(contract.parse_mentions)
assert callable(contract.get_conversation)
assert callable(contract.get_messages)
assert callable(contract.send_message)
assert callable(contract.create_plugin_room)
def test_exposes_participant_registry(self):
"""Contract exposes participant registry types."""
contract = get_contract("kommunikation")
assert callable(contract.get_participant_registry)
assert contract.ParticipantHandler is not None
def test_exposes_miniapp_registry(self):
"""Contract exposes MiniAppRegistry."""
contract = get_contract("kommunikation")
assert contract.MiniAppRegistry is not None
assert contract.MiniAppDef is not None
def test_exposes_models(self):
"""Contract exposes ORM models."""
contract = get_contract("kommunikation")
assert contract.CommConversation is not None
assert contract.CommMessage is not None
assert contract.CommParticipant is not None
def test_parse_mentions_works(self):
"""parse_mentions actually parses @mentions."""
contract = get_contract("kommunikation")
result = contract.parse_mentions("hello @ai_proactive and @system")
assert result == ["ai_proactive", "system"]
# ─── AI Assistant contract ───
class TestAIAssistantContract:
@pytest.fixture(autouse=True)
def _load_ai(self):
"""Reload ai_assistant contracts so it re-registers after reset."""
_reload_contracts("ai_assistant")
def test_contract_registers(self):
"""Importing ai_assistant.contracts registers it."""
contract = get_contract("ai_assistant")
assert contract is not None
assert contract.contract_name == "ai_assistant"
def test_exposes_tool_registry(self):
"""Contract exposes tool registry functions and types."""
contract = get_contract("ai_assistant")
assert callable(contract.get_tool_registry)
assert contract.ToolRegistry is not None
assert contract.AITool is not None
def test_exposes_get_default_provider(self):
"""Contract exposes get_default_provider."""
contract = get_contract("ai_assistant")
assert callable(contract.get_default_provider)
def test_tool_registry_singleton_works(self):
"""get_tool_registry returns a working singleton."""
contract = get_contract("ai_assistant")
reg = contract.get_tool_registry()
assert reg is not None
reg2 = contract.get_tool_registry()
assert reg is reg2
# ─── Mail contract ───
class TestMailContract:
@pytest.fixture(autouse=True)
def _load_mail(self):
"""Reload mail contracts so it re-registers after reset."""
_reload_contracts("mail")
def test_contract_registers(self):
"""Importing mail.contracts registers it."""
contract = get_contract("mail")
assert contract is not None
assert contract.contract_name == "mail"
def test_exposes_mail_model(self):
"""Contract exposes the Mail ORM model."""
contract = get_contract("mail")
assert contract.Mail is not None
from app.plugins.builtins.mail.models import Mail as MailModel
assert contract.Mail is MailModel
# ─── Lazy loading ───
class TestLazyLoading:
def test_lazy_load_on_first_access(self):
"""get_contract triggers lazy load of contracts module."""
reg = get_contract_registry()
contract = reg.get_contract("kommunikation")
assert contract is not None
assert contract.contract_name == "kommunikation"
def test_lazy_load_missing_plugin_returns_none(self):
"""Lazy load of non-existent plugin returns None."""
reg = get_contract_registry()
assert reg.get_contract("nonexistent_plugin_xyz") is None
@@ -0,0 +1,23 @@
"""Unified Search plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.search_engine import hybrid_search
class UnifiedSearchContract:
"""Public contract for the unified_search plugin."""
generate_embedding = staticmethod(generate_embedding)
hybrid_search = staticmethod(hybrid_search)
_contract_instance: UnifiedSearchContract | None = None
def get_contract() -> UnifiedSearchContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = UnifiedSearchContract()
return _contract_instance
@@ -40,7 +40,7 @@ async def _get_api_credentials(
# Fallback to DB provider
if db and tenant_id:
try:
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
provider = await get_default_provider(db, tenant_id)
if provider and provider.api_key:
return provider.api_key, provider.base_url, provider.provider_type
@@ -38,7 +38,7 @@ async def _get_api_credentials(
"""
if db and tenant_id:
try:
from app.plugins.builtins.ai_assistant.services import get_default_provider
from app.plugins.builtins.ai_assistant.contracts import get_default_provider
provider = await get_default_provider(db, tenant_id)
if provider and provider.api_key:
return provider.api_key, provider.base_url, provider.provider_type
+3 -3
View File
@@ -43,14 +43,14 @@ async def login(
settings.rate_limit_login_window,
)
result = await auth_service.login(db, redis, body.email, body.password)
result = await auth_service.login(db, redis, body.email, body.password, body.tenant_slug)
if result is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Invalid email or password", "code": "invalid_credentials"},
)
session_id, csrf_token, user, tenant = result
session_id, csrf_token, user, tenant, role = result
# Reset rate limit on success
await reset_rate_limit(f"auth:login:{ip}:{body.email}")
@@ -74,7 +74,7 @@ async def login(
"user_id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"role": role,
"is_system_admin": user.is_system_admin,
"tenant_id": str(tenant.id),
"tenant_name": tenant.name,
+51 -37
View File
@@ -1,8 +1,11 @@
"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete."""
"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete.
Write operations (create, update, delete, merge) are delegated to Commands.
Read operations (list, get, export, contact persons) use services directly.
"""
from __future__ import annotations
import csv
import io
import uuid
from typing import Any
@@ -11,8 +14,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.commands.contact_commands import (
CreateContactCommand,
UpdateContactCommand,
DeleteContactCommand,
MergeContactsCommand,
)
from app.core.db import get_db
from app.deps import require_permission
from app.deps import get_current_user, get_redis_dep, require_permission
from app.schemas.contact import (
ContactCreate,
ContactUpdate,
@@ -89,13 +100,16 @@ async def export_contacts(
async def create_contact(
body: ContactCreate,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Create a new contact (company or person)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
"""Create a new contact (company or person) via CreateContactCommand."""
data = body.model_dump(exclude_none=True)
return await contact_service.create_contact(db, tenant_id, user_id, data)
cmd = CreateContactCommand(data=data)
result = await cmd.execute(db, redis, current_user)
if not result.success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result.error)
return result.data
@router.get("/merge-history")
@@ -129,16 +143,20 @@ async def update_contact(
contact_id: str,
body: ContactUpdate,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Update a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
"""Update a contact via UpdateContactCommand."""
data = body.model_dump(exclude_none=True)
try:
return await contact_service.update_contact(db, tenant_id, user_id, contact_id, data)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
cmd = UpdateContactCommand(contact_id=contact_id, data=data)
result = await cmd.execute(db, redis, current_user)
if not result.success:
if "not found" in (result.error or "").lower():
raise HTTPException(status_code=404, detail=result.error)
if "Invalid state transition" in (result.error or ""):
raise HTTPException(status_code=422, detail=result.error)
raise HTTPException(status_code=400, detail=result.error)
return result.data
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -146,18 +164,15 @@ async def delete_contact(
contact_id: str,
hard: bool = Query(False, description="GDPR hard-delete"),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Soft-delete (or hard-delete with ?hard=true) a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
if hard:
await contact_service.hard_delete_contact(db, tenant_id, contact_id)
else:
await contact_service.delete_contact(db, tenant_id, contact_id, user_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
"""Soft-delete (or hard-delete with ?hard=true) a contact via DeleteContactCommand."""
cmd = DeleteContactCommand(contact_id=contact_id, hard=hard)
result = await cmd.execute(db, redis, current_user)
if not result.success:
raise HTTPException(status_code=404, detail=result.error)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ── ContactPersons ──
@@ -244,18 +259,17 @@ async def find_duplicate_contacts(
async def merge_duplicate_contacts(
body: MergeRequest,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Merge two contacts (source → target)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
return await dedup_service.merge_contacts(
db, tenant_id, user_id,
source_id=body.source_contact_id,
target_id=body.target_contact_id,
field_overrides=body.field_overrides,
note=body.note,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
"""Merge two contacts (source → target) via MergeContactsCommand."""
cmd = MergeContactsCommand(
source_contact_id=body.source_contact_id,
target_contact_id=body.target_contact_id,
field_overrides=body.field_overrides,
note=body.note,
)
result = await cmd.execute(db, redis, current_user)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return result.data
+2 -2
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends
from fastapi.responses import PlainTextResponse
from app.core.monitoring import generate_metrics
from app.deps import get_current_user
from app.deps import require_admin
router = APIRouter(tags=["metrics"])
@@ -14,7 +14,7 @@ router = APIRouter(tags=["metrics"])
@router.get(
"/api/v1/metrics",
response_class=PlainTextResponse,
dependencies=[Depends(get_current_user)],
dependencies=[Depends(require_admin)],
)
async def metrics():
"""Prometheus metrics endpoint.
+14 -204
View File
@@ -442,104 +442,13 @@ async def upload_plugin(
):
"""Upload and install a plugin from a ZIP file.
The ZIP must contain a plugin directory with a plugin.py that defines a BasePlugin subclass.
Validates the manifest, checks for conflicts, runs migrations, and installs the plugin.
DISABLED Plugin upload is deactivated due to security vulnerabilities (RCE via exec_module before validation).
Will be re-enabled with signed plugin artifacts and sandboxed execution.
"""
import uuid as uuid_mod
# Validate file is a ZIP
if not file.filename or not file.filename.endswith(".zip"):
raise HTTPException(400, detail={"detail": "File must be a .zip archive", "code": "invalid_file"})
# Check file size
contents = await file.read()
if len(contents) > MAX_UPLOAD_SIZE:
raise HTTPException(
413,
detail={
"detail": f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB",
"code": "file_too_large",
},
)
# Write to temp file
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
try:
tmp_zip.write(contents)
tmp_zip.close()
# Extract and validate
extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name)
# Check for name conflicts with existing plugins
service = get_plugin_service()
existing_plugins = await service.list_plugins(db)
existing_names = {p["name"] for p in existing_plugins}
if plugin_name in existing_names:
# Check if version is higher
existing_plugin = next(
(p for p in existing_plugins if p["name"] == plugin_name), None
)
if existing_plugin:
raise HTTPException(
409,
detail={
"detail": f"Plugin '{plugin_name}' already exists (version {existing_plugin.get('version', 'unknown')}). "
f"Uninstall the existing plugin first or upload a higher version.",
"code": "plugin_exists",
},
)
# Install the plugin directory
_install_plugin_from_dir(extract_dir, plugin_name, plugin_class)
# Run migrations and install via service
result = await service.install_plugin(
db,
plugin_name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
# Log audit
from app.core.audit import log_audit
await log_audit(
db,
uuid_mod.UUID(current_user["tenant_id"]),
uuid_mod.UUID(current_user["user_id"]),
action="plugin.upload",
entity_type="plugin",
changes={"name": plugin_name, "version": result.get("version"), "method": "upload"},
)
return {
**result,
"message": f"Plugin '{plugin_name}' uploaded and installed successfully",
}
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None
except MigrationValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "migration_validation_error"}
) from None
except Exception as exc:
logger.exception("Failed to upload plugin")
raise HTTPException(
500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"}
) from None
finally:
# Clean up temp files
try:
os.unlink(tmp_zip.name)
except Exception:
pass
try:
if "extract_dir" in dir():
shutil.rmtree(extract_dir, ignore_errors=True)
except Exception:
pass
raise HTTPException(
status_code=403,
detail={"detail": "Plugin upload is disabled. Use signed plugin artifacts from the allowlist.", "code": "upload_disabled"},
)
@router.post("/install-url")
@@ -548,111 +457,12 @@ async def install_plugin_from_url(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")),
):
"""Install a plugin from a URL (downloads ZIP and installs)."""
import uuid as uuid_mod
"""Install a plugin from a URL (downloads ZIP and installs).
if not body.url:
raise HTTPException(400, detail={"detail": "URL is required", "code": "missing_url"})
# Download ZIP from URL
tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(body.url, follow_redirects=True)
response.raise_for_status()
content = response.content
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(
413,
detail={
"detail": f"Downloaded file too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB",
"code": "file_too_large",
},
)
tmp_zip.write(content)
tmp_zip.close()
# Extract and validate
extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name)
# Check for name conflicts
service = get_plugin_service()
existing_plugins = await service.list_plugins(db)
existing_names = {p["name"] for p in existing_plugins}
if plugin_name in existing_names:
raise HTTPException(
409,
detail={
"detail": f"Plugin '{plugin_name}' already exists. Uninstall the existing plugin first.",
"code": "plugin_exists",
},
)
# Install the plugin directory
_install_plugin_from_dir(extract_dir, plugin_name, plugin_class)
# Run migrations and install via service
result = await service.install_plugin(
db,
plugin_name,
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
user_id=uuid_mod.UUID(current_user["user_id"]),
)
# Log audit
from app.core.audit import log_audit
await log_audit(
db,
uuid_mod.UUID(current_user["tenant_id"]),
uuid_mod.UUID(current_user["user_id"]),
action="plugin.install_url",
entity_type="plugin",
changes={"name": plugin_name, "version": result.get("version"), "url": body.url},
)
return {
**result,
"message": f"Plugin '{plugin_name}' downloaded and installed successfully",
}
except httpx.HTTPStatusError as exc:
raise HTTPException(
400,
detail={
"detail": f"Failed to download plugin from URL: HTTP {exc.response.status_code}",
"code": "download_error",
},
) from None
except httpx.RequestError as exc:
raise HTTPException(
400,
detail={
"detail": f"Failed to download plugin from URL: {str(exc)}",
"code": "download_error",
},
) from None
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None
except MigrationValidationError as exc:
raise HTTPException(
422, detail={"detail": str(exc), "code": "migration_validation_error"}
) from None
except Exception as exc:
logger.exception("Failed to install plugin from URL")
raise HTTPException(
500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"}
) from None
finally:
# Clean up temp files
try:
os.unlink(tmp_zip.name)
except Exception:
pass
try:
if "extract_dir" in dir():
shutil.rmtree(extract_dir, ignore_errors=True)
except Exception:
pass
DISABLED URL installation is deactivated due to SSRF and RCE vulnerabilities.
Will be re-enabled with signed plugin artifacts and allowlist.
"""
raise HTTPException(
status_code=403,
detail={"detail": "Plugin URL installation is disabled. Use signed plugin artifacts from the allowlist.", "code": "install_url_disabled"},
)
+22 -25
View File
@@ -7,6 +7,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.audit import log_audit
from app.core.auth import get_redis
@@ -14,6 +15,7 @@ from app.core.db import get_db
from app.core.notifications import create_notification
from app.core.permissions import invalidate_permission_cache
from app.deps import get_current_user, require_permission
from app.models.user import User, UserTenant
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PaginatedUsers
from app.services.user_service import user_service, _UNSET
@@ -107,10 +109,10 @@ async def create_user(
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"role_id": str(user.role_id) if user.role_id else None,
"role": body.role,
"role_id": str(role_id) if role_id else None,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
"tenant_id": str(tenant_id),
}
@@ -129,18 +131,19 @@ async def get_user(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
result = await user_service.get_user(db, tenant_id, uid)
if result is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
user, user_tenant = result
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"role_id": str(user.role_id) if user.role_id else None,
"role": user_tenant.role,
"role_id": str(user_tenant.role_id) if user_tenant.role_id else None,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
"tenant_id": str(user_tenant.tenant_id),
}
@@ -211,7 +214,7 @@ async def update_user(
changes["password_changed"] = True
try:
user = await user_service.update_user(
result = await user_service.update_user(
db,
tenant_id,
uid,
@@ -228,9 +231,10 @@ async def update_user(
)
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_password"}) from None
if user is None:
if result is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
user, user_tenant = result
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
# Invalidate permission cache for the updated user
@@ -244,10 +248,10 @@ async def update_user(
"first_name": user.first_name,
"last_name": user.last_name,
"avatar_url": user.avatar_url,
"role": user.role,
"role_id": str(user.role_id) if user.role_id else None,
"role": user_tenant.role,
"role_id": str(user_tenant.role_id) if user_tenant.role_id else None,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
"tenant_id": str(user_tenant.tenant_id),
}
@@ -268,9 +272,10 @@ async def delete_user(
) from None
# Get user snapshot for audit before deletion
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
result = await user_service.get_user(db, tenant_id, uid)
if result is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
user, user_tenant = result
success = await user_service.delete_user(db, tenant_id, uid)
if not success:
@@ -295,14 +300,10 @@ async def get_menu_order(
current_user: dict = Depends(get_current_user),
):
"""Get the current user's menu order preference."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user is None:
@@ -319,12 +320,8 @@ async def update_menu_order(
current_user: dict = Depends(get_current_user),
):
"""Update the current user's menu order preference."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
from sqlalchemy import select
from app.models.user import User
menu_order = body.get("menu_order")
if not isinstance(menu_order, list) or not all(isinstance(x, str) for x in menu_order):
raise HTTPException(
@@ -333,7 +330,7 @@ async def update_menu_order(
)
result = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user is None:
+1
View File
@@ -8,6 +8,7 @@ from pydantic import BaseModel, EmailStr, Field
class LoginRequest(BaseModel):
email: EmailStr = Field(..., examples=["admin@leocrm.local"])
password: str = Field(..., min_length=1, examples=["secure-password"])
tenant_slug: str | None = Field(None, description="Tenant slug to select tenant at login", examples=["tenant-a"])
class PasswordResetRequest(BaseModel):
+26 -21
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from decimal import Decimal
from pydantic import BaseModel, Field
@@ -70,10 +72,11 @@ class ContactPersonResponse(BaseModel):
class ContactCreate(BaseModel):
type: str = Field("company", pattern="^(company|person)$")
status: str | None = Field(None, pattern="^(lead|qualified|customer|inactive)$")
name: str | None = Field(None, max_length=255)
firstname: str | None = Field(None, max_length=100)
surname: str | None = Field(None, max_length=100)
surfix: str | None = Field(None, max_length=50)
suffix: str | None = Field(None, max_length=50)
ext_name_line: str | None = Field(None, max_length=255)
gender: str | None = Field(None, max_length=20)
code: str | None = Field(None, max_length=100)
@@ -124,12 +127,12 @@ class ContactCreate(BaseModel):
bic: str | None = Field(None, max_length=50)
bank_account: str | None = Field(None, max_length=50)
# Discounts
discount_crew: float = 0
discount_transport: float = 0
discount_rental: float = 0
discount_sale: float = 0
discount_subrent: float = 0
discount_total: float = 0
discount_crew: Decimal = Decimal("0")
discount_transport: Decimal = Decimal("0")
discount_rental: Decimal = Decimal("0")
discount_sale: Decimal = Decimal("0")
discount_subrent: Decimal = Decimal("0")
discount_total: Decimal = Decimal("0")
# Geo
latitude: float | None = None
longitude: float | None = None
@@ -149,10 +152,11 @@ class ContactCreate(BaseModel):
class ContactUpdate(BaseModel):
type: str | None = Field(None, pattern="^(company|person)$")
status: str | None = Field(None, pattern="^(lead|qualified|customer|inactive)$")
name: str | None = Field(None, max_length=255)
firstname: str | None = Field(None, max_length=100)
surname: str | None = Field(None, max_length=100)
surfix: str | None = Field(None, max_length=50)
suffix: str | None = Field(None, max_length=50)
ext_name_line: str | None = Field(None, max_length=255)
gender: str | None = Field(None, max_length=20)
code: str | None = Field(None, max_length=100)
@@ -196,12 +200,12 @@ class ContactUpdate(BaseModel):
purchase_number: str | None = Field(None, max_length=100)
bic: str | None = Field(None, max_length=50)
bank_account: str | None = Field(None, max_length=50)
discount_crew: float | None = None
discount_transport: float | None = None
discount_rental: float | None = None
discount_sale: float | None = None
discount_subrent: float | None = None
discount_total: float | None = None
discount_crew: Decimal | None = None
discount_transport: Decimal | None = None
discount_rental: Decimal | None = None
discount_sale: Decimal | None = None
discount_subrent: Decimal | None = None
discount_total: Decimal | None = None
latitude: float | None = None
longitude: float | None = None
projectnote: str | None = None
@@ -219,10 +223,11 @@ class ContactResponse(BaseModel):
id: str
type: str
displayname: str
status: str = "lead"
name: str | None = None
firstname: str | None = None
surname: str | None = None
surfix: str | None = None
suffix: str | None = None
ext_name_line: str | None = None
gender: str | None = None
code: str | None = None
@@ -267,12 +272,12 @@ class ContactResponse(BaseModel):
purchase_number: str | None = None
bic: str | None = None
bank_account: str | None = None
discount_crew: float = 0
discount_transport: float = 0
discount_rental: float = 0
discount_sale: float = 0
discount_subrent: float = 0
discount_total: float = 0
discount_crew: Decimal = Decimal("0")
discount_transport: Decimal = Decimal("0")
discount_rental: Decimal = Decimal("0")
discount_sale: Decimal = Decimal("0")
discount_subrent: Decimal = Decimal("0")
discount_total: Decimal = Decimal("0")
latitude: float | None = None
longitude: float | None = None
projectnote: str | None = None
+119 -13
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -14,6 +15,7 @@ from app.config import get_settings
from app.core.audit import log_audit
from app.core.auth import (
create_session,
get_redis,
get_session_data,
hash_password,
hash_token,
@@ -25,6 +27,8 @@ from app.models.auth import PasswordResetToken
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
logger = logging.getLogger(__name__)
class AuthService:
"""Handles authentication operations."""
@@ -36,11 +40,15 @@ class AuthService:
email: str,
password: str,
tenant_slug: str | None = None,
) -> tuple[str, str, User, Tenant] | None:
) -> tuple[str, str, User, Tenant, str] | None:
"""Authenticate user and create session.
Returns (session_id, csrf_token, user, tenant) or None.
Returns (session_id, csrf_token, user, tenant, role) or None.
Email is globally unique so we can safely use scalar_one_or_none().
The tenant is resolved from UserTenant via tenant_slug or the
user's default tenant membership.
"""
# Find user by email — need to check across tenants or use default tenant
# Find user by email (globally unique now)
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
result = await db.execute(q)
user = result.scalar_one_or_none()
@@ -75,7 +83,9 @@ class AuthService:
if tenant is None:
return None
session_id, csrf_token = await create_session(db, redis, user, tenant.id)
session_id, csrf_token = await create_session(
db, redis, user, tenant.id, role=user_tenant.role
)
# Log the login in audit trail
await log_audit(
@@ -88,7 +98,7 @@ class AuthService:
changes={"email": email},
)
return session_id, csrf_token, user, tenant
return session_id, csrf_token, user, tenant, user_tenant.role
async def logout(self, redis: aioredis.Redis, session_id: str) -> bool:
"""Invalidate a session."""
@@ -141,10 +151,11 @@ class AuthService:
UserTenant.tenant_id == new_tenant_id,
)
ut_result = await db.execute(ut_q)
if ut_result.scalar_one_or_none() is None:
user_tenant = ut_result.scalar_one_or_none()
if user_tenant is None:
return None
updated = await update_session_tenant(redis, session_id, new_tenant_id)
updated = await update_session_tenant(redis, session_id, new_tenant_id, role=user_tenant.role)
if updated is None:
return None
@@ -169,6 +180,22 @@ class AuthService:
if user is None:
return True # Don't reveal whether email exists
# Resolve tenant_id from UserTenant (default or specified)
ut_q = select(UserTenant).where(UserTenant.user_id == user.id)
if tenant_id is not None:
ut_q = ut_q.where(UserTenant.tenant_id == tenant_id)
else:
ut_q = ut_q.where(UserTenant.is_default == True) # noqa: E712
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
if user_tenant is None:
# Fallback: get first tenant membership
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
ut_result2 = await db.execute(ut_q2)
user_tenant = ut_result2.scalar_one_or_none()
if user_tenant is None:
return True
# Invalidate previous unused tokens
prev_q = select(PasswordResetToken).where(
PasswordResetToken.user_id == user.id,
@@ -187,7 +214,7 @@ class AuthService:
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
tenant_id=user_tenant.tenant_id,
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
@@ -195,8 +222,26 @@ class AuthService:
db.add(reset_token)
await db.flush()
# In production: send email via SMTP. For now, log it.
# The raw_token would be in the email link.
# Enqueue ARQ job to send the password reset email
try:
from app.core.jobs import enqueue_job
await enqueue_job(
"send_password_reset_email",
user_id=str(user.id),
email=user.email,
raw_token=raw_token,
expires_at=expires_at.isoformat(),
)
logger.info("Enqueued password reset email job for user %s", user.id)
except Exception:
logger.warning(
"ARQ enqueue failed for password reset email — "
"raw_token for development: %s",
raw_token,
exc_info=True,
)
return True
async def confirm_password_reset(
@@ -232,13 +277,46 @@ class AuthService:
reset_token.used_at = datetime.now(UTC)
await db.flush()
# Invalidate all active Redis sessions for this user
try:
redis = get_redis()
# Scan for session keys and check which belong to this user
import json
async for key in redis.scan_iter(match="session:*", count=100):
raw = await redis.get(key)
if raw is None:
continue
try:
session_data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if session_data.get("user_id") == str(user.id):
await redis.delete(key)
logger.info("Deleted session %s for user %s after password reset", key, user.id)
except Exception:
logger.warning("Failed to invalidate Redis sessions for user %s", user.id, exc_info=True)
# Audit log entry for password reset
try:
await log_audit(
db,
reset_token.tenant_id,
user.id,
"password_reset",
"user",
user.id,
changes={"action": "password_changed"},
)
except Exception:
logger.warning("Failed to create audit log for password reset of user %s", user.id, exc_info=True)
return True
async def get_password_reset_token_raw(self, db: AsyncSession, email: str) -> str | None:
"""Get the raw (unhashed) reset token for testing purposes.
This simulates what would be sent via email.
"""
# This is a test helper — in production the token goes via email only
import secrets
q = select(User).where(User.email == email)
@@ -247,13 +325,27 @@ class AuthService:
if user is None:
return None
# Get tenant_id from UserTenant
ut_q = select(UserTenant).where(
UserTenant.user_id == user.id,
UserTenant.is_default == True, # noqa: E712
)
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
if user_tenant is None:
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
ut_result2 = await db.execute(ut_q2)
user_tenant = ut_result2.scalar_one_or_none()
if user_tenant is None:
return None
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
tenant_id=user_tenant.tenant_id,
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
@@ -272,12 +364,26 @@ class AuthService:
if user is None:
return None
# Get tenant_id from UserTenant
ut_q = select(UserTenant).where(
UserTenant.user_id == user.id,
UserTenant.is_default == True, # noqa: E712
)
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
if user_tenant is None:
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
ut_result2 = await db.execute(ut_q2)
user_tenant = ut_result2.scalar_one_or_none()
if user_tenant is None:
return None
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
expires_at = datetime.now(UTC) - timedelta(hours=1) # Already expired
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
tenant_id=user_tenant.tenant_id,
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
+28 -18
View File
@@ -18,7 +18,7 @@ from app.services.entity_history_service import record_history
def _compute_displayname(data: dict) -> str:
"""Compute displayname from type and name fields."""
if data.get("type") == "person":
parts = [data.get("surfix"), data.get("firstname"), data.get("surname")]
parts = [data.get("suffix"), data.get("firstname"), data.get("surname")]
return " ".join(p for p in parts if p).strip()
else:
return data.get("name") or ""
@@ -30,10 +30,11 @@ def _serialize_contact(c: Contact) -> dict:
"id": str(c.id),
"type": c.type,
"displayname": c.displayname,
"status": getattr(c, "status", "lead"),
"name": c.name,
"firstname": c.firstname,
"surname": c.surname,
"surfix": c.surfix,
"suffix": c.suffix,
"ext_name_line": c.ext_name_line,
"gender": c.gender,
"code": c.code,
@@ -77,12 +78,12 @@ def _serialize_contact(c: Contact) -> dict:
"purchase_number": c.purchase_number,
"bic": c.bic,
"bank_account": c.bank_account,
"discount_crew": c.discount_crew,
"discount_transport": c.discount_transport,
"discount_rental": c.discount_rental,
"discount_sale": c.discount_sale,
"discount_subrent": c.discount_subrent,
"discount_total": c.discount_total,
"discount_crew": float(c.discount_crew) if c.discount_crew is not None else 0.0,
"discount_transport": float(c.discount_transport) if c.discount_transport is not None else 0.0,
"discount_rental": float(c.discount_rental) if c.discount_rental is not None else 0.0,
"discount_sale": float(c.discount_sale) if c.discount_sale is not None else 0.0,
"discount_subrent": float(c.discount_subrent) if c.discount_subrent is not None else 0.0,
"discount_total": float(c.discount_total) if c.discount_total is not None else 0.0,
"latitude": c.latitude,
"longitude": c.longitude,
"projectnote": c.projectnote,
@@ -251,17 +252,16 @@ async def create_contact(
action="create", snapshot_after=serialized,
)
# Publish events
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.publish('contact.created', {
# Enqueue domain events via transactional outbox (durable, at-least-once)
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'contact.created', {
'contact_id': str(contact.id),
'tenant_id': str(tenant_id),
'user_id': str(user_id),
'type': data.get('type', 'person'),
})
if data.get('type') == 'company':
await event_bus.publish('lead.created', {
await enqueue_outbox_event(db, tenant_id, 'lead.created', {
'contact_id': str(contact.id),
'tenant_id': str(tenant_id),
'user_id': str(user_id),
@@ -274,6 +274,8 @@ async def update_contact(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
) -> dict:
"""Update a contact."""
# Expire all cached objects to ensure fresh data with selectinload
db.expire_all()
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
@@ -292,7 +294,7 @@ async def update_contact(
snapshot_before = _serialize_contact_detail(contact)
# Recompute displayname if name fields changed
if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")):
if any(k in data for k in ("type", "name", "firstname", "surname", "suffix")):
merged = {**_serialize_contact(contact), **data}
data["displayname"] = _compute_displayname(merged)
@@ -302,6 +304,15 @@ async def update_contact(
contact.updated_by = user_id
await db.flush()
# Re-query with selectinload to avoid lazy-loading issues after flush
q2 = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(Contact.id == contact.id)
)
result2 = await db.execute(q2)
contact = result2.scalar_one()
snapshot_after = _serialize_contact_detail(contact)
# Compute changes diff
@@ -320,10 +331,9 @@ async def update_contact(
changes=changes or None,
)
# Publish contact.updated event
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.publish('contact.updated', {
# Enqueue domain event via transactional outbox (durable, at-least-once)
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'contact.updated', {
'contact_id': str(contact.id),
'tenant_id': str(tenant_id),
'user_id': str(user_id),
+22 -7
View File
@@ -218,7 +218,7 @@ def _serialize_full(c: Contact) -> dict:
"name": c.name,
"firstname": c.firstname,
"surname": c.surname,
"surfix": c.surfix,
"suffix": c.suffix,
"email_1": c.email_1,
"email_2": c.email_2,
"phone_1": c.phone_1,
@@ -287,7 +287,6 @@ async def merge_contacts(
setattr(target, key, value)
# Re-point entity_links from source to target
from app.models.entity_link import EntityLink
await db.execute(
text(
"UPDATE entity_links SET entity_id = :target_id "
@@ -297,7 +296,6 @@ async def merge_contacts(
)
# Re-point tag_assignments from source to target
from app.models.tag import TagAssignment
await db.execute(
text(
"UPDATE tag_assignments SET entity_id = :target_id "
@@ -309,7 +307,7 @@ async def merge_contacts(
# Re-point contact_persons from source to target
await db.execute(
text(
"UPDATE contact_persons SET contact_id = :target_id "
"UPDATE contactpersons SET contact_id = :target_id "
"WHERE contact_id = :source_id AND tenant_id = :tenant_id"
),
{"target_id": target_uuid, "source_id": source_uuid, "tenant_id": tenant_id},
@@ -322,20 +320,37 @@ async def merge_contacts(
# Record merge history
history = ContactMergeHistory(
tenant_id=tenant_id,
user_id=user_id,
source_id=source_uuid,
target_id=target_uuid,
merged_by=user_id,
source_contact_id=source_uuid,
target_contact_id=target_uuid,
note=note,
)
db.add(history)
await db.flush()
# Determine which fields were actually overridden
merged_fields = field_overrides or {}
if not merged_fields:
# Auto-merge: fill empty target fields from source
for attr in ("email_1", "email_2", "phone_1", "phone_2", "website",
"mailing_street", "mailing_postalcode", "mailing_city",
"mailing_country", "code", "vat_code"):
target_val = getattr(target, attr, None)
source_val = getattr(source, attr, None)
if not target_val and source_val:
setattr(target, attr, source_val)
merged_fields[attr] = source_val
history.merged_fields = merged_fields
await db.flush()
return {
"history": {
"id": str(history.id),
"source_id": source_id,
"target_id": target_id,
"note": note,
"merged_fields": merged_fields,
"created_at": history.created_at.isoformat() if history.created_at else None,
},
"target_contact": _serialize_full(target),
+9 -5
View File
@@ -61,19 +61,23 @@ class TenantService:
db: AsyncSession,
tenant_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List users in a tenant."""
q = select(User).where(User.tenant_id == tenant_id)
"""List users in a tenant via UserTenant association."""
q = (
select(User, UserTenant)
.join(UserTenant, UserTenant.user_id == User.id)
.where(UserTenant.tenant_id == tenant_id)
)
result = await db.execute(q)
users = result.scalars().all()
rows = result.all()
return [
{
"id": str(u.id),
"email": u.email,
"name": u.name,
"role": u.role,
"role": ut.role,
"is_active": u.is_active,
}
for u in users
for u, ut in rows
]
async def assign_user_to_tenant(
+99 -49
View File
@@ -18,7 +18,11 @@ _UNSET: Any = object()
class UserService:
"""Handles user CRUD operations."""
"""Handles user CRUD operations.
All queries are tenant-scoped through the UserTenant association table.
User.email is globally unique; tenant membership and role live in UserTenant.
"""
async def list_users(
self,
@@ -31,25 +35,33 @@ class UserService:
"""List users in a tenant with pagination and search."""
offset = (page - 1) * page_size
q = select(User).where(User.tenant_id == tenant_id)
count_q = select(func.count()).select_from(User).where(User.tenant_id == tenant_id)
base = (
select(User, UserTenant)
.join(UserTenant, UserTenant.user_id == User.id)
.where(UserTenant.tenant_id == tenant_id)
)
count_q = (
select(func.count())
.select_from(UserTenant)
.where(UserTenant.tenant_id == tenant_id)
)
if search:
search_filter = or_(
User.name.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
)
q = q.where(search_filter)
count_q = count_q.where(search_filter)
base = base.where(search_filter)
count_q = count_q.join(User, User.id == UserTenant.user_id).where(search_filter)
total = (await db.execute(count_q)).scalar() or 0
q = q.offset(offset).limit(page_size).order_by(User.created_at.desc())
q = base.offset(offset).limit(page_size).order_by(User.created_at.desc())
result = await db.execute(q)
users = result.scalars().all()
rows = result.all()
return {
"items": [self._user_to_dict(u) for u in users],
"items": [self._user_to_dict(u, ut) for u, ut in rows],
"total": total,
"page": page,
"page_size": page_size,
@@ -60,11 +72,21 @@ class UserService:
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> User | None:
"""Get a single user by ID within tenant scope."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
) -> tuple[User, UserTenant] | None:
"""Get a single user by ID within tenant scope.
Returns (User, UserTenant) tuple or None.
"""
q = (
select(User, UserTenant)
.join(UserTenant, UserTenant.user_id == User.id)
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
)
result = await db.execute(q)
return result.scalar_one_or_none()
row = result.first()
if row is None:
return None
return row[0], row[1]
async def create_user(
self,
@@ -77,29 +99,27 @@ class UserService:
role_id: uuid.UUID | None = None,
is_active: bool = True,
) -> User:
"""Create a new user in a tenant.
"""Create a new user and add them to the specified tenant.
If role_id is provided it links the user to a custom Role record.
The legacy ``role`` string is kept for backward compatibility.
If role_id is provided it links the UserTenant to a custom Role record.
The ``role`` string is the built-in role (admin/editor/viewer).
"""
user = User(
tenant_id=tenant_id,
email=email,
name=name,
password_hash=hash_password(password),
role=role,
role_id=role_id,
is_active=is_active,
preferences={},
)
db.add(user)
await db.flush()
# Add user-tenant membership
# Add user-tenant membership with role
ut = UserTenant(
user_id=user.id,
tenant_id=tenant_id,
is_default=True,
role=role,
role_id=role_id,
)
db.add(ut)
@@ -122,35 +142,34 @@ class UserService:
email: str | None = None,
current_password: str | None = None,
new_password: str | None = None,
) -> User | None:
"""Update a user.
) -> tuple[User, UserTenant] | None:
"""Update a user and their tenant membership.
``role_id`` uses a sentinel to distinguish three states:
- ``_UNSET`` (default): leave the existing role_id unchanged
- ``None``: clear the FK (fall back to the legacy ``role`` string)
- ``None``: clear the FK (fall back to the built-in ``role`` string)
- ``uuid.UUID``: link to a custom Role record
Returns (User, UserTenant) tuple or None if not found.
"""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
q = (
select(User, UserTenant)
.join(UserTenant, UserTenant.user_id == User.id)
.where(User.id == user_id, UserTenant.tenant_id == tenant_id)
)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
row = result.first()
if row is None:
return None
user, user_tenant = row[0], row[1]
if name is not None:
user.name = name
if role is not None:
user.role = role
user_tenant.role = role
if role_id is not _UNSET:
user.role_id = role_id
# Sync UserTenant.role_id so resolve_permissions picks up the change
ut_q = select(UserTenant).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == tenant_id,
)
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
if user_tenant:
user_tenant.role_id = role_id
user_tenant.role_id = role_id
if is_active is not None:
user.is_active = is_active
if first_name is not None:
@@ -170,7 +189,7 @@ class UserService:
user.password_hash = hash_password(new_password)
await db.flush()
return user
return user, user_tenant
async def delete_user(
self,
@@ -178,28 +197,59 @@ class UserService:
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Delete a user from a tenant."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
"""Remove a user from a tenant (delete UserTenant membership).
If this is the user's only tenant membership, the User record is
also deleted. Otherwise only the UserTenant row is removed.
"""
ut_q = select(UserTenant).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == tenant_id,
)
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
if user_tenant is None:
return False
await db.delete(user)
# Count total tenant memberships for this user
count_q = select(func.count()).select_from(UserTenant).where(
UserTenant.user_id == user_id
)
count_result = await db.execute(count_q)
membership_count = count_result.scalar() or 0
await db.delete(user_tenant)
if membership_count <= 1:
# User's only tenant — delete the User record too
user_q = select(User).where(User.id == user_id)
user_result = await db.execute(user_q)
user = user_result.scalar_one_or_none()
if user is not None:
await db.delete(user)
await db.flush()
return True
def _user_to_dict(self, user: User) -> dict[str, Any]:
"""Convert user to response dict."""
return {
def _user_to_dict(
self, user: User, user_tenant: UserTenant | None = None
) -> dict[str, Any]:
"""Convert user + user_tenant to response dict."""
result: dict[str, Any] = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"role_id": str(user.role_id) if user.role_id else None,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
if user_tenant is not None:
result["role"] = user_tenant.role
result["role_id"] = str(user_tenant.role_id) if user_tenant.role_id else None
result["tenant_id"] = str(user_tenant.tenant_id)
else:
result["role"] = "viewer"
result["role_id"] = None
result["tenant_id"] = None
return result
user_service = UserService()