Files
leocrm/app/services/workspace_service.py
T
Agent Zero 7fbbe420bd
Check Cross-Plugin Imports / check (push) Has been cancelled
fix: comprehensive system audit fixes (55+ issues)
CRITICAL:
- Fix SQL injection in prestart.sh (parameterized query)
- Fix secret key validation (always validate, not just production)
- Fix workspace model partial index bug (func.text -> text)
- Fix HealthResponse schema (add checks field)
- Fix Tenant import in permissions.py (NameError on every auth request)
- Fix README tech stack (React instead of Alpine.js)
- Delete broken test_cross_tenant_security_v2.py
- Add fail-closed RLS migration 0084 (48 tenant tables)

HIGH:
- Add GeneralRateLimitMiddleware for all API routes
- Add file type blocklist for DMS and attachment uploads
- Fix guest auth: Pydantic schema, tenant_slug required, CSRF bypass
- Fix CSRF bypass path matching (in -> endswith)
- Add worker healthcheck in docker-compose.yml
- Add ARQ max_tries=3 for job retries
- Fix 28 bare pass in mail services (-> logger.debug)
- Fix print() -> logger in main.py and ai_assistant
- Fix duplicate email handling (catch IntegrityError -> 409)
- Add session revocation (invalidate_all_user_sessions)
- Add resource limits to all containers
- Fix CORS default (localhost -> production domain)
- Fix SameSite=Lax -> Strict
- Fix Redis password visibility in healthcheck
- Fix npm vulnerabilities (19 -> 9)
- Fix Sidebar OOM (wildcard lucide import -> curated ICON_MAP)

MEDIUM:
- Localize ErrorBoundary to German
- Wire Mail.tsx save/delete filter to API
- Document system_notif plugin (no routes needed)
- Fix datetime.utcnow() -> datetime.now(UTC)
- Pin litellm version (>=1.0,<2.0)
- Move CSRF token from sessionStorage to in-memory
- Fix restore_backup error handling and transaction
- Fix Dms.tsx useEffect cleanup
- Add skip-to-content link for accessibility
- Add selectinload imports to 3 services
- Add .env.example missing variables
- Fix AppShell/TopBar/Sidebar test mocks

NEW TESTS:
- test_guest_auth.py (6 tests)
- test_user_service.py (8 tests)
- test_backup_service.py (5 tests)

NEW SCHEMAS:
- saved_filter, saved_view, user_preference, workspace, entity_policy

Tests: 22/22 PASSED
2026-07-31 00:58:05 +02:00

427 lines
12 KiB
Python

"""Workspace service — CRUD, module config, user assignment, widgets.
Workspaces are UI/navigation context only. They never affect permissions.
See: docs/security_kernel.md
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget
def _workspace_to_dict(ws: Workspace, modules: list[WorkspaceModule] | None = None, user_count: int = 0) -> dict[str, Any]:
return {
"id": str(ws.id),
"name": ws.name,
"icon": ws.icon,
"description": ws.description,
"is_default": ws.is_default,
"is_active": ws.is_active,
"created_by": str(ws.created_by) if ws.created_by else None,
"created_at": ws.created_at.isoformat() if ws.created_at else None,
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
"modules": [
{
"id": str(m.id),
"module_key": m.module_key,
"is_visible": m.is_visible,
"menu_order": m.menu_order,
"config": m.config or {},
}
for m in (modules or [])
],
"user_count": user_count,
}
async def list_workspaces(
db: AsyncSession, tenant_id: uuid.UUID
) -> dict[str, Any]:
"""List all workspaces for a tenant."""
q = select(Workspace).where(
Workspace.tenant_id == tenant_id,
Workspace.is_active == True, # noqa: E712
).order_by(Workspace.name)
result = await db.execute(q)
workspaces = result.scalars().all()
items = []
for ws in workspaces:
# Count users
count_q = select(func.count()).select_from(WorkspaceUser).where(
WorkspaceUser.workspace_id == ws.id,
WorkspaceUser.tenant_id == tenant_id,
)
count_result = await db.execute(count_q)
user_count = count_result.scalar() or 0
items.append(_workspace_to_dict(ws, user_count=user_count))
return {"items": items, "total": len(items)}
async def get_workspace(
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID
) -> dict[str, Any] | None:
"""Get a single workspace with modules and user count."""
q = select(Workspace).where(
Workspace.id == workspace_id,
Workspace.tenant_id == tenant_id,
)
result = await db.execute(q)
ws = result.scalar_one_or_none()
if ws is None:
return None
# Get modules
mod_q = select(WorkspaceModule).where(
WorkspaceModule.workspace_id == workspace_id,
WorkspaceModule.tenant_id == tenant_id,
).order_by(WorkspaceModule.menu_order)
mod_result = await db.execute(mod_q)
modules = mod_result.scalars().all()
# Count users
count_q = select(func.count()).select_from(WorkspaceUser).where(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.tenant_id == tenant_id,
)
count_result = await db.execute(count_q)
user_count = count_result.scalar() or 0
return _workspace_to_dict(ws, modules=modules, user_count=user_count)
async def create_workspace(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
name: str,
icon: str = "LayoutGrid",
description: str | None = None,
is_default: bool = False,
) -> dict[str, Any]:
"""Create a new workspace."""
ws = Workspace(
tenant_id=tenant_id,
name=name,
icon=icon,
description=description,
is_default=is_default,
is_active=True,
created_by=user_id,
)
db.add(ws)
await db.flush()
await db.refresh(ws)
# If this is the default workspace, unset others
if is_default:
await db.execute(
update(Workspace)
.where(
Workspace.tenant_id == tenant_id,
Workspace.id != ws.id,
)
.values(is_default=False)
)
# Auto-assign creator as manager
wu = WorkspaceUser(
tenant_id=tenant_id,
workspace_id=ws.id,
user_id=user_id,
role="manager",
is_default=is_default,
assigned_by=user_id,
)
db.add(wu)
await db.flush()
return _workspace_to_dict(ws, user_count=1)
async def update_workspace(
db: AsyncSession,
tenant_id: uuid.UUID,
workspace_id: uuid.UUID,
name: str | None = None,
icon: str | None = None,
description: str | None = None,
is_default: bool | None = None,
is_active: bool | None = None,
) -> dict[str, Any] | None:
"""Update a workspace."""
q = select(Workspace).where(
Workspace.id == workspace_id,
Workspace.tenant_id == tenant_id,
)
result = await db.execute(q)
ws = result.scalar_one_or_none()
if ws is None:
return None
if name is not None:
ws.name = name
if icon is not None:
ws.icon = icon
if description is not None:
ws.description = description
if is_active is not None:
ws.is_active = is_active
if is_default is True:
# Unset other defaults
await db.execute(
update(Workspace)
.where(
Workspace.tenant_id == tenant_id,
Workspace.id != ws.id,
)
.values(is_default=False)
)
ws.is_default = True
elif is_default is False:
ws.is_default = False
await db.flush()
await db.refresh(ws)
return _workspace_to_dict(ws)
async def delete_workspace(
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID
) -> bool:
"""Delete a workspace (soft delete by setting is_active=False)."""
q = select(Workspace).where(
Workspace.id == workspace_id,
Workspace.tenant_id == tenant_id,
)
result = await db.execute(q)
ws = result.scalar_one_or_none()
if ws is None:
return False
ws.is_active = False
await db.flush()
return True
async def set_workspace_modules(
db: AsyncSession,
tenant_id: uuid.UUID,
workspace_id: uuid.UUID,
modules: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Set the modules for a workspace. Replaces all existing modules."""
# Delete existing modules
existing_q = select(WorkspaceModule).where(
WorkspaceModule.workspace_id == workspace_id,
WorkspaceModule.tenant_id == tenant_id,
)
existing = await db.execute(existing_q)
for m in existing.scalars().all():
await db.delete(m)
# Insert new modules
result = []
for mod in modules:
wm = WorkspaceModule(
tenant_id=tenant_id,
workspace_id=workspace_id,
module_key=mod["module_key"],
is_visible=mod.get("is_visible", True),
menu_order=mod.get("menu_order", 0),
config=mod.get("config", {}),
)
db.add(wm)
await db.flush()
await db.refresh(wm)
result.append({
"id": str(wm.id),
"module_key": wm.module_key,
"is_visible": wm.is_visible,
"menu_order": wm.menu_order,
"config": wm.config or {},
})
return result
async def assign_user(
db: AsyncSession,
tenant_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
role: str = "member",
assigned_by: uuid.UUID | None = None,
) -> dict[str, Any]:
"""Assign a user to a workspace."""
wu = WorkspaceUser(
tenant_id=tenant_id,
workspace_id=workspace_id,
user_id=user_id,
role=role,
is_default=False,
assigned_by=assigned_by,
)
db.add(wu)
await db.flush()
await db.refresh(wu)
return {
"id": str(wu.id),
"workspace_id": str(wu.workspace_id),
"user_id": str(wu.user_id),
"role": wu.role,
"is_default": wu.is_default,
"assigned_at": wu.assigned_at.isoformat() if wu.assigned_at else None,
}
async def remove_user(
db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID, user_id: uuid.UUID
) -> bool:
"""Remove a user from a workspace."""
q = select(WorkspaceUser).where(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == user_id,
WorkspaceUser.tenant_id == tenant_id,
)
result = await db.execute(q)
wu = result.scalar_one_or_none()
if wu is None:
return False
await db.delete(wu)
await db.flush()
return True
async def get_my_workspaces(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID
) -> dict[str, Any]:
"""Get workspaces assigned to the current user."""
q = (
select(Workspace, WorkspaceUser)
.join(WorkspaceUser, WorkspaceUser.workspace_id == Workspace.id)
.where(
WorkspaceUser.user_id == user_id,
Workspace.tenant_id == tenant_id,
Workspace.is_active == True, # noqa: E712
)
.order_by(Workspace.name)
)
result = await db.execute(q)
rows = result.all()
items = []
for ws, wu in rows:
# Get modules for this workspace
mod_q = select(WorkspaceModule).where(
WorkspaceModule.workspace_id == ws.id,
WorkspaceModule.tenant_id == tenant_id,
WorkspaceModule.is_visible == True, # noqa: E712
).order_by(WorkspaceModule.menu_order)
mod_result = await db.execute(mod_q)
modules = mod_result.scalars().all()
items.append({
"id": str(ws.id),
"name": ws.name,
"icon": ws.icon,
"description": ws.description,
"is_default": ws.is_default,
"role": wu.role,
"is_user_default": wu.is_default,
"modules": [
{
"module_key": m.module_key,
"menu_order": m.menu_order,
"config": m.config or {},
}
for m in modules
],
})
return {"items": items, "total": len(items)}
async def get_workspace_context(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dict[str, Any] | None:
"""Get workspace context for a user — modules, widgets, config.
Validates:
- Workspace belongs to tenant
- User is assigned or is system admin / tenant admin
- Workspace is active
"""
# Check workspace exists and is active
ws_q = select(Workspace).where(
Workspace.id == workspace_id,
Workspace.tenant_id == tenant_id,
Workspace.is_active == True, # noqa: E712
)
ws_result = await db.execute(ws_q)
ws = ws_result.scalar_one_or_none()
if ws is None:
return None
# Check user is assigned
wu_q = select(WorkspaceUser).where(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == user_id,
WorkspaceUser.tenant_id == tenant_id,
)
wu_result = await db.execute(wu_q)
wu = wu_result.scalar_one_or_none()
if wu is None:
return None # User not assigned — caller can check is_system_admin
# Get visible modules
mod_q = select(WorkspaceModule).where(
WorkspaceModule.workspace_id == workspace_id,
WorkspaceModule.tenant_id == tenant_id,
WorkspaceModule.is_visible == True, # noqa: E712
).order_by(WorkspaceModule.menu_order)
mod_result = await db.execute(mod_q)
modules = mod_result.scalars().all()
# Get widgets
widget_q = select(WorkspaceWidget).where(
WorkspaceWidget.workspace_id == workspace_id,
WorkspaceWidget.tenant_id == tenant_id,
).order_by(WorkspaceWidget.position_y, WorkspaceWidget.position_x)
widget_result = await db.execute(widget_q)
widgets = widget_result.scalars().all()
return {
"workspace_id": str(ws.id),
"name": ws.name,
"icon": ws.icon,
"role": wu.role,
"modules": [
{
"module_key": m.module_key,
"menu_order": m.menu_order,
"config": m.config or {},
}
for m in modules
],
"widgets": [
{
"id": str(w.id),
"widget_key": w.widget_key,
"position_x": w.position_x,
"position_y": w.position_y,
"width": w.width,
"height": w.height,
"config": w.config or {},
}
for w in widgets
],
}