fix(permissions): fix 10 high-priority permission system issues
P8: Invalidate all Redis sessions when is_system_admin changes - Added is_system_admin to UserUpdate schema and UserResponse - Added invalidate_all_user_sessions call in users.py route - Added is_system_admin param to user_service.update_user P9: Remove no-op permission resolution strategies - Only highest_wins supported, others removed as no-ops - Updated tenant.py CheckConstraint to only allow highest_wins - Added KI-Kommentar in permissions.py P10: Remove legacy check_permission from auth.py - Removed duplicate check_permission and filter_fields_by_permission - Fixed ai_copilot_service.py to use permissions.check_permission - Updated ai_copilot route to pass resolved permissions dict P11: Verified — no guest_users remnants found P12: Migrate ContactFolderPermission to EntityPermission - contact_folder_permission_service now delegates to entity_permission_service - contact_folder_service uses EntityPermission queries - Removed ContactFolderPermission from models/__init__.py - Created migration 0114 to migrate data and drop table P13: Added RLS migration history comment in alembic/env.py P14: Verified — services already apply visibility_filter - saved_filters/views filter by user_id (personal data) - workspaces are UI context only - notifications already filter by entity access P15: Split entity_permission_service.py (932 lines) into 4 modules - permission_resolver.py: get_effective_access, get_visible_ids, etc. - permission_cache.py: Redis caching functions - permission_audit.py: Audit logging helpers - entity_permission_service.py: CRUD operations + re-exports P16: Centralize PERM_RANK in permissions.py - Single source: app.core.permissions.PERM_RANK - Updated all services to import from permissions.py P17: Fix MIGRATION_DATABASE_URL to use crm_migration - docker-compose.yaml defaults changed from crm_user to crm_migration - .env.docker.example updated - prestart.sh comment updated
This commit is contained in:
@@ -1,20 +1,35 @@
|
||||
"""Contact folder permission service — ACL management and access resolution."""
|
||||
"""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 typing import Any
|
||||
|
||||
from sqlalchemy import or_, select, func
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contact_folder import ContactFolder
|
||||
from app.models.contact_folder_permission import ContactFolderPermission
|
||||
from app.models.entity_permission import EntityPermission
|
||||
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}
|
||||
_ENTITY_TYPE = "contact_folder"
|
||||
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||
|
||||
|
||||
def _rank(level: str) -> int:
|
||||
@@ -22,39 +37,32 @@ def _rank(level: str) -> int:
|
||||
|
||||
|
||||
def _serialize_permission(
|
||||
p: ContactFolderPermission,
|
||||
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.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,
|
||||
"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": p.inherit_to_subfolders,
|
||||
"inherit_to_subfolders": True, # always True after migration
|
||||
"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]
|
||||
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:
|
||||
@@ -70,11 +78,30 @@ async def list_permissions(
|
||||
)
|
||||
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.user_id) if p.user_id else None,
|
||||
group_names.get(p.group_id) if p.group_id else None,
|
||||
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
|
||||
]
|
||||
@@ -89,7 +116,12 @@ async def create_permission(
|
||||
permission_level: str,
|
||||
inherit_to_subfolders: bool = True,
|
||||
) -> dict:
|
||||
"""Create or update a permission entry for a folder."""
|
||||
"""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
|
||||
@@ -109,58 +141,51 @@ async def create_permission(
|
||||
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(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)
|
||||
)
|
||||
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
|
||||
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))
|
||||
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()
|
||||
if existing.group_id:
|
||||
g = await db.execute(select(Group.name).where(Group.id == existing.group_id))
|
||||
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 = ContactFolderPermission(
|
||||
perm = EntityPermission(
|
||||
tenant_id=tenant_id,
|
||||
folder_id=folder_uuid,
|
||||
user_id=user_uuid,
|
||||
group_id=group_uuid,
|
||||
entity_type=_ENTITY_TYPE,
|
||||
entity_id=folder_uuid,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_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))
|
||||
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()
|
||||
if perm.group_id:
|
||||
g = await db.execute(select(Group.name).where(Group.id == perm.group_id))
|
||||
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)
|
||||
|
||||
@@ -172,30 +197,34 @@ async def update_permission(
|
||||
permission_level: str,
|
||||
inherit_to_subfolders: bool | None = None,
|
||||
) -> dict:
|
||||
"""Update an existing permission entry."""
|
||||
"""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(ContactFolderPermission)
|
||||
.where(ContactFolderPermission.id == perm_uuid)
|
||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
||||
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
|
||||
if inherit_to_subfolders is not None:
|
||||
perm.inherit_to_subfolders = inherit_to_subfolders
|
||||
# inherit_to_subfolders has no equivalent in EntityPermission
|
||||
|
||||
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))
|
||||
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()
|
||||
if perm.group_id:
|
||||
g = await db.execute(select(Group.name).where(Group.id == perm.group_id))
|
||||
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)
|
||||
|
||||
@@ -206,9 +235,10 @@ async def delete_permission(
|
||||
"""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)
|
||||
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:
|
||||
@@ -229,7 +259,7 @@ async def get_effective_access(
|
||||
Resolution order (highest wins):
|
||||
1. Folder owner → "owner"
|
||||
2. Direct permission on this folder
|
||||
3. Inherited permission from ancestor folders (inherit_to_subfolders=True)
|
||||
3. Inherited permission from ancestor folders (all inherit after migration)
|
||||
4. Group membership permissions (direct + inherited)
|
||||
5. No access → "none"
|
||||
"""
|
||||
@@ -246,9 +276,10 @@ async def get_effective_access(
|
||||
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)
|
||||
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}
|
||||
@@ -282,15 +313,15 @@ async def get_effective_access(
|
||||
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)
|
||||
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():
|
||||
# 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
|
||||
# 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
|
||||
@@ -298,23 +329,24 @@ async def get_effective_access(
|
||||
# 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))
|
||||
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 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)
|
||||
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
|
||||
|
||||
@@ -356,22 +388,20 @@ async def get_visible_folder_ids(
|
||||
)
|
||||
group_ids = [row[0] for row in groups_q]
|
||||
|
||||
# Get all permission entries for this tenant
|
||||
# Get all permission entries for this tenant + entity_type
|
||||
all_perms_q = await db.execute(
|
||||
select(ContactFolderPermission)
|
||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
||||
select(EntityPermission)
|
||||
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||
.where(EntityPermission.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]]] = {}
|
||||
# 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.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))
|
||||
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] = {}
|
||||
@@ -397,9 +427,8 @@ async def get_visible_folder_ids(
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user