"""Universal entity permission service — ACL management for ALL entities. This service handles: - CRUD for entity_permissions - Effective access resolution (owner → user → group → role → guest) - Batch resolution for list queries - Redis caching with bitmap optimization - Permission expiration checks - Audit logging for permission changes """ from __future__ import annotations import json import logging import uuid from datetime import datetime, UTC from typing import Any import redis.asyncio as aioredis from sqlalchemy import and_, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.notifications import create_notification from app.models.entity_permission import EntityPermission from app.models.group import Group, UserGroup from app.models.role import Role from app.models.user import User, UserTenant logger = logging.getLogger(__name__) # Permission hierarchy: higher = more access _PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "delete": 4, "owner": 5} CACHE_TTL = 300 # 5 minutes CACHE_PREFIX = "ep_vis" # entity permission visibility def _rank(level: str) -> int: return _PERM_RANK.get(level, 0) def _serialize_permission(p: EntityPermission, principal_name: str | None = None) -> dict: return { "id": str(p.id), "entity_type": p.entity_type, "entity_id": str(p.entity_id), "principal_type": p.principal_type, "principal_id": str(p.principal_id), "principal_name": principal_name, "permission_level": p.permission_level, "expires_at": p.expires_at.isoformat() if p.expires_at else None, "created_by": str(p.created_by) if p.created_by else None, "created_at": p.created_at.isoformat() if p.created_at else None, } async def _load_principal_names( db: AsyncSession, perms: list[EntityPermission] ) -> dict[uuid.UUID, str]: """Batch-load names for all principals in a permission list.""" 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"] role_ids = [p.principal_id for p in perms if p.principal_type == "role"] names: dict[uuid.UUID, str] = {} if user_ids: result = await db.execute(select(User.id, User.name).where(User.id.in_(user_ids))) names.update({row[0]: row[1] for row in result}) if group_ids: result = await db.execute(select(Group.id, Group.name).where(Group.id.in_(group_ids))) names.update({row[0]: row[1] for row in result}) if role_ids: result = await db.execute(select(Role.id, Role.name).where(Role.id.in_(role_ids))) names.update({row[0]: row[1] for row in result}) return names async def list_permissions( db: AsyncSession, tenant_id: uuid.UUID, entity_type: str, entity_id: str ) -> list[dict]: """List all permission entries for a specific entity.""" entity_uuid = uuid.UUID(entity_id) result = await db.execute( select(EntityPermission) .where(EntityPermission.entity_type == entity_type) .where(EntityPermission.entity_id == entity_uuid) .where(EntityPermission.tenant_id == tenant_id) .order_by(EntityPermission.created_at) ) perms = result.scalars().all() names = await _load_principal_names(db, perms) return [_serialize_permission(p, names.get(p.principal_id)) for p in perms] async def create_permission( db: AsyncSession, tenant_id: uuid.UUID, entity_type: str, entity_id: str, principal_type: str, principal_id: str, permission_level: str, expires_at: datetime | None = None, created_by: uuid.UUID | None = None, ) -> dict: """Create or update a permission entry (upsert).""" entity_uuid = uuid.UUID(entity_id) principal_uuid = uuid.UUID(principal_id) # Check for existing entry (upsert) existing_q = await db.execute( select(EntityPermission) .where(EntityPermission.entity_type == entity_type) .where(EntityPermission.entity_id == entity_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.expires_at = expires_at await db.commit() await db.refresh(existing) names = await _load_principal_names(db, [existing]) # Audit log for permission update await log_audit( db, tenant_id, created_by, action='permission_update', entity_type='entity_permission', entity_id=existing.id, changes={'permission_level': permission_level, 'principal_type': principal_type, 'principal_id': principal_id} ) # Notify user if direct permission if principal_type == 'user': await create_notification( db, tenant_id, principal_uuid, type='permission_granted', title='Neue Berechtigung', body=f'{entity_type} wurde mit dir geteilt', entity_type=entity_type, entity_id=entity_uuid, ) return _serialize_permission(existing, names.get(existing.principal_id)) perm = EntityPermission( tenant_id=tenant_id, entity_type=entity_type, entity_id=entity_uuid, principal_type=principal_type, principal_id=principal_uuid, permission_level=permission_level, expires_at=expires_at, created_by=created_by, ) db.add(perm) await db.commit() await db.refresh(perm) # Invalidate cache for this principal if principal_type == "user": await _invalidate_user_cache(None, tenant_id, principal_uuid, entity_type) elif principal_type == "group": # Invalidate for all group members members_q = await db.execute( select(UserGroup.user_id) .where(UserGroup.group_id == principal_uuid) .where(UserGroup.tenant_id == tenant_id) ) for (uid,) in members_q: await _invalidate_user_cache(None, tenant_id, uid, entity_type) # Audit log for new permission await log_audit( db, tenant_id, created_by, action='permission_grant', entity_type='entity_permission', entity_id=perm.id, changes={'permission_level': permission_level, 'principal_type': principal_type, 'principal_id': principal_id} ) # Notify user if direct permission if principal_type == 'user': await create_notification( db, tenant_id, principal_uuid, type='permission_granted', title='Neue Berechtigung', body=f'{entity_type} wurde mit dir geteilt', entity_type=entity_type, entity_id=entity_uuid, ) names = await _load_principal_names(db, [perm]) return _serialize_permission(perm, names.get(perm.principal_id)) async def update_permission( db: AsyncSession, tenant_id: uuid.UUID, permission_id: str, permission_level: str, expires_at: datetime | None = None, ) -> dict: """Update an existing 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) ) perm = result.scalar_one_or_none() if not perm: raise ValueError("Permission not found") old_principal_type = perm.principal_type old_principal_id = perm.principal_id old_entity_type = perm.entity_type perm.permission_level = permission_level if expires_at is not None: perm.expires_at = expires_at await db.commit() await db.refresh(perm) # Invalidate cache if old_principal_type == "user": await _invalidate_user_cache(None, tenant_id, old_principal_id, old_entity_type) elif old_principal_type == "group": members_q = await db.execute( select(UserGroup.user_id) .where(UserGroup.group_id == old_principal_id) .where(UserGroup.tenant_id == tenant_id) ) for (uid,) in members_q: await _invalidate_user_cache(None, tenant_id, uid, old_entity_type) # Audit log for permission update await log_audit( db, tenant_id, None, action='permission_update', entity_type='entity_permission', entity_id=perm.id, changes={'permission_level': permission_level, 'principal_type': old_principal_type, 'principal_id': str(old_principal_id)} ) names = await _load_principal_names(db, [perm]) return _serialize_permission(perm, names.get(perm.principal_id)) 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) ) perm = result.scalar_one_or_none() if not perm: raise ValueError("Permission not found") old_principal_type = perm.principal_type old_principal_id = perm.principal_id old_entity_type = perm.entity_type old_entity_id = perm.entity_id # Audit log for permission revoke await log_audit( db, tenant_id, None, action='permission_revoke', entity_type='entity_permission', entity_id=perm.id, changes={'permission_level': perm.permission_level, 'principal_type': old_principal_type, 'principal_id': str(old_principal_id)} ) # Notify user if direct permission if old_principal_type == 'user': await create_notification( db, tenant_id, old_principal_id, type='permission_revoked', title='Berechtigung entfernt', body=f'{old_entity_type} wurde nicht mehr mit dir geteilt', entity_type=old_entity_type, entity_id=old_entity_id, ) await db.delete(perm) await db.commit() # Invalidate cache if old_principal_type == "user": await _invalidate_user_cache(None, tenant_id, old_principal_id, old_entity_type) elif old_principal_type == "group": members_q = await db.execute( select(UserGroup.user_id) .where(UserGroup.group_id == old_principal_id) .where(UserGroup.tenant_id == tenant_id) ) for (uid,) in members_q: await _invalidate_user_cache(None, tenant_id, uid, old_entity_type) async def get_effective_access( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, ) -> str: """Get the effective access level for a user on a specific entity. Resolution (highest wins): 1. System admin → 'delete' (full access) 2. Owner → 'owner' (from owner_id on the entity table) 3. Direct user permission 4. Group permission (via user_groups) 5. Role permission (via user_tenants.role_id) 6. owner_id IS NULL → 'read' (tenant-owned, visible to all with module permission) 7. No access → 'none' Returns: 'none' | 'read' | 'write' | 'admin' | 'delete' | 'owner' """ # Check system admin user_q = await db.execute( select(User.is_system_admin).where(User.id == user_id) ) if user_q.scalar(): return "delete" # Check ownership — load the entity's owner_id # We use raw SQL to avoid importing every model owner_q = await db.execute( text(f"SELECT owner_id FROM {entity_type}s WHERE id = :eid AND tenant_id = :tid"), {"eid": entity_id, "tid": tenant_id}, ) owner_row = owner_q.first() if not owner_row: return "none" owner_id = owner_row[0] if owner_id == user_id: return "owner" # Get user's groups and role 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] role_q = await db.execute( select(UserTenant.role_id) .where(UserTenant.user_id == user_id) .where(UserTenant.tenant_id == tenant_id) ) role_id = role_q.scalar_one_or_none() # Build principal conditions principal_conditions = [ and_( EntityPermission.principal_type == "user", EntityPermission.principal_id == user_id, ), ] if group_ids: principal_conditions.append( and_( EntityPermission.principal_type == "group", EntityPermission.principal_id.in_(group_ids), ) ) if role_id: principal_conditions.append( and_( EntityPermission.principal_type == "role", EntityPermission.principal_id == role_id, ) ) # Query permissions now = datetime.now(UTC) perm_q = await db.execute( select(EntityPermission.permission_level) .where(EntityPermission.entity_type == entity_type) .where(EntityPermission.entity_id == entity_id) .where(EntityPermission.tenant_id == tenant_id) .where(or_(*principal_conditions)) .where( or_( EntityPermission.expires_at.is_(None), EntityPermission.expires_at > now, ) ) ) best_level = "none" for (level,) in perm_q: if _rank(level) > _rank(best_level): best_level = level # If owner_id is NULL (tenant-owned), user with module permission gets at least 'read' if best_level == "none" and owner_id is None: return "read" return best_level async def get_visible_ids( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, ) -> tuple[set[uuid.UUID], dict[uuid.UUID, str]]: """Get all visible entity IDs for a user and their access levels. Returns (visible_ids, access_map) where access_map is entity_id → access_level string. Resolution: 1. System admin → all entities at 'delete' level 2. Owned entities → 'owner' 3. Entities with direct/group/role permissions → permission level 4. Tenant-owned entities (owner_id IS NULL) → 'read' """ # Check system admin user_q = await db.execute( select(User.is_system_admin).where(User.id == user_id) ) if user_q.scalar(): # Return all entity IDs all_q = await db.execute( text(f"SELECT id FROM {entity_type}s WHERE tenant_id = :tid AND deleted_at IS NULL"), {"tid": tenant_id}, ) all_ids = {row[0] for row in all_q} return all_ids, {eid: "delete" for eid in all_ids} # Get user's groups and role 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] role_q = await db.execute( select(UserTenant.role_id) .where(UserTenant.user_id == user_id) .where(UserTenant.tenant_id == tenant_id) ) role_id = role_q.scalar_one_or_none() # 1. Owned entities owned_q = await db.execute( text(f"SELECT id FROM {entity_type}s WHERE tenant_id = :tid AND owner_id = :uid AND deleted_at IS NULL"), {"tid": tenant_id, "uid": user_id}, ) visible: set[uuid.UUID] = set() access_map: dict[uuid.UUID, str] = {} for (eid,) in owned_q: visible.add(eid) access_map[eid] = "owner" # 2. Tenant-owned entities (owner_id IS NULL) tenant_owned_q = await db.execute( text(f"SELECT id FROM {entity_type}s WHERE tenant_id = :tid AND owner_id IS NULL AND deleted_at IS NULL"), {"tid": tenant_id}, ) for (eid,) in tenant_owned_q: if eid not in visible: visible.add(eid) access_map[eid] = "read" # 3. Permission-based access now = datetime.now(UTC) principal_conditions = [ and_( EntityPermission.principal_type == "user", EntityPermission.principal_id == user_id, ), ] if group_ids: principal_conditions.append( and_( EntityPermission.principal_type == "group", EntityPermission.principal_id.in_(group_ids), ) ) if role_id: principal_conditions.append( and_( EntityPermission.principal_type == "role", EntityPermission.principal_id == role_id, ) ) perm_q = await db.execute( select(EntityPermission.entity_id, EntityPermission.permission_level) .where(EntityPermission.entity_type == entity_type) .where(EntityPermission.tenant_id == tenant_id) .where(or_(*principal_conditions)) .where( or_( EntityPermission.expires_at.is_(None), EntityPermission.expires_at > now, ) ) ) for eid, level in perm_q: if eid not in visible or _rank(level) > _rank(access_map.get(eid, "none")): visible.add(eid) access_map[eid] = level return visible, access_map async def batch_get_effective_access( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, entity_ids: list[uuid.UUID], ) -> dict[uuid.UUID, str]: """Batch resolution: get access levels for multiple entities at once. Much more efficient than calling get_effective_access() in a loop. """ if not entity_ids: return {} # Check system admin user_q = await db.execute( select(User.is_system_admin).where(User.id == user_id) ) if user_q.scalar(): return {eid: "delete" for eid in entity_ids} # Get user's groups and role 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] role_q = await db.execute( select(UserTenant.role_id) .where(UserTenant.user_id == user_id) .where(UserTenant.tenant_id == tenant_id) ) role_id = role_q.scalar_one_or_none() result: dict[uuid.UUID, str] = {} # 1. Check ownership owner_q = await db.execute( text(f"SELECT id, owner_id FROM {entity_type}s WHERE id = ANY(:ids) AND tenant_id = :tid AND deleted_at IS NULL"), {"ids": [str(eid) for eid in entity_ids], "tid": tenant_id}, ) for eid, owner_id in owner_q: if owner_id == user_id: result[uuid.UUID(str(eid))] = "owner" elif owner_id is None: result[uuid.UUID(str(eid))] = "read" # 2. Check permissions now = datetime.now(UTC) principal_conditions = [ and_( EntityPermission.principal_type == "user", EntityPermission.principal_id == user_id, ), ] if group_ids: principal_conditions.append( and_( EntityPermission.principal_type == "group", EntityPermission.principal_id.in_(group_ids), ) ) if role_id: principal_conditions.append( and_( EntityPermission.principal_type == "role", EntityPermission.principal_id == role_id, ) ) perm_q = await db.execute( select(EntityPermission.entity_id, EntityPermission.permission_level) .where(EntityPermission.entity_type == entity_type) .where(EntityPermission.entity_id.in_(entity_ids)) .where(EntityPermission.tenant_id == tenant_id) .where(or_(*principal_conditions)) .where( or_( EntityPermission.expires_at.is_(None), EntityPermission.expires_at > now, ) ) ) for eid, level in perm_q: current = result.get(eid, "none") if _rank(level) > _rank(current): result[eid] = level # Fill in 'none' for entities not found for eid in entity_ids: if eid not in result: result[eid] = "none" return result async def _invalidate_user_cache( redis: aioredis.Redis | None, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, ) -> None: """Invalidate the visibility cache for a user + entity type.""" if redis is None: return cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}:{entity_type}" await redis.delete(cache_key) async def get_cached_visible_ids( db: AsyncSession, redis: aioredis.Redis, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, ) -> tuple[set[uuid.UUID], dict[uuid.UUID, str]]: """Get visible IDs from Redis cache or resolve from DB. Cache key: ep_vis:{user_id}:{tenant_id}:{entity_type} Cache value: JSON {visible_ids: [str], access_map: {str: str}} TTL: 5 minutes """ cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}:{entity_type}" raw = await redis.get(cache_key) if raw is not None: data = json.loads(raw) visible = {uuid.UUID(eid) for eid in data.get("visible_ids", [])} access_map = {uuid.UUID(eid): level for eid, level in data.get("access_map", {}).items()} return visible, access_map # Cache miss — resolve from DB visible, access_map = await get_visible_ids(db, tenant_id, user_id, entity_type) # Store in cache cache_data = { "visible_ids": [str(eid) for eid in visible], "access_map": {str(eid): level for eid, level in access_map.items()}, } await redis.setex(cache_key, CACHE_TTL, json.dumps(cache_data)) return visible, access_map async def invalidate_all_user_entity_cache( redis: aioredis.Redis, tenant_id: uuid.UUID, user_id: uuid.UUID, ) -> None: """Invalidate all entity permission caches for a user.""" pattern = f"{CACHE_PREFIX}:{user_id}:{tenant_id}:*" batch_size = 200 cursor: int | bytes | str = 0 while True: cursor, keys = await redis.scan(cursor=cursor, match=pattern, count=batch_size) if keys: await redis.delete(*keys) if int(cursor) == 0: break async def check_entity_access( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, required_level: str = "read", ) -> bool: """Check if user has at least the required access level on an entity.""" access = await get_effective_access(db, tenant_id, user_id, entity_type, entity_id) return _rank(access) >= _rank(required_level) async def list_all_permissions( db: AsyncSession, tenant_id: uuid.UUID ) -> list[dict]: """List ALL permission entries for a tenant (global view).""" result = await db.execute( select(EntityPermission) .where(EntityPermission.tenant_id == tenant_id) .order_by(EntityPermission.entity_type, EntityPermission.created_at) ) perms = result.scalars().all() names = await _load_principal_names(db, perms) return [_serialize_permission(p, names.get(p.principal_id)) for p in perms] async def get_permission_analytics( db: AsyncSession, tenant_id: uuid.UUID, ) -> dict: """Get permission analytics for a tenant. Returns: total_permissions: Total number of permission entries total_shared_entities: Number of unique entities with permissions permissions_by_level: Breakdown by permission level permissions_by_entity_type: Breakdown by entity type recent_changes: Last 10 permission changes """ from sqlalchemy import func as sa_func # Total permissions total_q = await db.execute( select(sa_func.count(EntityPermission.id)) .where(EntityPermission.tenant_id == tenant_id) ) total_permissions = total_q.scalar() or 0 # Total unique shared entities unique_q = await db.execute( select(sa_func.count(sa_func.distinct( EntityPermission.entity_type + ":" + EntityPermission.entity_id.cast(String) ))) .where(EntityPermission.tenant_id == tenant_id) ) total_shared_entities = unique_q.scalar() or 0 # Permissions by level level_q = await db.execute( select(EntityPermission.permission_level, sa_func.count(EntityPermission.id)) .where(EntityPermission.tenant_id == tenant_id) .group_by(EntityPermission.permission_level) ) permissions_by_level = {row[0]: row[1] for row in level_q} # Permissions by entity type type_q = await db.execute( select(EntityPermission.entity_type, sa_func.count(EntityPermission.id)) .where(EntityPermission.tenant_id == tenant_id) .group_by(EntityPermission.entity_type) ) permissions_by_entity_type = {row[0]: row[1] for row in type_q} # Recent changes (last 10) recent_q = await db.execute( select(EntityPermission) .where(EntityPermission.tenant_id == tenant_id) .order_by(EntityPermission.updated_at.desc()) .limit(10) ) recent = recent_q.scalars().all() recent_changes = [ { "id": str(p.id), "entity_type": p.entity_type, "entity_id": str(p.entity_id), "principal_type": p.principal_type, "principal_id": str(p.principal_id), "permission_level": p.permission_level, "updated_at": p.updated_at.isoformat() if p.updated_at else None, } for p in recent ] return { "total_permissions": total_permissions, "total_shared_entities": total_shared_entities, "permissions_by_level": permissions_by_level, "permissions_by_entity_type": permissions_by_entity_type, "recent_changes": recent_changes, } async def cleanup_expired_permissions(db: AsyncSession) -> int: """Delete all expired permission entries. Returns count deleted.""" now = datetime.now(UTC) result = await db.execute( select(EntityPermission).where(EntityPermission.expires_at < now) ) expired = result.scalars().all() count = len(expired) for perm in expired: await db.delete(perm) if count > 0: await db.commit() logger.info("Cleaned up %d expired entity permissions", count) return count