"""Contact folder permission service — ACL management and access resolution.""" from __future__ import annotations import uuid from typing import Any from sqlalchemy import or_, select, func from sqlalchemy.ext.asyncio import AsyncSession from app.models.contact_folder import ContactFolder from app.models.contact_folder_permission import ContactFolderPermission from app.models.group import Group, UserGroup from app.models.user import User # Permission hierarchy: higher = more access _PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "owner": 4} def _rank(level: str) -> int: return _PERM_RANK.get(level, 0) def _serialize_permission( p: ContactFolderPermission, user_name: str | None = None, group_name: str | None = None, ) -> dict: return { "id": str(p.id), "folder_id": str(p.folder_id), "user_id": str(p.user_id) if p.user_id else None, "group_id": str(p.group_id) if p.group_id else None, "user_name": user_name, "group_name": group_name, "permission_level": p.permission_level, "inherit_to_subfolders": p.inherit_to_subfolders, "created_at": p.created_at.isoformat() if p.created_at else None, } 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(ContactFolderPermission) .where(ContactFolderPermission.folder_id == folder_uuid) .where(ContactFolderPermission.tenant_id == tenant_id) .order_by(ContactFolderPermission.created_at) ) perms = result.scalars().all() # Batch-load user and group names user_ids = [p.user_id for p in perms if p.user_id] group_ids = [p.group_id for p in perms if p.group_id] 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 [ _serialize_permission( p, user_names.get(p.user_id) if p.user_id else None, group_names.get(p.group_id) if p.group_id 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.""" 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") # Check for existing entry (upsert) existing_q = await db.execute( select(ContactFolderPermission) .where(ContactFolderPermission.folder_id == folder_uuid) .where(ContactFolderPermission.tenant_id == tenant_id) .where( ContactFolderPermission.user_id == user_uuid if user_uuid else ContactFolderPermission.user_id.is_(None) ) .where( ContactFolderPermission.group_id == group_uuid if group_uuid else ContactFolderPermission.group_id.is_(None) ) ) existing = existing_q.scalar_one_or_none() if existing: existing.permission_level = permission_level existing.inherit_to_subfolders = inherit_to_subfolders await db.commit() await db.refresh(existing) # Load names for response user_name = group_name = None if existing.user_id: u = await db.execute(select(User.name).where(User.id == existing.user_id)) user_name = u.scalar_one_or_none() if existing.group_id: g = await db.execute(select(Group.name).where(Group.id == existing.group_id)) group_name = g.scalar_one_or_none() return _serialize_permission(existing, user_name, group_name) perm = ContactFolderPermission( tenant_id=tenant_id, folder_id=folder_uuid, user_id=user_uuid, group_id=group_uuid, permission_level=permission_level, inherit_to_subfolders=inherit_to_subfolders, ) db.add(perm) await db.commit() await db.refresh(perm) # Load names for response user_name = group_name = None if perm.user_id: u = await db.execute(select(User.name).where(User.id == perm.user_id)) user_name = u.scalar_one_or_none() if perm.group_id: g = await db.execute(select(Group.name).where(Group.id == perm.group_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.""" perm_uuid = uuid.UUID(permission_id) result = await db.execute( select(ContactFolderPermission) .where(ContactFolderPermission.id == perm_uuid) .where(ContactFolderPermission.tenant_id == tenant_id) ) perm = result.scalar_one_or_none() if not perm: raise ValueError("Permission not found") perm.permission_level = permission_level if inherit_to_subfolders is not None: perm.inherit_to_subfolders = inherit_to_subfolders await db.commit() await db.refresh(perm) user_name = group_name = None if perm.user_id: u = await db.execute(select(User.name).where(User.id == perm.user_id)) user_name = u.scalar_one_or_none() if perm.group_id: g = await db.execute(select(Group.name).where(Group.id == perm.group_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(ContactFolderPermission) .where(ContactFolderPermission.id == perm_uuid) .where(ContactFolderPermission.tenant_id == tenant_id) ) perm = result.scalar_one_or_none() if not perm: raise ValueError("Permission not found") await db.delete(perm) await db.commit() 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 (inherit_to_subfolders=True) 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(ContactFolderPermission.id)) .where(ContactFolderPermission.folder_id == folder_id) .where(ContactFolderPermission.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(ContactFolderPermission) .where(ContactFolderPermission.folder_id == ancestor_id) .where(ContactFolderPermission.tenant_id == tenant_id) .where(ContactFolderPermission.user_id == user_id) ) for perm in user_perm_q.scalars(): # If this is an ancestor (not the folder itself), only apply if inherit_to_subfolders if i > 0 and not perm.inherit_to_subfolders: continue 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(ContactFolderPermission) .where(ContactFolderPermission.folder_id == ancestor_id) .where(ContactFolderPermission.tenant_id == tenant_id) .where(ContactFolderPermission.group_id.in_(group_ids)) ) for perm in group_perm_q.scalars(): if i > 0 and not perm.inherit_to_subfolders: continue 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(ContactFolderPermission.id)) .where(ContactFolderPermission.folder_id == folder_id) .where(ContactFolderPermission.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 all_perms_q = await db.execute( select(ContactFolderPermission) .where(ContactFolderPermission.tenant_id == tenant_id) ) all_perms = all_perms_q.scalars().all() # Build permission lookup: folder_id → list of (principal_type, principal_id, level, inherit) perm_lookup: dict[uuid.UUID, list[tuple[str, uuid.UUID | None, str, bool]]] = {} for p in all_perms: if p.folder_id not in perm_lookup: perm_lookup[p.folder_id] = [] if p.user_id: perm_lookup[p.folder_id].append(("user", p.user_id, p.permission_level, p.inherit_to_subfolders)) if p.group_id: perm_lookup[p.folder_id].append(("group", p.group_id, p.permission_level, p.inherit_to_subfolders)) # 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, inherit in perms: if i > 0 and not inherit: continue 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