"""Universal entity permission routes — ACL management for ALL entities.""" from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.core.rate_limit import check_rate_limit from app.deps import get_current_user, get_redis_dep, require_permission from app.schemas.entity_permission import ( EntityPermissionCreate, EntityPermissionUpdate, ) from app.services import bulk_permission_service, entity_permission_service router = APIRouter(prefix="/api/v1/permissions", tags=["entity-permissions"]) class BulkShareRequest(BaseModel): """Bulk-share multiple entities with a principal.""" entity_type: str = Field(..., min_length=1) entity_ids: list[str] = Field(..., min_length=1) principal_type: str = Field(..., pattern="^(user|group|guest)$") principal_id: str = Field(..., min_length=1) level: str = Field(..., pattern="^(read|write|admin|delete|owner)$") class BulkUnshareRequest(BaseModel): """Bulk-remove permissions for a principal from multiple entities.""" entity_type: str = Field(..., min_length=1) entity_ids: list[str] = Field(..., min_length=1) principal_type: str = Field(..., pattern="^(user|group|guest)$") principal_id: str = Field(..., min_length=1) # Rate limits for permission changes (prevent abuse/DoS) _PERM_RATE_LIMIT_MAX = 50 # max changes per minute _PERM_RATE_LIMIT_WINDOW = 60 # 60 seconds @router.get("/{entity_type}/{entity_id}") async def list_entity_permissions( entity_type: str, entity_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List all permission entries for a specific entity.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: items = await entity_permission_service.list_permissions( db, tenant_id, entity_type, entity_id ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e return {"items": items, "total": len(items)} @router.post("/{entity_type}/{entity_id}", status_code=status.HTTP_201_CREATED) async def create_entity_permission( entity_type: str, entity_id: str, body: EntityPermissionCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), redis = Depends(get_redis_dep), ): """Grant or update a permission on any entity for a user, group, role, or guest.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_system_admin = current_user.get("is_system_admin", False) # Permission check: only owner/admin/system_admin can manage permissions if not is_system_admin: access = await entity_permission_service.get_effective_access( db, tenant_id, user_id, entity_type, uuid.UUID(entity_id) ) if access not in ("owner", "admin", "delete"): raise HTTPException(status_code=403, detail="Sie benötigen Admin-Rechte auf diesen Datensatz, um Berechtigungen zu verwalten") # Rate limit: max 50 permission changes per minute per user await check_rate_limit( f"perm_change:{user_id}", _PERM_RATE_LIMIT_MAX, _PERM_RATE_LIMIT_WINDOW, ) try: result = await entity_permission_service.create_permission( db, tenant_id, entity_type, entity_id, body.principal_type, body.principal_id, body.permission_level, body.expires_at, created_by=user_id, redis=redis, ) return result except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e async def _check_entity_ownership( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, entity_id: str, is_system_admin: bool, ) -> None: """Verify that the current user owns the entity or is system admin. Raises 403 if the user is neither owner nor system admin. """ if is_system_admin: return from sqlalchemy import select from app.services.entity_permission_service import ENTITY_MODELS model_info = ENTITY_MODELS.get(entity_type) if model_info is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": f"Unknown entity type: {entity_type}", "code": "not_found"}, ) model = model_info # ENTITY_MODELS maps directly to model classes try: eid = uuid.UUID(entity_id) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Invalid entity_id", "code": "invalid_id"}, ) from None result = await db.execute( select(model.owner_id).where( model.id == eid, model.tenant_id == tenant_id, ) ) row = result.first() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Entity not found", "code": "not_found"}, ) owner_id = row[0] if owner_id is not None and owner_id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"detail": "Only the entity owner or system admin can modify permissions", "code": "forbidden"}, ) @router.put("/{entity_type}/{entity_id}/{permission_id}") async def update_entity_permission( entity_type: str, entity_id: str, permission_id: str, body: EntityPermissionUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update an existing permission entry.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) await check_rate_limit( f"perm_change:{user_id}", _PERM_RATE_LIMIT_MAX, _PERM_RATE_LIMIT_WINDOW, ) # Ownership check: only owner or system admin can update permissions await _check_entity_ownership( db, tenant_id, user_id, entity_type, entity_id, current_user.get("is_system_admin", False), ) try: return await entity_permission_service.update_permission( db, tenant_id, permission_id, body.permission_level, body.expires_at, ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e @router.delete("/{entity_type}/{entity_id}/{permission_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_entity_permission( entity_type: str, entity_id: str, permission_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Revoke a permission entry.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) await check_rate_limit( f"perm_change:{user_id}", _PERM_RATE_LIMIT_MAX, _PERM_RATE_LIMIT_WINDOW, ) try: await entity_permission_service.delete_permission(db, tenant_id, permission_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e @router.get("/{entity_type}/{entity_id}/access") async def get_entity_access( entity_type: str, entity_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get effective access level for the current user on an entity.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) access = await entity_permission_service.get_effective_access( db, tenant_id, user_id, entity_type, uuid.UUID(entity_id) ) is_owner = access == "owner" is_shared = access not in ("none", "owner") return { "entity_type": entity_type, "entity_id": entity_id, "access_level": access, "is_owner": is_owner, "is_shared": is_shared, } @router.get("/all") async def list_all_permissions( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("settings:read")), ): """List ALL permission entries for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) items = await entity_permission_service.list_all_permissions(db, tenant_id) return {"items": items, "total": len(items)} @router.get("/registry") async def list_entity_registry( current_user: dict = Depends(get_current_user), ): """List all registered entity types that support permissions. Generated dynamically from the entity model registry (ARCH-016): core models plus every entity registered by an active plugin at activation time. Plugin entities appear automatically without touching this file. """ from app.services.entity_permission_service import ENTITY_MODELS # Human-readable labels for known types; plugin entities fall back to a # title-cased entity_type so they are still presentable. _labels = { "contact": "Kontakte", "contact_folder": "Ordner", "address": "Adressen", "attachment": "Anhänge", "bank_account": "Bankkonten", "workflow": "Workflows", "sequence": "Sequenzen", "saved_filter": "Gespeicherte Filter", "saved_view": "Gespeicherte Ansichten", "webhook": "Webhooks", "notification": "Benachrichtigungen", "custom_field_definition": "Custom Fields", } seen_models: set[int] = set() entity_types: list[dict[str, str]] = [] for entity_type, model_class in ENTITY_MODELS.items(): # Skip aliases pointing at the same model (contact/contacts/company) if id(model_class) in seen_models: continue seen_models.add(id(model_class)) table = getattr(model_class, "__tablename__", f"{entity_type}s") entity_types.append({ "entity_type": entity_type, "label": _labels.get(entity_type, entity_type.replace("_", " ").title()), "table": table, }) entity_types.sort(key=lambda e: e["entity_type"]) return {"items": entity_types, "total": len(entity_types)} @router.post("/bulk", status_code=status.HTTP_201_CREATED) async def bulk_share_permissions( body: BulkShareRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("settings:write")), ): """Bulk share multiple entities with a principal.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) try: result = await bulk_permission_service.bulk_share( db, tenant_id, body.entity_type, body.entity_ids, body.principal_type, body.principal_id, body.level, created_by=user_id, ) return result except (ValueError, KeyError) as e: raise HTTPException(status_code=400, detail=str(e)) from e @router.post("/bulk/unshare", status_code=status.HTTP_200_OK) async def bulk_unshare_permissions( body: BulkUnshareRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("settings:write")), ): """Bulk remove permissions for a principal from multiple entities.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: result = await bulk_permission_service.bulk_unshare( db, tenant_id, body.entity_type, body.entity_ids, body.principal_type, body.principal_id, ) return result except (ValueError, KeyError) as e: raise HTTPException(status_code=400, detail=str(e)) from e @router.get("/analytics") async def get_permission_analytics( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("settings:read")), ): """Get permission analytics for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: result = await entity_permission_service.get_permission_analytics(db, tenant_id) return result except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e