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:
@@ -7,6 +7,11 @@ This service handles:
|
||||
- Redis caching with bitmap optimization
|
||||
- Permission expiration checks
|
||||
- Audit logging for permission changes
|
||||
|
||||
Resolution, caching, and audit logic have been extracted into focused modules:
|
||||
- permission_resolver.py: get_effective_access, get_visible_ids, batch_get_effective_access, check_entity_access
|
||||
- permission_cache.py: _invalidate_user_cache, get_cached_visible_ids, invalidate_all_user_entity_cache
|
||||
- permission_audit.py: _log_permission_grant, _log_permission_update, _log_permission_revoke
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,6 +44,9 @@ from app.models.webhook import Webhook
|
||||
from app.models.custom_field_definition import CustomFieldDefinition
|
||||
from app.models.contact_folder import ContactFolder
|
||||
|
||||
# Import cache helpers used by CRUD operations
|
||||
from app.services.permission_cache import _invalidate_user_cache, CACHE_TTL, CACHE_PREFIX
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Entity Model Registry ────────────────────────────────────────────────────
|
||||
@@ -116,11 +124,7 @@ def _get_entity_model(entity_type: str) -> type:
|
||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||
return model
|
||||
|
||||
# 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
|
||||
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||
|
||||
|
||||
def _rank(level: str) -> int:
|
||||
@@ -408,422 +412,6 @@ async def delete_permission(
|
||||
await _invalidate_user_cache(redis, 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. Guest permission (principal_type='guest')
|
||||
7. owner_id IS NULL → 'read' (tenant-owned, visible to all with module permission)
|
||||
8. 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 via SQLAlchemy model (safe from SQL injection)
|
||||
model = _get_entity_model(entity_type)
|
||||
owner_q = await db.execute(
|
||||
select(model.owner_id).where(model.id == entity_id).where(model.tenant_id == 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,
|
||||
)
|
||||
)
|
||||
# Guest permission — guests are now regular users with role=guest
|
||||
# (Guest users may have no groups/roles, only direct entity_permissions)
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
EntityPermission.principal_type == "guest",
|
||||
EntityPermission.principal_id == user_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
|
||||
model = _get_entity_model(entity_type)
|
||||
admin_q = select(model.id).where(model.tenant_id == tenant_id)
|
||||
if hasattr(model, 'deleted_at'):
|
||||
admin_q = admin_q.where(model.deleted_at.is_(None))
|
||||
all_q = await db.execute(admin_q)
|
||||
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
|
||||
model = _get_entity_model(entity_type)
|
||||
owned_q_builder = select(model.id).where(model.tenant_id == tenant_id).where(model.owner_id == user_id)
|
||||
if hasattr(model, 'deleted_at'):
|
||||
owned_q_builder = owned_q_builder.where(model.deleted_at.is_(None))
|
||||
owned_q = await db.execute(owned_q_builder)
|
||||
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_q_builder = select(model.id).where(model.tenant_id == tenant_id).where(model.owner_id.is_(None))
|
||||
if hasattr(model, 'deleted_at'):
|
||||
tenant_q_builder = tenant_q_builder.where(model.deleted_at.is_(None))
|
||||
tenant_owned_q = await db.execute(tenant_q_builder)
|
||||
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,
|
||||
)
|
||||
)
|
||||
# Guest permission — guest users have no groups/roles
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
EntityPermission.principal_type == "guest",
|
||||
EntityPermission.principal_id == user_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 via SQLAlchemy model (safe from SQL injection)
|
||||
model = _get_entity_model(entity_type)
|
||||
batch_q = select(model.id, model.owner_id).where(model.id.in_(entity_ids)).where(model.tenant_id == tenant_id)
|
||||
if hasattr(model, 'deleted_at'):
|
||||
batch_q = batch_q.where(model.deleted_at.is_(None))
|
||||
owner_q = await db.execute(batch_q)
|
||||
for eid, owner_id in owner_q:
|
||||
if owner_id == user_id:
|
||||
result[eid] = "owner"
|
||||
elif owner_id is None:
|
||||
result[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,
|
||||
)
|
||||
)
|
||||
# Guest permission — guest users have no groups/roles
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
EntityPermission.principal_type == "guest",
|
||||
EntityPermission.principal_id == user_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]:
|
||||
@@ -930,3 +518,16 @@ async def cleanup_expired_permissions(db: AsyncSession) -> int:
|
||||
await db.commit()
|
||||
logger.info("Cleaned up %d expired entity permissions", count)
|
||||
return count
|
||||
|
||||
|
||||
# Backward compatibility re-exports
|
||||
from app.services.permission_resolver import ( # noqa: E402
|
||||
get_effective_access,
|
||||
get_visible_ids,
|
||||
batch_get_effective_access,
|
||||
check_entity_access,
|
||||
)
|
||||
from app.services.permission_cache import ( # noqa: E402
|
||||
get_cached_visible_ids,
|
||||
invalidate_all_user_entity_cache,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user