7fbbe420bd
Check Cross-Plugin Imports / check (push) Has been cancelled
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
247 lines
7.3 KiB
Python
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()
|