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:
+3
-3
@@ -27,10 +27,10 @@ REDIS_PASSWORD=STRONG_REDIS_PASSWORD_HERE
|
|||||||
RUNTIME_DB_PASSWORD=STRONG_RUNTIME_PASSWORD_HERE
|
RUNTIME_DB_PASSWORD=STRONG_RUNTIME_PASSWORD_HERE
|
||||||
DATABASE_URL=postgresql+asyncpg://crm_runtime:STRONG_RUNTIME_PASSWORD_HERE@postgres:5432/crm_db
|
DATABASE_URL=postgresql+asyncpg://crm_runtime:STRONG_RUNTIME_PASSWORD_HERE@postgres:5432/crm_db
|
||||||
|
|
||||||
# --- CRM Application: Migration DB user (owner, can run DDL) -----------------
|
# --- CRM Application: Migration DB user (NOSUPERUSER, BYPASSRLS) ------------
|
||||||
# Migrations and DDL operations use the owner user (crm_user).
|
# Migrations use crm_migration (NOSUPERUSER, BYPASSRLS) — NOT crm_user (SUPERUSER).
|
||||||
# This is NOT used by the app at runtime — only by prestart.sh / alembic.
|
# This is NOT used by the app at runtime — only by prestart.sh / alembic.
|
||||||
MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db
|
MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_migration:STRONG_PASSWORD_HERE@postgres:5432/crm_db
|
||||||
|
|
||||||
# --- SECRET_KEY (REQUIRED, min 32 chars) -------------------------------------
|
# --- SECRET_KEY (REQUIRED, min 32 chars) -------------------------------------
|
||||||
# Session signing secret. MUST be at least 32 characters.
|
# Session signing secret. MUST be at least 32 characters.
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ if config.config_file_name is not None:
|
|||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
# ⚠️ RLS Migration History: 21 Migrationen mit 8 Disable-Zyklen. Dies ist historisch bedingt
|
||||||
|
# und zeigt trial-and-error. Aktuelle RLS-Konfiguration ist stabil (113 Tabellen).
|
||||||
|
# Bei neuen RLS-Änderungen nur noch Migration-Runner nutzen.
|
||||||
# Use migration_database_url (crm_migration role, table owner) for Alembic
|
# Use migration_database_url (crm_migration role, table owner) for Alembic
|
||||||
config.set_main_option("sqlalchemy.url", settings.migration_database_url or settings.database_url)
|
config.set_main_option("sqlalchemy.url", settings.migration_database_url or settings.database_url)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Migrate contact_folder_permissions to entity_permissions.
|
||||||
|
|
||||||
|
This migration moves all ACL entries from the dedicated
|
||||||
|
``contact_folder_permissions`` table into 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 (unchanged)
|
||||||
|
- inherit_to_subfolders is dropped (always treated as True after migration)
|
||||||
|
|
||||||
|
After data migration the ``contact_folder_permissions`` table is dropped.
|
||||||
|
|
||||||
|
Revision ID: 0114
|
||||||
|
Revises: 0113
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0114"
|
||||||
|
down_revision = "0113"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Migrate user-based permissions
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO entity_permissions (
|
||||||
|
id, tenant_id, entity_type, entity_id,
|
||||||
|
principal_type, principal_id,
|
||||||
|
permission_level, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cfp.id,
|
||||||
|
cfp.tenant_id,
|
||||||
|
'contact_folder',
|
||||||
|
cfp.folder_id,
|
||||||
|
'user',
|
||||||
|
cfp.user_id,
|
||||||
|
cfp.permission_level,
|
||||||
|
cfp.created_at,
|
||||||
|
cfp.updated_at
|
||||||
|
FROM contact_folder_permissions cfp
|
||||||
|
WHERE cfp.user_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 2. Migrate group-based permissions
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO entity_permissions (
|
||||||
|
id, tenant_id, entity_type, entity_id,
|
||||||
|
principal_type, principal_id,
|
||||||
|
permission_level, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cfp.id,
|
||||||
|
cfp.tenant_id,
|
||||||
|
'contact_folder',
|
||||||
|
cfp.folder_id,
|
||||||
|
'group',
|
||||||
|
cfp.group_id,
|
||||||
|
cfp.permission_level,
|
||||||
|
cfp.created_at,
|
||||||
|
cfp.updated_at
|
||||||
|
FROM contact_folder_permissions cfp
|
||||||
|
WHERE cfp.group_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 3. Drop the old table
|
||||||
|
op.execute("DROP TABLE IF EXISTS contact_folder_permissions CASCADE")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Recreate the old table (data is lost — this is a one-way migration)
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_folder_permissions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
folder_id UUID NOT NULL REFERENCES contact_folders(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
permission_level VARCHAR(20) NOT NULL DEFAULT 'read',
|
||||||
|
inherit_to_subfolders BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Restore user permissions
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO contact_folder_permissions (
|
||||||
|
id, tenant_id, folder_id, user_id, group_id,
|
||||||
|
permission_level, inherit_to_subfolders, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ep.id,
|
||||||
|
ep.tenant_id,
|
||||||
|
ep.entity_id,
|
||||||
|
CASE WHEN ep.principal_type = 'user' THEN ep.principal_id ELSE NULL END,
|
||||||
|
CASE WHEN ep.principal_type = 'group' THEN ep.principal_id ELSE NULL END,
|
||||||
|
ep.permission_level,
|
||||||
|
TRUE,
|
||||||
|
ep.created_at,
|
||||||
|
ep.updated_at
|
||||||
|
FROM entity_permissions ep
|
||||||
|
WHERE ep.entity_type = 'contact_folder'
|
||||||
|
AND ep.principal_type IN ('user', 'group')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Remove migrated entries from entity_permissions
|
||||||
|
op.execute("""
|
||||||
|
DELETE FROM entity_permissions
|
||||||
|
WHERE entity_type = 'contact_folder'
|
||||||
|
AND principal_type IN ('user', 'group')
|
||||||
|
""")
|
||||||
+2
-35
@@ -297,38 +297,5 @@ async def update_session_tenant(
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def check_permission(
|
# ⚠️ Legacy check_permission and filter_fields_by_permission removed from auth.py.
|
||||||
role_name: str, module: str, action: str, permissions: dict | None = None
|
# Use app.core.permissions.check_permission and app.core.permissions.filter_fields_by_permission instead.
|
||||||
) -> bool:
|
|
||||||
"""Check if a role has permission for a module+action.
|
|
||||||
|
|
||||||
⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
|
|
||||||
Built-in role strings (admin/editor/viewer) no longer grant permissions directly.
|
|
||||||
All permission checks must go through the RBAC system in app.core.permissions.
|
|
||||||
This function is kept for backward compatibility but no longer bypasses checks
|
|
||||||
based on role_name alone.
|
|
||||||
"""
|
|
||||||
# Custom role — check permissions dict
|
|
||||||
if permissions:
|
|
||||||
module_perms = permissions.get(module, {})
|
|
||||||
return bool(module_perms.get(action, False))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def filter_fields_by_permission(
|
|
||||||
data: dict[str, Any],
|
|
||||||
field_permissions: dict[str, str],
|
|
||||||
role_name: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Filter response fields based on field-level permissions.
|
|
||||||
field_permissions: {"annual_revenue": "hidden"} → removed for non-admin.
|
|
||||||
"""
|
|
||||||
if role_name == "admin":
|
|
||||||
return data
|
|
||||||
result = {}
|
|
||||||
for key, value in data.items():
|
|
||||||
perm = field_permissions.get(key)
|
|
||||||
if perm == "hidden":
|
|
||||||
continue
|
|
||||||
result[key] = value
|
|
||||||
return result
|
|
||||||
|
|||||||
+8
-19
@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
|
|||||||
CACHE_TTL = 300 # 5 minutes
|
CACHE_TTL = 300 # 5 minutes
|
||||||
CACHE_PREFIX = "resolved"
|
CACHE_PREFIX = "resolved"
|
||||||
|
|
||||||
|
# Central permission rank — single source of truth for permission level ordering.
|
||||||
|
# Used by entity_permission_service, bulk_permission_service, visibility, etc.
|
||||||
|
PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "delete": 4, "owner": 5}
|
||||||
|
|
||||||
# Severity ordering for field permissions: highest wins
|
# Severity ordering for field permissions: highest wins
|
||||||
_FIELD_PERM_SEVERITY = {"hidden": 3, "readonly": 2, "read": 1}
|
_FIELD_PERM_SEVERITY = {"hidden": 3, "readonly": 2, "read": 1}
|
||||||
|
|
||||||
@@ -284,25 +288,10 @@ async def resolve_permissions(
|
|||||||
tenant = tenant_result.scalar_one_or_none()
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
resolution_strategy = tenant.resolution_strategy if tenant else "highest_wins"
|
resolution_strategy = tenant.resolution_strategy if tenant else "highest_wins"
|
||||||
|
|
||||||
# Apply resolution strategy
|
# ⚠️ Only highest_wins strategy supported — other strategies removed as they were no-ops.
|
||||||
if resolution_strategy == "highest_wins":
|
# All strategies previously produced the same result: resolved = allowed - denied.
|
||||||
# Default: allowed - denied (deny overrides allow at permission level)
|
# The strategy field is kept for backward compatibility but only highest_wins is honored.
|
||||||
resolved = allowed - denied
|
resolved = allowed - denied
|
||||||
elif resolution_strategy == "deny_overrides_allow":
|
|
||||||
# Deny always wins: remove any allowed permission that is also denied
|
|
||||||
resolved = allowed - denied
|
|
||||||
elif resolution_strategy == "direct_overrides_group":
|
|
||||||
# Direct role permissions override group permissions
|
|
||||||
# Role permissions are loaded first, group permissions add but don't override
|
|
||||||
# Already implemented by loading order: role first, then group
|
|
||||||
resolved = allowed - denied
|
|
||||||
elif resolution_strategy == "most_restrictive_wins":
|
|
||||||
# Only permissions present in ALL sources (role AND groups) are kept
|
|
||||||
# This is intersection-based: only permissions granted by both role and groups
|
|
||||||
# For now, we keep the default behavior as intersection is complex with multiple groups
|
|
||||||
resolved = allowed - denied
|
|
||||||
else:
|
|
||||||
resolved = allowed - denied
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"permissions": resolved,
|
"permissions": resolved,
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ from app.models.user import User, UserTenant
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Permission rank for comparison
|
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||||
_PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "delete": 4, "owner": 5}
|
|
||||||
|
|
||||||
|
|
||||||
def _rank(level: str) -> int:
|
def _rank(level: str) -> int:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from app.models.audit import AuditLog, DeletionLog
|
|||||||
from app.models.auth import ApiToken, PasswordResetToken
|
from app.models.auth import ApiToken, PasswordResetToken
|
||||||
from app.models.contact import Contact, ContactPerson
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.models.contact_folder import ContactFolder
|
from app.models.contact_folder import ContactFolder
|
||||||
from app.models.contact_folder_permission import ContactFolderPermission
|
|
||||||
from app.models.contact_merge import ContactMergeHistory
|
from app.models.contact_merge import ContactMergeHistory
|
||||||
from app.models.entity_permission import EntityPermission
|
from app.models.entity_permission import EntityPermission
|
||||||
from app.models.consumer_inbox import ConsumerInbox
|
from app.models.consumer_inbox import ConsumerInbox
|
||||||
@@ -53,7 +52,6 @@ __all__ = [
|
|||||||
"Contact",
|
"Contact",
|
||||||
"ContactPerson",
|
"ContactPerson",
|
||||||
"ContactFolder",
|
"ContactFolder",
|
||||||
"ContactFolderPermission",
|
|
||||||
"ContactMergeHistory",
|
"ContactMergeHistory",
|
||||||
"EntityPermission",
|
"EntityPermission",
|
||||||
"ConsumerInbox",
|
"ConsumerInbox",
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ class Tenant(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
# ⚠️ Only highest_wins strategy supported — other strategies removed as they were no-ops.
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"resolution_strategy IN ('highest_wins', 'deny_overrides_allow', 'direct_overrides_group', 'most_restrictive_wins')",
|
"resolution_strategy IN ('highest_wins')",
|
||||||
name="ck_tenant_resolution_strategy",
|
name="ck_tenant_resolution_strategy",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,13 +63,18 @@ async def copilot_execute(
|
|||||||
"""
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
role = current_user.get("role", "viewer")
|
resolved = {
|
||||||
|
"permissions": current_user.get("permissions", []),
|
||||||
|
"denied": current_user.get("denied_permissions", []),
|
||||||
|
"field_permissions": current_user.get("field_permissions", {}),
|
||||||
|
"is_system_admin": current_user.get("is_system_admin", False),
|
||||||
|
}
|
||||||
|
|
||||||
result = await ai_copilot_service.execute_action(
|
result = await ai_copilot_service.execute_action(
|
||||||
db,
|
db,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
user_id,
|
user_id,
|
||||||
role,
|
resolved,
|
||||||
conversation_id=body.conversation_id,
|
conversation_id=body.conversation_id,
|
||||||
action=body.action.model_dump(),
|
action=body.action.model_dump(),
|
||||||
)
|
)
|
||||||
|
|||||||
+19
-1
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.auth import get_redis
|
from app.core.auth import get_redis, invalidate_all_user_sessions
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.notifications import create_notification
|
from app.core.notifications import create_notification
|
||||||
from app.core.permissions import invalidate_permission_cache
|
from app.core.permissions import invalidate_permission_cache
|
||||||
@@ -205,6 +205,13 @@ async def update_user(
|
|||||||
detail={"detail": "Only system admin can assign admin role", "code": "role_escalation_forbidden"},
|
detail={"detail": "Only system admin can assign admin role", "code": "role_escalation_forbidden"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Only system admin can change is_system_admin flag
|
||||||
|
if body.is_system_admin is not None and not current_user.get("is_system_admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"detail": "Only system admin can change system admin flag", "code": "admin_flag_forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
# Determine if role_id was explicitly sent (Pydantic v2)
|
# Determine if role_id was explicitly sent (Pydantic v2)
|
||||||
role_id_sent = "role_id" in body.model_fields_set
|
role_id_sent = "role_id" in body.model_fields_set
|
||||||
|
|
||||||
@@ -217,6 +224,8 @@ async def update_user(
|
|||||||
changes["role_id"] = body.role_id
|
changes["role_id"] = body.role_id
|
||||||
if body.is_active is not None:
|
if body.is_active is not None:
|
||||||
changes["is_active"] = body.is_active
|
changes["is_active"] = body.is_active
|
||||||
|
if body.is_system_admin is not None:
|
||||||
|
changes["is_system_admin"] = body.is_system_admin
|
||||||
|
|
||||||
# Pass _UNSET sentinel when role_id was not in the request body
|
# Pass _UNSET sentinel when role_id was not in the request body
|
||||||
# so the service leaves the existing value untouched.
|
# so the service leaves the existing value untouched.
|
||||||
@@ -253,6 +262,7 @@ async def update_user(
|
|||||||
body.email,
|
body.email,
|
||||||
body.current_password,
|
body.current_password,
|
||||||
body.new_password,
|
body.new_password,
|
||||||
|
body.is_system_admin,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_password"}) from None
|
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_password"}) from None
|
||||||
@@ -276,6 +286,14 @@ async def update_user(
|
|||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
await invalidate_permission_cache(redis, uid, tenant_id)
|
await invalidate_permission_cache(redis, uid, tenant_id)
|
||||||
|
|
||||||
|
# If is_system_admin was changed, invalidate ALL sessions for this user
|
||||||
|
# so the stale admin flag doesn't persist in Redis until TTL (8h)
|
||||||
|
if body.is_system_admin is not None:
|
||||||
|
try:
|
||||||
|
await invalidate_all_user_sessions(redis, uid)
|
||||||
|
except Exception:
|
||||||
|
pass # Best-effort — don't fail the update if Redis is down
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class UserUpdate(BaseModel):
|
|||||||
role: str | None = None
|
role: str | None = None
|
||||||
role_id: str | None = Field(default=None, description="UUID of a custom Role; send null to clear")
|
role_id: str | None = Field(default=None, description="UUID of a custom Role; send null to clear")
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
is_system_admin: bool | None = Field(None, description="System admin flag — requires system admin to set")
|
||||||
current_password: str | None = Field(None, description="Required when changing password")
|
current_password: str | None = Field(None, description="Required when changing password")
|
||||||
new_password: str | None = Field(None, min_length=8, description="New password")
|
new_password: str | None = Field(None, min_length=8, description="New password")
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ class UserResponse(BaseModel):
|
|||||||
role: str
|
role: str
|
||||||
role_id: str | None = None
|
role_id: str | None = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_system_admin: bool = False
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.ai.llm_client import get_llm_client
|
from app.ai.llm_client import get_llm_client
|
||||||
from app.core.audit import log_audit
|
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 apply_visibility_filter
|
||||||
from app.core.visibility import check_single_entity_access
|
from app.core.visibility import check_single_entity_access
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
@@ -159,7 +159,7 @@ async def execute_action(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
role: str,
|
resolved: dict[str, Any],
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
action: dict[str, Any],
|
action: dict[str, Any],
|
||||||
is_system_admin: bool = False,
|
is_system_admin: bool = False,
|
||||||
@@ -191,8 +191,9 @@ async def execute_action(
|
|||||||
|
|
||||||
# Determine module and action_type from path for RBAC
|
# Determine module and action_type from path for RBAC
|
||||||
module, action_type = _derive_rbac_from_path(method, path)
|
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 {
|
return {
|
||||||
"error": "Insufficient permissions for this action",
|
"error": "Insufficient permissions for this action",
|
||||||
"status_code": 403,
|
"status_code": 403,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.models.user import User, UserTenant
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _rank(level: str) -> int:
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import or_, select, func
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.contact_folder import ContactFolder
|
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.group import Group, UserGroup
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
# Permission hierarchy: higher = more access
|
_ENTITY_TYPE = "contact_folder"
|
||||||
_PERM_RANK = {"none": 0, "read": 1, "write": 2, "admin": 3, "owner": 4}
|
|
||||||
|
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||||
|
|
||||||
|
|
||||||
def _rank(level: str) -> int:
|
def _rank(level: str) -> int:
|
||||||
@@ -22,39 +37,32 @@ def _rank(level: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_permission(
|
def _serialize_permission(
|
||||||
p: ContactFolderPermission,
|
p: EntityPermission,
|
||||||
user_name: str | None = None,
|
user_name: str | None = None,
|
||||||
group_name: str | None = None,
|
group_name: str | None = None,
|
||||||
) -> dict:
|
) -> 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 {
|
return {
|
||||||
"id": str(p.id),
|
"id": str(p.id),
|
||||||
"folder_id": str(p.folder_id),
|
"folder_id": str(p.entity_id),
|
||||||
"user_id": str(p.user_id) if p.user_id else None,
|
"user_id": user_id,
|
||||||
"group_id": str(p.group_id) if p.group_id else None,
|
"group_id": group_id,
|
||||||
"user_name": user_name,
|
"user_name": user_name,
|
||||||
"group_name": group_name,
|
"group_name": group_name,
|
||||||
"permission_level": p.permission_level,
|
"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,
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def list_permissions(
|
async def _load_names(
|
||||||
db: AsyncSession, tenant_id: uuid.UUID, folder_id: str
|
db: AsyncSession, perms: list[EntityPermission]
|
||||||
) -> list[dict]:
|
) -> tuple[dict[uuid.UUID, str], dict[uuid.UUID, str]]:
|
||||||
"""List all permission entries for a folder."""
|
"""Batch-load user and group names for a list of permissions."""
|
||||||
folder_uuid = uuid.UUID(folder_id)
|
user_ids = [p.principal_id for p in perms if p.principal_type == "user"]
|
||||||
result = await db.execute(
|
group_ids = [p.principal_id for p in perms if p.principal_type == "group"]
|
||||||
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]
|
|
||||||
|
|
||||||
user_names: dict[uuid.UUID, str] = {}
|
user_names: dict[uuid.UUID, str] = {}
|
||||||
if user_ids:
|
if user_ids:
|
||||||
@@ -70,11 +78,30 @@ async def list_permissions(
|
|||||||
)
|
)
|
||||||
group_names = {row[0]: row[1] for row in groups_q}
|
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 [
|
return [
|
||||||
_serialize_permission(
|
_serialize_permission(
|
||||||
p,
|
p,
|
||||||
user_names.get(p.user_id) if p.user_id else None,
|
user_names.get(p.principal_id) if p.principal_type == "user" else None,
|
||||||
group_names.get(p.group_id) if p.group_id else None,
|
group_names.get(p.principal_id) if p.principal_type == "group" else None,
|
||||||
)
|
)
|
||||||
for p in perms
|
for p in perms
|
||||||
]
|
]
|
||||||
@@ -89,7 +116,12 @@ async def create_permission(
|
|||||||
permission_level: str,
|
permission_level: str,
|
||||||
inherit_to_subfolders: bool = True,
|
inherit_to_subfolders: bool = True,
|
||||||
) -> dict:
|
) -> 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)
|
folder_uuid = uuid.UUID(folder_id)
|
||||||
|
|
||||||
# Verify folder exists and belongs to tenant
|
# Verify folder exists and belongs to tenant
|
||||||
@@ -109,58 +141,51 @@ async def create_permission(
|
|||||||
if user_uuid and group_uuid:
|
if user_uuid and group_uuid:
|
||||||
raise ValueError("Only one of user_id or group_id can be provided")
|
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)
|
# Check for existing entry (upsert)
|
||||||
existing_q = await db.execute(
|
existing_q = await db.execute(
|
||||||
select(ContactFolderPermission)
|
select(EntityPermission)
|
||||||
.where(ContactFolderPermission.folder_id == folder_uuid)
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.entity_id == folder_uuid)
|
||||||
.where(
|
.where(EntityPermission.principal_type == principal_type)
|
||||||
ContactFolderPermission.user_id == user_uuid
|
.where(EntityPermission.principal_id == principal_uuid)
|
||||||
if user_uuid
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
else ContactFolderPermission.user_id.is_(None)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
ContactFolderPermission.group_id == group_uuid
|
|
||||||
if group_uuid
|
|
||||||
else ContactFolderPermission.group_id.is_(None)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
existing = existing_q.scalar_one_or_none()
|
existing = existing_q.scalar_one_or_none()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
existing.permission_level = permission_level
|
existing.permission_level = permission_level
|
||||||
existing.inherit_to_subfolders = inherit_to_subfolders
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(existing)
|
await db.refresh(existing)
|
||||||
# Load names for response
|
|
||||||
user_name = group_name = None
|
user_name = group_name = None
|
||||||
if existing.user_id:
|
if existing.principal_type == "user":
|
||||||
u = await db.execute(select(User.name).where(User.id == existing.user_id))
|
u = await db.execute(select(User.name).where(User.id == existing.principal_id))
|
||||||
user_name = u.scalar_one_or_none()
|
user_name = u.scalar_one_or_none()
|
||||||
if existing.group_id:
|
else:
|
||||||
g = await db.execute(select(Group.name).where(Group.id == existing.group_id))
|
g = await db.execute(select(Group.name).where(Group.id == existing.principal_id))
|
||||||
group_name = g.scalar_one_or_none()
|
group_name = g.scalar_one_or_none()
|
||||||
return _serialize_permission(existing, user_name, group_name)
|
return _serialize_permission(existing, user_name, group_name)
|
||||||
|
|
||||||
perm = ContactFolderPermission(
|
perm = EntityPermission(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
folder_id=folder_uuid,
|
entity_type=_ENTITY_TYPE,
|
||||||
user_id=user_uuid,
|
entity_id=folder_uuid,
|
||||||
group_id=group_uuid,
|
principal_type=principal_type,
|
||||||
|
principal_id=principal_uuid,
|
||||||
permission_level=permission_level,
|
permission_level=permission_level,
|
||||||
inherit_to_subfolders=inherit_to_subfolders,
|
|
||||||
)
|
)
|
||||||
db.add(perm)
|
db.add(perm)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(perm)
|
await db.refresh(perm)
|
||||||
|
|
||||||
# Load names for response
|
|
||||||
user_name = group_name = None
|
user_name = group_name = None
|
||||||
if perm.user_id:
|
if perm.principal_type == "user":
|
||||||
u = await db.execute(select(User.name).where(User.id == perm.user_id))
|
u = await db.execute(select(User.name).where(User.id == perm.principal_id))
|
||||||
user_name = u.scalar_one_or_none()
|
user_name = u.scalar_one_or_none()
|
||||||
if perm.group_id:
|
else:
|
||||||
g = await db.execute(select(Group.name).where(Group.id == perm.group_id))
|
g = await db.execute(select(Group.name).where(Group.id == perm.principal_id))
|
||||||
group_name = g.scalar_one_or_none()
|
group_name = g.scalar_one_or_none()
|
||||||
return _serialize_permission(perm, user_name, group_name)
|
return _serialize_permission(perm, user_name, group_name)
|
||||||
|
|
||||||
@@ -172,30 +197,34 @@ async def update_permission(
|
|||||||
permission_level: str,
|
permission_level: str,
|
||||||
inherit_to_subfolders: bool | None = None,
|
inherit_to_subfolders: bool | None = None,
|
||||||
) -> dict:
|
) -> 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)
|
perm_uuid = uuid.UUID(permission_id)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ContactFolderPermission)
|
select(EntityPermission)
|
||||||
.where(ContactFolderPermission.id == perm_uuid)
|
.where(EntityPermission.id == perm_uuid)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
)
|
)
|
||||||
perm = result.scalar_one_or_none()
|
perm = result.scalar_one_or_none()
|
||||||
if not perm:
|
if not perm:
|
||||||
raise ValueError("Permission not found")
|
raise ValueError("Permission not found")
|
||||||
|
|
||||||
perm.permission_level = permission_level
|
perm.permission_level = permission_level
|
||||||
if inherit_to_subfolders is not None:
|
# inherit_to_subfolders has no equivalent in EntityPermission
|
||||||
perm.inherit_to_subfolders = inherit_to_subfolders
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(perm)
|
await db.refresh(perm)
|
||||||
|
|
||||||
user_name = group_name = None
|
user_name = group_name = None
|
||||||
if perm.user_id:
|
if perm.principal_type == "user":
|
||||||
u = await db.execute(select(User.name).where(User.id == perm.user_id))
|
u = await db.execute(select(User.name).where(User.id == perm.principal_id))
|
||||||
user_name = u.scalar_one_or_none()
|
user_name = u.scalar_one_or_none()
|
||||||
if perm.group_id:
|
else:
|
||||||
g = await db.execute(select(Group.name).where(Group.id == perm.group_id))
|
g = await db.execute(select(Group.name).where(Group.id == perm.principal_id))
|
||||||
group_name = g.scalar_one_or_none()
|
group_name = g.scalar_one_or_none()
|
||||||
return _serialize_permission(perm, user_name, group_name)
|
return _serialize_permission(perm, user_name, group_name)
|
||||||
|
|
||||||
@@ -206,9 +235,10 @@ async def delete_permission(
|
|||||||
"""Delete a permission entry."""
|
"""Delete a permission entry."""
|
||||||
perm_uuid = uuid.UUID(permission_id)
|
perm_uuid = uuid.UUID(permission_id)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ContactFolderPermission)
|
select(EntityPermission)
|
||||||
.where(ContactFolderPermission.id == perm_uuid)
|
.where(EntityPermission.id == perm_uuid)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
)
|
)
|
||||||
perm = result.scalar_one_or_none()
|
perm = result.scalar_one_or_none()
|
||||||
if not perm:
|
if not perm:
|
||||||
@@ -229,7 +259,7 @@ async def get_effective_access(
|
|||||||
Resolution order (highest wins):
|
Resolution order (highest wins):
|
||||||
1. Folder owner → "owner"
|
1. Folder owner → "owner"
|
||||||
2. Direct permission on this folder
|
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)
|
4. Group membership permissions (direct + inherited)
|
||||||
5. No access → "none"
|
5. No access → "none"
|
||||||
"""
|
"""
|
||||||
@@ -246,9 +276,10 @@ async def get_effective_access(
|
|||||||
if folder.user_id == user_id:
|
if folder.user_id == user_id:
|
||||||
# Check if shared with anyone
|
# Check if shared with anyone
|
||||||
shared_q = await db.execute(
|
shared_q = await db.execute(
|
||||||
select(func.count(ContactFolderPermission.id))
|
select(func.count(EntityPermission.id))
|
||||||
.where(ContactFolderPermission.folder_id == folder_id)
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.entity_id == folder_id)
|
||||||
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
)
|
)
|
||||||
is_shared = (shared_q.scalar() or 0) > 0
|
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}
|
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):
|
for i, ancestor_id in enumerate(ancestor_chain):
|
||||||
# Direct user permission
|
# Direct user permission
|
||||||
user_perm_q = await db.execute(
|
user_perm_q = await db.execute(
|
||||||
select(ContactFolderPermission)
|
select(EntityPermission)
|
||||||
.where(ContactFolderPermission.folder_id == ancestor_id)
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.entity_id == ancestor_id)
|
||||||
.where(ContactFolderPermission.user_id == user_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():
|
for perm in user_perm_q.scalars():
|
||||||
# If this is an ancestor (not the folder itself), only apply if inherit_to_subfolders
|
# All permissions inherit after migration (inherit_to_subfolders always True)
|
||||||
if i > 0 and not perm.inherit_to_subfolders:
|
|
||||||
continue
|
|
||||||
if _rank(perm.permission_level) > _rank(best_level):
|
if _rank(perm.permission_level) > _rank(best_level):
|
||||||
best_level = perm.permission_level
|
best_level = perm.permission_level
|
||||||
inherited_from = str(ancestor_id) if i > 0 else None
|
inherited_from = str(ancestor_id) if i > 0 else None
|
||||||
@@ -298,23 +329,24 @@ async def get_effective_access(
|
|||||||
# Group permissions
|
# Group permissions
|
||||||
if group_ids:
|
if group_ids:
|
||||||
group_perm_q = await db.execute(
|
group_perm_q = await db.execute(
|
||||||
select(ContactFolderPermission)
|
select(EntityPermission)
|
||||||
.where(ContactFolderPermission.folder_id == ancestor_id)
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.entity_id == ancestor_id)
|
||||||
.where(ContactFolderPermission.group_id.in_(group_ids))
|
.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():
|
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):
|
if _rank(perm.permission_level) > _rank(best_level):
|
||||||
best_level = perm.permission_level
|
best_level = perm.permission_level
|
||||||
inherited_from = str(ancestor_id) if i > 0 else None
|
inherited_from = str(ancestor_id) if i > 0 else None
|
||||||
|
|
||||||
# Check if folder is shared at all
|
# Check if folder is shared at all
|
||||||
shared_q = await db.execute(
|
shared_q = await db.execute(
|
||||||
select(func.count(ContactFolderPermission.id))
|
select(func.count(EntityPermission.id))
|
||||||
.where(ContactFolderPermission.folder_id == folder_id)
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.entity_id == folder_id)
|
||||||
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
)
|
)
|
||||||
is_shared = (shared_q.scalar() or 0) > 0
|
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]
|
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(
|
all_perms_q = await db.execute(
|
||||||
select(ContactFolderPermission)
|
select(EntityPermission)
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.entity_type == _ENTITY_TYPE)
|
||||||
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
)
|
)
|
||||||
all_perms = all_perms_q.scalars().all()
|
all_perms = all_perms_q.scalars().all()
|
||||||
|
|
||||||
# Build permission lookup: folder_id → list of (principal_type, principal_id, level, inherit)
|
# Build permission lookup: entity_id → list of (principal_type, principal_id, level)
|
||||||
perm_lookup: dict[uuid.UUID, list[tuple[str, uuid.UUID | None, str, bool]]] = {}
|
perm_lookup: dict[uuid.UUID, list[tuple[str, uuid.UUID, str]]] = {}
|
||||||
for p in all_perms:
|
for p in all_perms:
|
||||||
if p.folder_id not in perm_lookup:
|
if p.entity_id not in perm_lookup:
|
||||||
perm_lookup[p.folder_id] = []
|
perm_lookup[p.entity_id] = []
|
||||||
if p.user_id:
|
perm_lookup[p.entity_id].append((p.principal_type, p.principal_id, p.permission_level))
|
||||||
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))
|
|
||||||
|
|
||||||
# Build parent map for ancestor traversal
|
# Build parent map for ancestor traversal
|
||||||
parent_map: dict[uuid.UUID, uuid.UUID | None] = {}
|
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):
|
for i, ancestor_id in enumerate(chain):
|
||||||
perms = perm_lookup.get(ancestor_id, [])
|
perms = perm_lookup.get(ancestor_id, [])
|
||||||
for ptype, pid, level, inherit in perms:
|
for ptype, pid, level in perms:
|
||||||
if i > 0 and not inherit:
|
# All permissions inherit after migration
|
||||||
continue
|
|
||||||
if ptype == "user" and pid == user_id:
|
if ptype == "user" and pid == user_id:
|
||||||
if _rank(level) > _rank(best_level):
|
if _rank(level) > _rank(best_level):
|
||||||
best_level = level
|
best_level = level
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ async def list_folders(
|
|||||||
counts[fid] = cnt
|
counts[fid] = cnt
|
||||||
|
|
||||||
# Check which folders have permissions (are shared)
|
# 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(
|
shared_q = await db.execute(
|
||||||
select(ContactFolderPermission.folder_id)
|
select(EntityPermission.entity_id)
|
||||||
.where(ContactFolderPermission.folder_id.in_(folder_ids))
|
.where(EntityPermission.entity_type == "contact_folder")
|
||||||
.where(ContactFolderPermission.tenant_id == tenant_id)
|
.where(EntityPermission.entity_id.in_(folder_ids))
|
||||||
.group_by(ContactFolderPermission.folder_id)
|
.where(EntityPermission.tenant_id == tenant_id)
|
||||||
|
.group_by(EntityPermission.entity_id)
|
||||||
)
|
)
|
||||||
shared_ids = {row[0] for row in shared_q}
|
shared_ids = {row[0] for row in shared_q}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ This service handles:
|
|||||||
- Redis caching with bitmap optimization
|
- Redis caching with bitmap optimization
|
||||||
- Permission expiration checks
|
- Permission expiration checks
|
||||||
- Audit logging for permission changes
|
- 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
|
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.custom_field_definition import CustomFieldDefinition
|
||||||
from app.models.contact_folder import ContactFolder
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Entity Model Registry ────────────────────────────────────────────────────
|
# ── Entity Model Registry ────────────────────────────────────────────────────
|
||||||
@@ -116,11 +124,7 @@ def _get_entity_model(entity_type: str) -> type:
|
|||||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||||
return model
|
return model
|
||||||
|
|
||||||
# Permission hierarchy: higher = more access
|
from app.core.permissions import PERM_RANK as _PERM_RANK
|
||||||
_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:
|
def _rank(level: str) -> int:
|
||||||
@@ -408,422 +412,6 @@ async def delete_permission(
|
|||||||
await _invalidate_user_cache(redis, tenant_id, uid, old_entity_type)
|
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(
|
async def list_all_permissions(
|
||||||
db: AsyncSession, tenant_id: uuid.UUID
|
db: AsyncSession, tenant_id: uuid.UUID
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -930,3 +518,16 @@ async def cleanup_expired_permissions(db: AsyncSession) -> int:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Cleaned up %d expired entity permissions", count)
|
logger.info("Cleaned up %d expired entity permissions", count)
|
||||||
return 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -151,6 +151,7 @@ class UserService:
|
|||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
current_password: str | None = None,
|
current_password: str | None = None,
|
||||||
new_password: str | None = None,
|
new_password: str | None = None,
|
||||||
|
is_system_admin: bool | None = None,
|
||||||
) -> tuple[User, UserTenant] | None:
|
) -> tuple[User, UserTenant] | None:
|
||||||
"""Update a user and their tenant membership.
|
"""Update a user and their tenant membership.
|
||||||
|
|
||||||
@@ -181,6 +182,8 @@ class UserService:
|
|||||||
user_tenant.role_id = role_id
|
user_tenant.role_id = role_id
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
user.is_active = is_active
|
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:
|
if first_name is not None:
|
||||||
user.first_name = first_name
|
user.first_name = first_name
|
||||||
if last_name is not None:
|
if last_name is not None:
|
||||||
|
|||||||
+2
-2
@@ -71,7 +71,7 @@ services:
|
|||||||
SERVICE_FQDN_CRM_APP_8000: ${APP_DOMAIN}
|
SERVICE_FQDN_CRM_APP_8000: ${APP_DOMAIN}
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://crm_api:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://crm_api:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||||
AUTH_DATABASE_URL: ${AUTH_DATABASE_URL:-postgresql+asyncpg://crm_auth:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
AUTH_DATABASE_URL: ${AUTH_DATABASE_URL:-postgresql+asyncpg://crm_auth:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||||
MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_user:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_migration:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://default:${REDIS_PASSWORD}@redis:6379/0}
|
REDIS_URL: ${REDIS_URL:-redis://default:${REDIS_PASSWORD}@redis:6379/0}
|
||||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required}
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||||
@@ -114,7 +114,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${WORKER_DATABASE_URL:-postgresql+asyncpg://crm_worker:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
DATABASE_URL: ${WORKER_DATABASE_URL:-postgresql+asyncpg://crm_worker:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||||
WORKER_DATABASE_URL: ${WORKER_DATABASE_URL:-postgresql+asyncpg://crm_worker:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
WORKER_DATABASE_URL: ${WORKER_DATABASE_URL:-postgresql+asyncpg://crm_worker:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||||
MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_user:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_migration:${DB_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://default:${REDIS_PASSWORD}@redis:6379/0}
|
REDIS_URL: ${REDIS_URL:-redis://default:${REDIS_PASSWORD}@redis:6379/0}
|
||||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required}
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required}
|
||||||
FRONTEND_URL: ${FRONTEND_URL}
|
FRONTEND_URL: ${FRONTEND_URL}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# Notes:
|
# Notes:
|
||||||
# - `set -e` ensures the container crashes loudly if migrations fail.
|
# - `set -e` ensures the container crashes loudly if migrations fail.
|
||||||
# - The ARQ worker runs in a separate container (see worker.sh / docker-compose).
|
# - The ARQ worker runs in a separate container (see worker.sh / docker-compose).
|
||||||
# - Migrations use MIGRATION_DATABASE_URL (crm_user, owner, can bypass RLS).
|
# - Migrations use MIGRATION_DATABASE_URL (crm_migration, NOSUPERUSER, BYPASSRLS).
|
||||||
# - The app uses DATABASE_URL (crm_runtime, NOSUPERUSER, NOBYPASSRLS).
|
# - The app uses DATABASE_URL (crm_runtime, NOSUPERUSER, NOBYPASSRLS).
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user