Files
leocrm/app/services/group_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

247 lines
7.3 KiB
Python

"""Group management service."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.permissions import invalidate_all_user_permissions
from app.models.group import Group, UserGroup
from app.models.user import User, UserTenant
class GroupService:
"""Handles group CRUD and user-group membership."""
async def list_groups(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List all groups in a tenant."""
q = select(Group).where(Group.tenant_id == tenant_id, Group.deleted_at.is_(None))
result = await db.execute(q)
groups = result.scalars().all()
return [self._group_to_dict(g) for g in groups]
async def get_group(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
group_id: uuid.UUID,
) -> Group | None:
"""Get a single group by ID within tenant scope."""
q = select(Group).where(
Group.id == group_id,
Group.tenant_id == tenant_id,
Group.deleted_at.is_(None),
)
result = await db.execute(q)
return result.scalar_one_or_none()
async def create_group(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
name: str,
description: str | None = None,
permissions: dict[str, Any] | None = None,
denied_permissions: list[str] | None = None,
field_permissions: dict[str, Any] | None = None,
) -> Group:
"""Create a new group."""
group = Group(
tenant_id=tenant_id,
name=name,
description=description,
permissions=permissions or {},
denied_permissions=denied_permissions or [],
field_permissions=field_permissions or {},
)
db.add(group)
await db.flush()
return group
async def update_group(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
group_id: uuid.UUID,
name: str | None = None,
description: str | None = None,
permissions: dict[str, Any] | None = None,
denied_permissions: list[str] | None = None,
field_permissions: dict[str, Any] | None = None,
) -> Group | None:
"""Update a group. Increments permission_version on permission changes."""
q = select(Group).where(
Group.id == group_id,
Group.tenant_id == tenant_id,
Group.deleted_at.is_(None),
)
result = await db.execute(q)
group = result.scalar_one_or_none()
if group is None:
return None
version_bump = False
if name is not None:
group.name = name
if description is not None:
group.description = description
if permissions is not None:
group.permissions = permissions
version_bump = True
if denied_permissions is not None:
group.denied_permissions = denied_permissions
version_bump = True
if field_permissions is not None:
group.field_permissions = field_permissions
version_bump = True
if version_bump:
group.permission_version += 1
await db.flush()
return group
async def delete_group(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
group_id: uuid.UUID,
) -> bool:
"""Soft-delete a group."""
from datetime import UTC, datetime
q = select(Group).where(
Group.id == group_id,
Group.tenant_id == tenant_id,
Group.deleted_at.is_(None),
)
result = await db.execute(q)
group = result.scalar_one_or_none()
if group is None:
return False
group.deleted_at = datetime.now(UTC)
await db.flush()
return True
async def get_group_members(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
group_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List all users in a group."""
q = (
select(User, UserGroup)
.join(User, UserGroup.user_id == User.id)
.where(
UserGroup.group_id == group_id,
UserGroup.tenant_id == tenant_id,
)
)
result = await db.execute(q)
return [
{
"user_id": str(user.id),
"email": user.email,
"name": user.name,
"is_active": user.is_active,
}
for user, _ug in result.all()
]
async def add_user_to_group(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
group_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Add a user to a group."""
# Verify user is in this tenant
ut_q = select(UserTenant).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == tenant_id,
)
ut_result = await db.execute(ut_q)
if ut_result.scalar_one_or_none() is None:
return False
# Check if already member
existing_q = select(UserGroup).where(
UserGroup.user_id == user_id,
UserGroup.group_id == group_id,
UserGroup.tenant_id == tenant_id,
)
existing_result = await db.execute(existing_q)
if existing_result.scalar_one_or_none() is not None:
return True # Already member
ug = UserGroup(
user_id=user_id,
group_id=group_id,
tenant_id=tenant_id,
)
db.add(ug)
await db.flush()
return True
async def remove_user_from_group(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
group_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Remove a user from a group."""
q = delete(UserGroup).where(
UserGroup.user_id == user_id,
UserGroup.group_id == group_id,
UserGroup.tenant_id == tenant_id,
)
result = await db.execute(q)
await db.flush()
return result.rowcount > 0
async def get_user_groups(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List all groups a user is member of."""
q = (
select(Group)
.join(UserGroup, UserGroup.group_id == Group.id)
.where(
UserGroup.user_id == user_id,
UserGroup.tenant_id == tenant_id,
Group.deleted_at.is_(None),
)
)
result = await db.execute(q)
return [self._group_to_dict(g) for g in result.scalars().all()]
def _group_to_dict(self, group: Group) -> dict[str, Any]:
"""Convert group to response dict."""
return {
"id": str(group.id),
"name": group.name,
"description": group.description,
"permissions": group.permissions,
"denied_permissions": group.denied_permissions,
"field_permissions": group.field_permissions,
"permission_version": group.permission_version,
}
group_service = GroupService()