Files
leocrm/app/services/contact_folder_permission_service.py
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

443 lines
16 KiB
Python

"""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