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
+4 -3
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit
from app.core.auth import check_permission
from app.core.permissions import check_permission
from app.core.visibility import apply_visibility_filter
from app.core.visibility import check_single_entity_access
from app.models.ai_conversation import AIConversation, AIMessage
@@ -159,7 +159,7 @@ async def execute_action(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
role: str,
resolved: dict[str, Any],
conversation_id: str,
action: dict[str, Any],
is_system_admin: bool = False,
@@ -191,8 +191,9 @@ async def execute_action(
# Determine module and action_type from path for RBAC
module, action_type = _derive_rbac_from_path(method, path)
required_perm = f"{module}:{action_type}"
if not check_permission(role, module, action_type):
if not check_permission(resolved, required_perm):
return {
"error": "Insufficient permissions for this action",
"status_code": 403,
+1 -1
View File
@@ -20,7 +20,7 @@ from app.models.user import User, UserTenant
logger = logging.getLogger(__name__)
_PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "delete": 4, "owner": 5}
from app.core.permissions import PERM_RANK as _PERM_RANK
def _rank(level: str) -> int:
+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
+6 -5
View File
@@ -64,12 +64,13 @@ async def list_folders(
counts[fid] = cnt
# Check which folders have permissions (are shared)
from app.models.contact_folder_permission import ContactFolderPermission
from app.models.entity_permission import EntityPermission
shared_q = await db.execute(
select(ContactFolderPermission.folder_id)
.where(ContactFolderPermission.folder_id.in_(folder_ids))
.where(ContactFolderPermission.tenant_id == tenant_id)
.group_by(ContactFolderPermission.folder_id)
select(EntityPermission.entity_id)
.where(EntityPermission.entity_type == "contact_folder")
.where(EntityPermission.entity_id.in_(folder_ids))
.where(EntityPermission.tenant_id == tenant_id)
.group_by(EntityPermission.entity_id)
)
shared_ids = {row[0] for row in shared_q}
+22 -421
View File
@@ -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,
)
+97
View File
@@ -0,0 +1,97 @@
"""Audit logging helpers for entity permission changes.
Extracted from entity_permission_service.py for modularity.
"""
from __future__ import annotations
import uuid
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
async def _log_permission_grant(
db: AsyncSession,
tenant_id: uuid.UUID,
created_by: uuid.UUID | None,
perm: EntityPermission,
permission_level: str,
principal_type: str,
principal_id: str,
) -> None:
"""Log audit entry for a new permission grant and notify the user."""
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, uuid.UUID(principal_id),
type='permission_granted',
title='Neue Berechtigung',
body=f'{perm.entity_type} wurde mit dir geteilt',
entity_type=perm.entity_type,
entity_id=perm.entity_id,
)
async def _log_permission_update(
db: AsyncSession,
tenant_id: uuid.UUID,
created_by: uuid.UUID | None,
perm: EntityPermission,
permission_level: str,
principal_type: str,
principal_id: str,
) -> None:
"""Log audit entry for a permission update and notify the user."""
await log_audit(
db, tenant_id, created_by,
action='permission_update',
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, uuid.UUID(principal_id),
type='permission_granted',
title='Neue Berechtigung',
body=f'{perm.entity_type} wurde mit dir geteilt',
entity_type=perm.entity_type,
entity_id=perm.entity_id,
)
async def _log_permission_revoke(
db: AsyncSession,
tenant_id: uuid.UUID,
perm: EntityPermission,
) -> None:
"""Log audit entry for a permission revoke and notify the user."""
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': perm.principal_type, 'principal_id': str(perm.principal_id)}
)
# Notify user if direct permission
if perm.principal_type == 'user':
await create_notification(
db, tenant_id, perm.principal_id,
type='permission_revoked',
title='Berechtigung entfernt',
body=f'{perm.entity_type} wurde nicht mehr mit dir geteilt',
entity_type=perm.entity_type,
entity_id=perm.entity_id,
)
+82
View File
@@ -0,0 +1,82 @@
"""Redis caching for entity permission visibility.
Extracted from entity_permission_service.py for modularity.
"""
from __future__ import annotations
import json
import uuid
import redis.asyncio as aioredis
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.permission_resolver import get_visible_ids
CACHE_TTL = 300 # 5 minutes
CACHE_PREFIX = "ep_vis" # entity permission visibility
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
+384
View File
@@ -0,0 +1,384 @@
"""Permission resolution logic — effective access, visibility, batch resolution.
Extracted from entity_permission_service.py for modularity.
"""
from __future__ import annotations
import uuid
from datetime import datetime, UTC
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"
# 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 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)
+3
View File
@@ -151,6 +151,7 @@ class UserService:
email: str | None = None,
current_password: str | None = None,
new_password: str | None = None,
is_system_admin: bool | None = None,
) -> tuple[User, UserTenant] | None:
"""Update a user and their tenant membership.
@@ -181,6 +182,8 @@ class UserService:
user_tenant.role_id = role_id
if is_active is not None:
user.is_active = is_active
if is_system_admin is not None:
user.is_system_admin = is_system_admin
if first_name is not None:
user.first_name = first_name
if last_name is not None: