"""Contact folder permission service — delegates to EntityPermission. This service preserves the original public API (list_permissions, create_permission, update_permission, delete_permission, get_effective_access, get_visible_folder_ids) but internally uses the universal ``entity_permissions`` table with ``entity_type='contact_folder'``. Mapping: - folder_id → entity_id (entity_type='contact_folder') - user_id → principal_type='user', principal_id=user_id - group_id → principal_type='group', principal_id=group_id - permission_level → permission_level (same values) - inherit_to_subfolders → always treated as True (EntityPermission has no such column; all folder permissions inherit to subfolders). """ from __future__ import annotations import uuid from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.contact_folder import ContactFolder from app.models.entity_permission import EntityPermission from app.models.group import Group, UserGroup from app.models.user import User _ENTITY_TYPE = "contact_folder" from app.core.permissions import PERM_RANK as _PERM_RANK # noqa: E402 def _rank(level: str) -> int: return _PERM_RANK.get(level, 0) def _serialize_permission( p: EntityPermission, user_name: str | None = None, group_name: str | None = None, ) -> dict: """Serialize EntityPermission back to the legacy folder-permission format.""" user_id = str(p.principal_id) if p.principal_type == "user" else None group_id = str(p.principal_id) if p.principal_type == "group" else None return { "id": str(p.id), "folder_id": str(p.entity_id), "user_id": user_id, "group_id": group_id, "user_name": user_name, "group_name": group_name, "permission_level": p.permission_level, "inherit_to_subfolders": True, # always True after migration "created_at": p.created_at.isoformat() if p.created_at else None, } async def _load_names( db: AsyncSession, perms: list[EntityPermission] ) -> tuple[dict[uuid.UUID, str], dict[uuid.UUID, str]]: """Batch-load user and group names for a list of permissions.""" user_ids = [p.principal_id for p in perms if p.principal_type == "user"] group_ids = [p.principal_id for p in perms if p.principal_type == "group"] user_names: dict[uuid.UUID, str] = {} if user_ids: users_q = await db.execute( select(User.id, User.name).where(User.id.in_(user_ids)) ) user_names = {row[0]: row[1] for row in users_q} group_names: dict[uuid.UUID, str] = {} if group_ids: groups_q = await db.execute( select(Group.id, Group.name).where(Group.id.in_(group_ids)) ) group_names = {row[0]: row[1] for row in groups_q} return user_names, group_names async def list_permissions( db: AsyncSession, tenant_id: uuid.UUID, folder_id: str ) -> list[dict]: """List all permission entries for a folder.""" folder_uuid = uuid.UUID(folder_id) result = await db.execute( select(EntityPermission) .where(EntityPermission.entity_type == _ENTITY_TYPE) .where(EntityPermission.entity_id == folder_uuid) .where(EntityPermission.tenant_id == tenant_id) .order_by(EntityPermission.created_at) ) perms = result.scalars().all() user_names, group_names = await _load_names(db, perms) return [ _serialize_permission( p, user_names.get(p.principal_id) if p.principal_type == "user" else None, group_names.get(p.principal_id) if p.principal_type == "group" else None, ) for p in perms ] async def create_permission( db: AsyncSession, tenant_id: uuid.UUID, folder_id: str, user_id: str | None, group_id: str | None, permission_level: str, inherit_to_subfolders: bool = True, ) -> dict: """Create or update a permission entry for a folder. Delegates to EntityPermission with entity_type='contact_folder'. ``inherit_to_subfolders`` is accepted for API compatibility but has no effect (all folder permissions inherit to subfolders after migration). """ folder_uuid = uuid.UUID(folder_id) # Verify folder exists and belongs to tenant folder_q = await db.execute( select(ContactFolder) .where(ContactFolder.id == folder_uuid) .where(ContactFolder.tenant_id == tenant_id) ) if not folder_q.scalar_one_or_none(): raise ValueError("Folder not found") user_uuid = uuid.UUID(user_id) if user_id else None group_uuid = uuid.UUID(group_id) if group_id else None if not user_uuid and not group_uuid: raise ValueError("Either user_id or group_id must be provided") if user_uuid and group_uuid: raise ValueError("Only one of user_id or group_id can be provided") principal_type = "user" if user_uuid else "group" principal_uuid = user_uuid or group_uuid # Check for existing entry (upsert) existing_q = await db.execute( select(EntityPermission) .where(EntityPermission.entity_type == _ENTITY_TYPE) .where(EntityPermission.entity_id == folder_uuid) .where(EntityPermission.principal_type == principal_type) .where(EntityPermission.principal_id == principal_uuid) .where(EntityPermission.tenant_id == tenant_id) ) existing = existing_q.scalar_one_or_none() if existing: existing.permission_level = permission_level await db.flush() await db.refresh(existing) user_name = group_name = None if existing.principal_type == "user": u = await db.execute(select(User.name).where(User.id == existing.principal_id)) user_name = u.scalar_one_or_none() else: g = await db.execute(select(Group.name).where(Group.id == existing.principal_id)) group_name = g.scalar_one_or_none() return _serialize_permission(existing, user_name, group_name) perm = EntityPermission( tenant_id=tenant_id, entity_type=_ENTITY_TYPE, entity_id=folder_uuid, principal_type=principal_type, principal_id=principal_uuid, permission_level=permission_level, ) db.add(perm) await db.flush() await db.refresh(perm) user_name = group_name = None if perm.principal_type == "user": u = await db.execute(select(User.name).where(User.id == perm.principal_id)) user_name = u.scalar_one_or_none() else: g = await db.execute(select(Group.name).where(Group.id == perm.principal_id)) group_name = g.scalar_one_or_none() return _serialize_permission(perm, user_name, group_name) async def update_permission( db: AsyncSession, tenant_id: uuid.UUID, permission_id: str, permission_level: str, inherit_to_subfolders: bool | None = None, ) -> dict: """Update an existing permission entry. ``inherit_to_subfolders`` is accepted for API compatibility but has no effect. """ perm_uuid = uuid.UUID(permission_id) result = await db.execute( select(EntityPermission) .where(EntityPermission.id == perm_uuid) .where(EntityPermission.tenant_id == tenant_id) .where(EntityPermission.entity_type == _ENTITY_TYPE) ) perm = result.scalar_one_or_none() if not perm: raise ValueError("Permission not found") perm.permission_level = permission_level # inherit_to_subfolders has no equivalent in EntityPermission await db.flush() await db.refresh(perm) user_name = group_name = None if perm.principal_type == "user": u = await db.execute(select(User.name).where(User.id == perm.principal_id)) user_name = u.scalar_one_or_none() else: g = await db.execute(select(Group.name).where(Group.id == perm.principal_id)) group_name = g.scalar_one_or_none() return _serialize_permission(perm, user_name, group_name) async def delete_permission( db: AsyncSession, tenant_id: uuid.UUID, permission_id: str ) -> None: """Delete a permission entry.""" perm_uuid = uuid.UUID(permission_id) result = await db.execute( select(EntityPermission) .where(EntityPermission.id == perm_uuid) .where(EntityPermission.tenant_id == tenant_id) .where(EntityPermission.entity_type == _ENTITY_TYPE) ) perm = result.scalar_one_or_none() if not perm: raise ValueError("Permission not found") await db.delete(perm) await db.flush() async def get_effective_access( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, folder_id: uuid.UUID, ) -> dict: """Get the effective access level for a user on a folder. Resolution order (highest wins): 1. Folder owner → "owner" 2. Direct permission on this folder 3. Inherited permission from ancestor folders (all inherit after migration) 4. Group membership permissions (direct + inherited) 5. No access → "none" """ # Check ownership folder_q = await db.execute( select(ContactFolder) .where(ContactFolder.id == folder_id) .where(ContactFolder.tenant_id == tenant_id) ) folder = folder_q.scalar_one_or_none() if not folder: return {"folder_id": str(folder_id), "access_level": "none", "is_owner": False, "is_shared": False, "inherited_from": None} if folder.user_id == user_id: # Check if shared with anyone shared_q = await db.execute( select(func.count(EntityPermission.id)) .where(EntityPermission.entity_type == _ENTITY_TYPE) .where(EntityPermission.entity_id == folder_id) .where(EntityPermission.tenant_id == tenant_id) ) is_shared = (shared_q.scalar() or 0) > 0 return {"folder_id": str(folder_id), "access_level": "owner", "is_owner": True, "is_shared": is_shared, "inherited_from": None} # Build ancestor chain (folder → parent → grandparent → ...) ancestor_chain: list[uuid.UUID] = [folder_id] current = folder while current.parent_id: ancestor_chain.append(current.parent_id) parent_q = await db.execute( select(ContactFolder) .where(ContactFolder.id == current.parent_id) .where(ContactFolder.tenant_id == tenant_id) ) current = parent_q.scalar_one_or_none() if not current: break # Get user's group memberships groups_q = await db.execute( select(UserGroup.group_id) .where(UserGroup.user_id == user_id) .where(UserGroup.tenant_id == tenant_id) ) group_ids = [row[0] for row in groups_q] best_level = "none" inherited_from = None # Walk ancestor chain from closest to furthest for i, ancestor_id in enumerate(ancestor_chain): # Direct user permission user_perm_q = await db.execute( select(EntityPermission) .where(EntityPermission.entity_type == _ENTITY_TYPE) .where(EntityPermission.entity_id == ancestor_id) .where(EntityPermission.tenant_id == tenant_id) .where(EntityPermission.principal_type == "user") .where(EntityPermission.principal_id == user_id) ) for perm in user_perm_q.scalars(): # All permissions inherit after migration (inherit_to_subfolders always True) if _rank(perm.permission_level) > _rank(best_level): best_level = perm.permission_level inherited_from = str(ancestor_id) if i > 0 else None # Group permissions if group_ids: group_perm_q = await db.execute( select(EntityPermission) .where(EntityPermission.entity_type == _ENTITY_TYPE) .where(EntityPermission.entity_id == ancestor_id) .where(EntityPermission.tenant_id == tenant_id) .where(EntityPermission.principal_type == "group") .where(EntityPermission.principal_id.in_(group_ids)) ) for perm in group_perm_q.scalars(): if _rank(perm.permission_level) > _rank(best_level): best_level = perm.permission_level inherited_from = str(ancestor_id) if i > 0 else None # Check if folder is shared at all shared_q = await db.execute( select(func.count(EntityPermission.id)) .where(EntityPermission.entity_type == _ENTITY_TYPE) .where(EntityPermission.entity_id == folder_id) .where(EntityPermission.tenant_id == tenant_id) ) is_shared = (shared_q.scalar() or 0) > 0 return { "folder_id": str(folder_id), "access_level": best_level, "is_owner": False, "is_shared": is_shared, "inherited_from": inherited_from, } async def get_visible_folder_ids( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, ) -> tuple[list[uuid.UUID], dict[uuid.UUID, str]]: """Get all folder IDs visible to a user and their access levels. Returns (visible_folder_ids, access_map) where access_map is folder_id → access_level string. """ # Get all folders in tenant all_folders_q = await db.execute( select(ContactFolder) .where(ContactFolder.tenant_id == tenant_id) .where(ContactFolder.deleted_at.is_(None)) ) all_folders = all_folders_q.scalars().all() visible: list[uuid.UUID] = [] access_map: dict[uuid.UUID, str] = {} # Get user's group memberships groups_q = await db.execute( select(UserGroup.group_id) .where(UserGroup.user_id == user_id) .where(UserGroup.tenant_id == tenant_id) ) group_ids = [row[0] for row in groups_q] # Get all permission entries for this tenant + entity_type all_perms_q = await db.execute( select(EntityPermission) .where(EntityPermission.entity_type == _ENTITY_TYPE) .where(EntityPermission.tenant_id == tenant_id) ) all_perms = all_perms_q.scalars().all() # Build permission lookup: entity_id → list of (principal_type, principal_id, level) perm_lookup: dict[uuid.UUID, list[tuple[str, uuid.UUID, str]]] = {} for p in all_perms: if p.entity_id not in perm_lookup: perm_lookup[p.entity_id] = [] perm_lookup[p.entity_id].append((p.principal_type, p.principal_id, p.permission_level)) # Build parent map for ancestor traversal parent_map: dict[uuid.UUID, uuid.UUID | None] = {} for f in all_folders: parent_map[f.id] = f.parent_id for folder in all_folders: # Owner sees everything they own if folder.user_id == user_id: visible.append(folder.id) access_map[folder.id] = "owner" continue # Check effective access via permissions (including inherited) best_level = "none" # Build ancestor chain chain: list[uuid.UUID] = [folder.id] current_parent = parent_map.get(folder.id) while current_parent: chain.append(current_parent) current_parent = parent_map.get(current_parent) for _i, ancestor_id in enumerate(chain): perms = perm_lookup.get(ancestor_id, []) for ptype, pid, level in perms: # All permissions inherit after migration if ptype == "user" and pid == user_id: if _rank(level) > _rank(best_level): best_level = level elif ptype == "group" and pid in group_ids: if _rank(level) > _rank(best_level): best_level = level if best_level != "none": visible.append(folder.id) access_map[folder.id] = best_level return visible, access_map