2026-08-06 12:05:09 +02:00
|
|
|
"""Permission resolution logic — effective access, visibility, batch resolution.
|
|
|
|
|
|
|
|
|
|
Extracted from entity_permission_service.py for modularity.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
2026-08-16 01:17:18 +02:00
|
|
|
from datetime import UTC, datetime
|
2026-08-06 12:05:09 +02:00
|
|
|
|
|
|
|
|
from sqlalchemy import and_, or_, select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.permissions import PERM_RANK as _PERM_RANK
|
|
|
|
|
from app.models.entity_permission import EntityPermission
|
|
|
|
|
from app.models.group import UserGroup
|
|
|
|
|
from app.models.user import User, UserTenant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rank(level: str) -> int:
|
|
|
|
|
return _PERM_RANK.get(level, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_entity_model(entity_type: str) -> type:
|
|
|
|
|
"""Get SQLAlchemy model class for entity_type, or raise ValueError.
|
|
|
|
|
|
|
|
|
|
Uses lazy import to avoid circular dependency with entity_permission_service.
|
|
|
|
|
"""
|
|
|
|
|
from app.services.entity_permission_service import ENTITY_MODELS
|
|
|
|
|
model = ENTITY_MODELS.get(entity_type)
|
|
|
|
|
if model is None:
|
|
|
|
|
raise ValueError(f"Unknown entity type: {entity_type}")
|
|
|
|
|
return model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
# Get user's groups and role — use ContextVar cache when available
|
|
|
|
|
from app.core.principals import get_principals
|
|
|
|
|
cached = get_principals()
|
|
|
|
|
if cached is not None and cached.user_id == user_id and cached.tenant_id == tenant_id:
|
|
|
|
|
group_ids = cached.group_ids
|
|
|
|
|
role_id = cached.role_id
|
|
|
|
|
else:
|
|
|
|
|
# Fallback: load from DB (worker, tests, non-request context)
|
|
|
|
|
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]
|
2026-08-06 12:05:09 +02:00
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
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()
|
2026-08-06 12:05:09 +02:00
|
|
|
|
|
|
|
|
# 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}
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
# Get user's groups and role — use ContextVar cache when available
|
|
|
|
|
from app.core.principals import get_principals
|
|
|
|
|
cached = get_principals()
|
|
|
|
|
if cached is not None and cached.user_id == user_id and cached.tenant_id == tenant_id:
|
|
|
|
|
group_ids = cached.group_ids
|
|
|
|
|
role_id = cached.role_id
|
|
|
|
|
else:
|
|
|
|
|
# Fallback: load from DB (worker, tests, non-request context)
|
|
|
|
|
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]
|
2026-08-06 12:05:09 +02:00
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
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()
|
2026-08-06 12:05:09 +02:00
|
|
|
|
|
|
|
|
# 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 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)
|