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:
Agent Zero
2026-08-06 12:05:09 +02:00
parent 8060505baa
commit 627360113f
22 changed files with 905 additions and 603 deletions
+134 -105
View File
@@ -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