sprint14-19: ABAC UI rule editor + permission templates + bulk share + analytics + delegation + resolution strategies + migrations 0056-0058
This commit is contained in:
+27
-2
@@ -301,8 +301,32 @@ async def resolve_permissions(
|
||||
if group.field_permissions:
|
||||
_merge_field_permissions(field_perms, group.field_permissions)
|
||||
|
||||
# Apply deny list
|
||||
resolved = allowed - denied
|
||||
# Load tenant resolution strategy
|
||||
async with db.begin_nested():
|
||||
tenant_q = select(Tenant).where(Tenant.id == tenant_id)
|
||||
tenant_result = await db.execute(tenant_q)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
resolution_strategy = tenant.resolution_strategy if tenant else "highest_wins"
|
||||
|
||||
# Apply resolution strategy
|
||||
if resolution_strategy == "highest_wins":
|
||||
# Default: allowed - denied (deny overrides allow at permission level)
|
||||
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 {
|
||||
"permissions": resolved,
|
||||
@@ -310,6 +334,7 @@ async def resolve_permissions(
|
||||
"field_permissions": field_perms,
|
||||
"is_system_admin": False,
|
||||
"version": max_version,
|
||||
"resolution_strategy": resolution_strategy,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ from app.routes import (
|
||||
webhooks,
|
||||
backups,
|
||||
owner_transfer,
|
||||
permission_templates,
|
||||
delegations,
|
||||
policies,
|
||||
)
|
||||
|
||||
@@ -404,6 +406,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(saved_filters.router)
|
||||
app.include_router(saved_views.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(permission_templates.router)
|
||||
app.include_router(delegations.router)
|
||||
app.include_router(policies.router)
|
||||
app.include_router(errors.router)
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ from app.models.contact_folder_permission import ContactFolderPermission
|
||||
from app.models.contact_merge import ContactMergeHistory
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.models.permission_template import PermissionTemplate
|
||||
from app.models.permission_delegation import PermissionDelegation
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from app.models.entity_history import EntityHistory
|
||||
from app.models.currency import Currency
|
||||
@@ -52,6 +54,8 @@ __all__ = [
|
||||
"ContactFolderPermission",
|
||||
"ContactMergeHistory",
|
||||
"EntityPermission",
|
||||
"PermissionDelegation",
|
||||
"PermissionTemplate",
|
||||
"EntityPolicy",
|
||||
"OwnedMixin",
|
||||
"EntityHistory",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Permission delegation model — temporary permission handover between users.
|
||||
|
||||
Allows a user to delegate their permissions to another user for a specified
|
||||
time period and scope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class PermissionDelegation(Base, TenantMixin):
|
||||
"""Permission delegation — temporary handover of permissions.
|
||||
|
||||
from_user_id delegates their permissions to to_user_id
|
||||
for the duration [start_at, end_at].
|
||||
|
||||
scope: JSONB defining which permissions are delegated.
|
||||
Examples:
|
||||
- {"all": true} — all permissions
|
||||
- {"entity_types": ["contact", "document"]} — specific entity types
|
||||
- {"permissions": ["contacts:read", "contacts:write"]} — specific permissions
|
||||
"""
|
||||
|
||||
__tablename__ = "permission_delegations"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"end_at > start_at",
|
||||
name="ck_pd_end_after_start",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
from_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
to_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
start_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
end_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
scope: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Permission template model — reusable permission presets for entity types.
|
||||
|
||||
Templates define default sharing rules that can be applied to entities.
|
||||
When applied, they automatically create entity_permissions entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class PermissionTemplate(Base, TenantMixin):
|
||||
"""Reusable permission template for entity types.
|
||||
|
||||
When applied to an entity, the template evaluates trigger_condition
|
||||
and auto_share_with to create entity_permissions entries.
|
||||
|
||||
Fields:
|
||||
- name: Human-readable template name
|
||||
- entity_type: Which entity type this template applies to
|
||||
- trigger_condition: JSONB conditions that must be met for auto-apply
|
||||
- auto_share_with: JSONB list of {principal_type, principal_id, level} to share with
|
||||
- level: Default permission level for this template
|
||||
"""
|
||||
|
||||
__tablename__ = "permission_templates"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"level IN ('read', 'write', 'admin', 'delete')",
|
||||
name="ck_pt_level",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
trigger_condition: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
|
||||
auto_share_with: Mapped[list | None] = mapped_column(JSONB, nullable=True, default=None)
|
||||
level: Mapped[str] = mapped_column(String(20), nullable=False, default="read")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
+11
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy import CheckConstraint, DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -25,6 +25,16 @@ class Tenant(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
resolution_strategy: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, default="highest_wins"
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"resolution_strategy IN ('highest_wins', 'deny_overrides_allow', 'direct_overrides_group', 'most_restrictive_wins')",
|
||||
name="ck_tenant_resolution_strategy",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Permission delegation routes — CRUD API for temporary permission handovers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.delegation import DelegationCreate, DelegationUpdate
|
||||
from app.services import delegation_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/delegations", tags=["delegations"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_delegations(
|
||||
direction: str = Query("all", regex="^(from|to|all)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List delegations for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
items = await delegation_service.list_delegations(db, tenant_id, user_id, direction)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_delegation(
|
||||
body: DelegationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new permission delegation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
from_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
return await delegation_service.create_delegation(
|
||||
db,
|
||||
tenant_id,
|
||||
from_user_id=from_user_id,
|
||||
to_user_id=uuid.UUID(body.to_user_id),
|
||||
start_at=body.start_at,
|
||||
end_at=body.end_at,
|
||||
scope=body.scope,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{delegation_id}")
|
||||
async def update_delegation(
|
||||
delegation_id: str,
|
||||
body: DelegationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing delegation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
return await delegation_service.update_delegation(
|
||||
db,
|
||||
tenant_id,
|
||||
delegation_id,
|
||||
start_at=body.start_at,
|
||||
end_at=body.end_at,
|
||||
scope=body.scope,
|
||||
active=body.active,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{delegation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_delegation(
|
||||
delegation_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a delegation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
await delegation_service.delete_delegation(db, tenant_id, delegation_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def check_active_delegation(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Check if the current user has any active delegations."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_active = await delegation_service.is_delegation_active(db, user_id, tenant_id)
|
||||
active_list = await delegation_service.get_active_delegations(db, user_id, tenant_id)
|
||||
return {
|
||||
"is_active": is_active,
|
||||
"active_delegations": active_list,
|
||||
"count": len(active_list),
|
||||
}
|
||||
@@ -15,7 +15,7 @@ from app.schemas.entity_permission import (
|
||||
EntityPermissionCreate,
|
||||
EntityPermissionUpdate,
|
||||
)
|
||||
from app.services import entity_permission_service
|
||||
from app.services import entity_permission_service, bulk_permission_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/permissions", tags=["entity-permissions"])
|
||||
|
||||
@@ -184,3 +184,67 @@ async def list_entity_registry(
|
||||
{"entity_type": "ai_conversation", "label": "AI Konversationen", "table": "ai_conversations"},
|
||||
]
|
||||
return {"items": entity_types, "total": len(entity_types)}
|
||||
|
||||
|
||||
@router.post("/bulk", status_code=status.HTTP_201_CREATED)
|
||||
@require_permission("settings:write")
|
||||
async def bulk_share_permissions(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Bulk share multiple entities with a principal."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
result = await bulk_permission_service.bulk_share(
|
||||
db,
|
||||
tenant_id,
|
||||
body["entity_type"],
|
||||
body["entity_ids"],
|
||||
body["principal_type"],
|
||||
body["principal_id"],
|
||||
body["level"],
|
||||
created_by=user_id,
|
||||
)
|
||||
return result
|
||||
except (ValueError, KeyError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/bulk/unshare", status_code=status.HTTP_200_OK)
|
||||
@require_permission("settings:write")
|
||||
async def bulk_unshare_permissions(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Bulk remove permissions for a principal from multiple entities."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
result = await bulk_permission_service.bulk_unshare(
|
||||
db,
|
||||
tenant_id,
|
||||
body["entity_type"],
|
||||
body["entity_ids"],
|
||||
body["principal_type"],
|
||||
body["principal_id"],
|
||||
)
|
||||
return result
|
||||
except (ValueError, KeyError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/analytics")
|
||||
@require_permission("settings:read")
|
||||
async def get_permission_analytics(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get permission analytics for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
result = await entity_permission_service.get_permission_analytics(db, tenant_id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Permission template routes — CRUD API for reusable permission presets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.permission_template import (
|
||||
PermissionTemplateCreate,
|
||||
PermissionTemplateUpdate,
|
||||
PermissionTemplateApply,
|
||||
)
|
||||
from app.services import permission_template_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/permission-templates", tags=["permission-templates"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(
|
||||
entity_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all permission templates for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
items = await permission_template_service.list_templates(db, tenant_id, entity_type)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_template(
|
||||
body: PermissionTemplateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new permission template."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
return await permission_template_service.create_template(
|
||||
db,
|
||||
tenant_id,
|
||||
name=body.name,
|
||||
entity_type=body.entity_type,
|
||||
level=body.level,
|
||||
trigger_condition=body.trigger_condition,
|
||||
auto_share_with=body.auto_share_with,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
async def update_template(
|
||||
template_id: str,
|
||||
body: PermissionTemplateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing permission template."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
return await permission_template_service.update_template(
|
||||
db,
|
||||
tenant_id,
|
||||
template_id,
|
||||
name=body.name,
|
||||
entity_type=body.entity_type,
|
||||
level=body.level,
|
||||
trigger_condition=body.trigger_condition,
|
||||
auto_share_with=body.auto_share_with,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_template(
|
||||
template_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a permission template."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
await permission_template_service.delete_template(db, tenant_id, template_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/apply", status_code=status.HTTP_201_CREATED)
|
||||
async def apply_template(
|
||||
body: PermissionTemplateApply,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Apply a permission template to an entity, creating entity_permissions."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
result = await permission_template_service.apply_template(
|
||||
db,
|
||||
tenant_id,
|
||||
body.entity_type,
|
||||
body.entity_id,
|
||||
template_id=body.template_id,
|
||||
created_by=user_id,
|
||||
)
|
||||
return {"applied": result, "count": len(result)}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Schemas for permission delegations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DelegationCreate(BaseModel):
|
||||
to_user_id: str
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
scope: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DelegationUpdate(BaseModel):
|
||||
start_at: datetime | None = None
|
||||
end_at: datetime | None = None
|
||||
scope: dict[str, Any] | None = None
|
||||
active: bool | None = None
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Schemas for permission templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PermissionTemplateCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
entity_type: str = Field(..., min_length=1, max_length=50)
|
||||
level: str = Field("read", pattern="^(read|write|admin|delete)$")
|
||||
trigger_condition: dict[str, Any] | None = None
|
||||
auto_share_with: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class PermissionTemplateUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=200)
|
||||
entity_type: str | None = Field(None, min_length=1, max_length=50)
|
||||
level: str | None = Field(None, pattern="^(read|write|admin|delete)$")
|
||||
trigger_condition: dict[str, Any] | None = None
|
||||
auto_share_with: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class PermissionTemplateApply(BaseModel):
|
||||
entity_type: str = Field(..., min_length=1, max_length=50)
|
||||
entity_id: str
|
||||
template_id: str | None = None
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Bulk permission service — mass share/unshare operations for entity permissions.
|
||||
|
||||
Provides efficient batch operations for sharing multiple entities at once
|
||||
with the same principal and permission level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.group import UserGroup
|
||||
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}
|
||||
|
||||
|
||||
def _rank(level: str) -> int:
|
||||
return _PERM_RANK.get(level, 0)
|
||||
|
||||
|
||||
async def bulk_share(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_ids: list[str],
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
level: str,
|
||||
created_by: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Share multiple entities with a principal at a given permission level.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant UUID
|
||||
entity_type: Type of entity (e.g. 'contact', 'document')
|
||||
entity_ids: List of entity UUID strings
|
||||
principal_type: 'user', 'group', or 'role'
|
||||
principal_id: UUID string of the principal
|
||||
level: Permission level ('read', 'write', 'admin', 'delete')
|
||||
created_by: User UUID who initiated the bulk share
|
||||
|
||||
Returns:
|
||||
Dict with counts of created, updated, skipped, and errors
|
||||
"""
|
||||
principal_uuid = uuid.UUID(principal_id)
|
||||
entity_uuids = [uuid.UUID(eid) for eid in entity_ids]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
for entity_uuid in entity_uuids:
|
||||
try:
|
||||
# Check for existing permission
|
||||
existing_q = await db.execute(
|
||||
select(EntityPermission)
|
||||
.where(EntityPermission.entity_type == entity_type)
|
||||
.where(EntityPermission.entity_id == entity_uuid)
|
||||
.where(EntityPermission.principal_type == principal_type)
|
||||
.where(EntityPermission.principal_id == principal_uuid)
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
)
|
||||
existing = existing_q.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Update if new level is higher
|
||||
if _rank(level) > _rank(existing.permission_level):
|
||||
existing.permission_level = level
|
||||
updated_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
else:
|
||||
perm = EntityPermission(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_uuid,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_uuid,
|
||||
permission_level=level,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(perm)
|
||||
created_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
"entity_id": str(entity_uuid),
|
||||
"error": str(e),
|
||||
})
|
||||
logger.warning("Bulk share error for %s/%s: %s", entity_type, entity_uuid, e)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"created": created_count,
|
||||
"updated": updated_count,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors,
|
||||
"total": len(entity_ids),
|
||||
}
|
||||
|
||||
|
||||
async def bulk_unshare(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_ids: list[str],
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Remove permissions for a principal from multiple entities."""
|
||||
principal_uuid = uuid.UUID(principal_id)
|
||||
entity_uuids = [uuid.UUID(eid) for eid in entity_ids]
|
||||
|
||||
deleted_count = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
for entity_uuid in entity_uuids:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(EntityPermission)
|
||||
.where(EntityPermission.entity_type == entity_type)
|
||||
.where(EntityPermission.entity_id == entity_uuid)
|
||||
.where(EntityPermission.principal_type == principal_type)
|
||||
.where(EntityPermission.principal_id == principal_uuid)
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
)
|
||||
perm = result.scalar_one_or_none()
|
||||
if perm:
|
||||
await db.delete(perm)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
"entity_id": str(entity_uuid),
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"deleted": deleted_count,
|
||||
"errors": errors,
|
||||
"total": len(entity_ids),
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Permission delegation service — CRUD + active check for permission handovers.
|
||||
|
||||
Allows users to temporarily delegate their permissions to other users
|
||||
for a specified time period and scope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.permission_delegation import PermissionDelegation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_delegation(d: PermissionDelegation) -> dict:
|
||||
return {
|
||||
"id": str(d.id),
|
||||
"from_user_id": str(d.from_user_id),
|
||||
"to_user_id": str(d.to_user_id),
|
||||
"start_at": d.start_at.isoformat() if d.start_at else None,
|
||||
"end_at": d.end_at.isoformat() if d.end_at else None,
|
||||
"scope": d.scope,
|
||||
"active": d.active,
|
||||
"tenant_id": str(d.tenant_id),
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_delegations(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None = None,
|
||||
direction: str = "all",
|
||||
) -> list[dict]:
|
||||
"""List delegations for a tenant.
|
||||
|
||||
Args:
|
||||
direction: 'from' (delegations I created), 'to' (delegations to me), 'all' (both)
|
||||
"""
|
||||
query = select(PermissionDelegation).where(PermissionDelegation.tenant_id == tenant_id)
|
||||
|
||||
if user_id:
|
||||
if direction == "from":
|
||||
query = query.where(PermissionDelegation.from_user_id == user_id)
|
||||
elif direction == "to":
|
||||
query = query.where(PermissionDelegation.to_user_id == user_id)
|
||||
else:
|
||||
query = query.where(
|
||||
or_(
|
||||
PermissionDelegation.from_user_id == user_id,
|
||||
PermissionDelegation.to_user_id == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(PermissionDelegation.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
delegations = result.scalars().all()
|
||||
return [_serialize_delegation(d) for d in delegations]
|
||||
|
||||
|
||||
async def create_delegation(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
from_user_id: uuid.UUID,
|
||||
to_user_id: uuid.UUID,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
scope: dict | None = None,
|
||||
) -> dict:
|
||||
"""Create a new permission delegation."""
|
||||
if end_at <= start_at:
|
||||
raise ValueError("end_at must be after start_at")
|
||||
|
||||
delegation = PermissionDelegation(
|
||||
tenant_id=tenant_id,
|
||||
from_user_id=from_user_id,
|
||||
to_user_id=to_user_id,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
scope=scope,
|
||||
)
|
||||
db.add(delegation)
|
||||
await db.commit()
|
||||
await db.refresh(delegation)
|
||||
return _serialize_delegation(delegation)
|
||||
|
||||
|
||||
async def update_delegation(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
delegation_id: str,
|
||||
**kwargs: Any,
|
||||
) -> dict:
|
||||
"""Update an existing delegation."""
|
||||
result = await db.execute(
|
||||
select(PermissionDelegation)
|
||||
.where(PermissionDelegation.id == uuid.UUID(delegation_id))
|
||||
.where(PermissionDelegation.tenant_id == tenant_id)
|
||||
)
|
||||
delegation = result.scalar_one_or_none()
|
||||
if delegation is None:
|
||||
raise ValueError(f"Delegation {delegation_id} not found")
|
||||
|
||||
updatable_fields = {"start_at", "end_at", "scope", "active"}
|
||||
for key, value in kwargs.items():
|
||||
if key in updatable_fields and value is not None:
|
||||
setattr(delegation, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(delegation)
|
||||
return _serialize_delegation(delegation)
|
||||
|
||||
|
||||
async def delete_delegation(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
delegation_id: str,
|
||||
) -> None:
|
||||
"""Delete a delegation."""
|
||||
result = await db.execute(
|
||||
select(PermissionDelegation)
|
||||
.where(PermissionDelegation.id == uuid.UUID(delegation_id))
|
||||
.where(PermissionDelegation.tenant_id == tenant_id)
|
||||
)
|
||||
delegation = result.scalar_one_or_none()
|
||||
if delegation is None:
|
||||
raise ValueError(f"Delegation {delegation_id} not found")
|
||||
await db.delete(delegation)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def is_delegation_active(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Check if a user has any active delegations (as delegatee).
|
||||
|
||||
Returns True if there is at least one active delegation where
|
||||
this user is the to_user_id and the current time is within [start_at, end_at].
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(PermissionDelegation)
|
||||
.where(PermissionDelegation.to_user_id == user_id)
|
||||
.where(PermissionDelegation.tenant_id == tenant_id)
|
||||
.where(PermissionDelegation.active == True) # noqa: E712
|
||||
.where(PermissionDelegation.start_at <= now)
|
||||
.where(PermissionDelegation.end_at > now)
|
||||
)
|
||||
delegation = result.scalar_one_or_none()
|
||||
return delegation is not None
|
||||
|
||||
|
||||
async def get_active_delegations(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> list[dict]:
|
||||
"""Get all active delegations for a user (as delegatee)."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(PermissionDelegation)
|
||||
.where(PermissionDelegation.to_user_id == user_id)
|
||||
.where(PermissionDelegation.tenant_id == tenant_id)
|
||||
.where(PermissionDelegation.active == True) # noqa: E712
|
||||
.where(PermissionDelegation.start_at <= now)
|
||||
.where(PermissionDelegation.end_at > now)
|
||||
)
|
||||
delegations = result.scalars().all()
|
||||
return [_serialize_delegation(d) for d in delegations]
|
||||
|
||||
|
||||
async def deactivate_expired_delegations(db: AsyncSession) -> int:
|
||||
"""Deactivate all delegations that have passed their end_at."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(PermissionDelegation)
|
||||
.where(PermissionDelegation.active == True) # noqa: E712
|
||||
.where(PermissionDelegation.end_at <= now)
|
||||
)
|
||||
expired = result.scalars().all()
|
||||
count = len(expired)
|
||||
for delegation in expired:
|
||||
delegation.active = False
|
||||
if count > 0:
|
||||
await db.commit()
|
||||
logger.info("Deactivated %d expired delegations", count)
|
||||
return count
|
||||
@@ -715,6 +715,83 @@ async def list_all_permissions(
|
||||
return [_serialize_permission(p, names.get(p.principal_id)) for p in perms]
|
||||
|
||||
|
||||
async def get_permission_analytics(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""Get permission analytics for a tenant.
|
||||
|
||||
Returns:
|
||||
total_permissions: Total number of permission entries
|
||||
total_shared_entities: Number of unique entities with permissions
|
||||
permissions_by_level: Breakdown by permission level
|
||||
permissions_by_entity_type: Breakdown by entity type
|
||||
recent_changes: Last 10 permission changes
|
||||
"""
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
# Total permissions
|
||||
total_q = await db.execute(
|
||||
select(sa_func.count(EntityPermission.id))
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
)
|
||||
total_permissions = total_q.scalar() or 0
|
||||
|
||||
# Total unique shared entities
|
||||
unique_q = await db.execute(
|
||||
select(sa_func.count(sa_func.distinct(
|
||||
EntityPermission.entity_type + ":" + EntityPermission.entity_id.cast(String)
|
||||
)))
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
)
|
||||
total_shared_entities = unique_q.scalar() or 0
|
||||
|
||||
# Permissions by level
|
||||
level_q = await db.execute(
|
||||
select(EntityPermission.permission_level, sa_func.count(EntityPermission.id))
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
.group_by(EntityPermission.permission_level)
|
||||
)
|
||||
permissions_by_level = {row[0]: row[1] for row in level_q}
|
||||
|
||||
# Permissions by entity type
|
||||
type_q = await db.execute(
|
||||
select(EntityPermission.entity_type, sa_func.count(EntityPermission.id))
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
.group_by(EntityPermission.entity_type)
|
||||
)
|
||||
permissions_by_entity_type = {row[0]: row[1] for row in type_q}
|
||||
|
||||
# Recent changes (last 10)
|
||||
recent_q = await db.execute(
|
||||
select(EntityPermission)
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
.order_by(EntityPermission.updated_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recent = recent_q.scalars().all()
|
||||
recent_changes = [
|
||||
{
|
||||
"id": str(p.id),
|
||||
"entity_type": p.entity_type,
|
||||
"entity_id": str(p.entity_id),
|
||||
"principal_type": p.principal_type,
|
||||
"principal_id": str(p.principal_id),
|
||||
"permission_level": p.permission_level,
|
||||
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
||||
}
|
||||
for p in recent
|
||||
]
|
||||
|
||||
return {
|
||||
"total_permissions": total_permissions,
|
||||
"total_shared_entities": total_shared_entities,
|
||||
"permissions_by_level": permissions_by_level,
|
||||
"permissions_by_entity_type": permissions_by_entity_type,
|
||||
"recent_changes": recent_changes,
|
||||
}
|
||||
|
||||
|
||||
async def cleanup_expired_permissions(db: AsyncSession) -> int:
|
||||
"""Delete all expired permission entries. Returns count deleted."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Permission template service — CRUD + apply_template for reusable permission presets.
|
||||
|
||||
Templates define default sharing rules. When applied to an entity,
|
||||
they evaluate trigger_condition and auto_share_with to create
|
||||
entity_permissions entries automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.entity_permission import EntityPermission
|
||||
from app.models.permission_template import PermissionTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_template(t: PermissionTemplate) -> dict:
|
||||
return {
|
||||
"id": str(t.id),
|
||||
"name": t.name,
|
||||
"entity_type": t.entity_type,
|
||||
"trigger_condition": t.trigger_condition,
|
||||
"auto_share_with": t.auto_share_with,
|
||||
"level": t.level,
|
||||
"tenant_id": str(t.tenant_id),
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_templates(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity_type: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""List all permission templates for a tenant, optionally filtered by entity_type."""
|
||||
query = select(PermissionTemplate).where(PermissionTemplate.tenant_id == tenant_id)
|
||||
if entity_type:
|
||||
query = query.where(PermissionTemplate.entity_type == entity_type)
|
||||
query = query.order_by(PermissionTemplate.name)
|
||||
result = await db.execute(query)
|
||||
templates = result.scalars().all()
|
||||
return [_serialize_template(t) for t in templates]
|
||||
|
||||
|
||||
async def create_template(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
name: str,
|
||||
entity_type: str,
|
||||
level: str = "read",
|
||||
trigger_condition: dict | None = None,
|
||||
auto_share_with: list | None = None,
|
||||
) -> dict:
|
||||
"""Create a new permission template."""
|
||||
template = PermissionTemplate(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
entity_type=entity_type,
|
||||
level=level,
|
||||
trigger_condition=trigger_condition,
|
||||
auto_share_with=auto_share_with,
|
||||
)
|
||||
db.add(template)
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return _serialize_template(template)
|
||||
|
||||
|
||||
async def update_template(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
template_id: str,
|
||||
**kwargs: Any,
|
||||
) -> dict:
|
||||
"""Update an existing permission template."""
|
||||
result = await db.execute(
|
||||
select(PermissionTemplate)
|
||||
.where(PermissionTemplate.id == uuid.UUID(template_id))
|
||||
.where(PermissionTemplate.tenant_id == tenant_id)
|
||||
)
|
||||
template = result.scalar_one_or_none()
|
||||
if template is None:
|
||||
raise ValueError(f"Permission template {template_id} not found")
|
||||
|
||||
updatable_fields = {"name", "entity_type", "level", "trigger_condition", "auto_share_with"}
|
||||
for key, value in kwargs.items():
|
||||
if key in updatable_fields and value is not None:
|
||||
setattr(template, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return _serialize_template(template)
|
||||
|
||||
|
||||
async def delete_template(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
template_id: str,
|
||||
) -> None:
|
||||
"""Delete a permission template."""
|
||||
result = await db.execute(
|
||||
select(PermissionTemplate)
|
||||
.where(PermissionTemplate.id == uuid.UUID(template_id))
|
||||
.where(PermissionTemplate.tenant_id == tenant_id)
|
||||
)
|
||||
template = result.scalar_one_or_none()
|
||||
if template is None:
|
||||
raise ValueError(f"Permission template {template_id} not found")
|
||||
await db.delete(template)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def apply_template(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
template_id: str | None = None,
|
||||
created_by: uuid.UUID | None = None,
|
||||
) -> list[dict]:
|
||||
"""Apply a permission template to an entity, creating entity_permissions entries.
|
||||
|
||||
If template_id is provided, applies that specific template.
|
||||
Otherwise, finds all matching templates for the entity_type and applies them.
|
||||
|
||||
Returns the list of created entity_permissions.
|
||||
"""
|
||||
entity_uuid = uuid.UUID(entity_id)
|
||||
created_permissions: list[dict] = []
|
||||
|
||||
if template_id:
|
||||
result = await db.execute(
|
||||
select(PermissionTemplate)
|
||||
.where(PermissionTemplate.id == uuid.UUID(template_id))
|
||||
.where(PermissionTemplate.tenant_id == tenant_id)
|
||||
)
|
||||
templates = [result.scalar_one_or_none()]
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(PermissionTemplate)
|
||||
.where(PermissionTemplate.tenant_id == tenant_id)
|
||||
.where(PermissionTemplate.entity_type == entity_type)
|
||||
)
|
||||
templates = list(result.scalars().all())
|
||||
|
||||
for template in templates:
|
||||
if template is None:
|
||||
continue
|
||||
|
||||
# Evaluate trigger_condition if present
|
||||
if template.trigger_condition:
|
||||
# For now, simple evaluation: if trigger_condition exists, check if it matches
|
||||
# In a full implementation, this would evaluate against entity attributes
|
||||
if not _evaluate_trigger(template.trigger_condition, entity_type, entity_uuid):
|
||||
continue
|
||||
|
||||
# Create entity_permissions from auto_share_with
|
||||
if template.auto_share_with:
|
||||
for share_entry in template.auto_share_with:
|
||||
principal_type = share_entry.get("principal_type", "user")
|
||||
principal_id = share_entry.get("principal_id")
|
||||
level = share_entry.get("level", template.level)
|
||||
|
||||
if not principal_id:
|
||||
continue
|
||||
|
||||
# Check if permission already exists
|
||||
existing_q = await db.execute(
|
||||
select(EntityPermission)
|
||||
.where(EntityPermission.entity_type == entity_type)
|
||||
.where(EntityPermission.entity_id == entity_uuid)
|
||||
.where(EntityPermission.principal_type == principal_type)
|
||||
.where(EntityPermission.principal_id == uuid.UUID(principal_id))
|
||||
.where(EntityPermission.tenant_id == tenant_id)
|
||||
)
|
||||
if existing_q.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
perm = EntityPermission(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_uuid,
|
||||
principal_type=principal_type,
|
||||
principal_id=uuid.UUID(principal_id),
|
||||
permission_level=level,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(perm)
|
||||
await db.flush()
|
||||
created_permissions.append({
|
||||
"id": str(perm.id),
|
||||
"entity_type": entity_type,
|
||||
"entity_id": str(entity_uuid),
|
||||
"principal_type": principal_type,
|
||||
"principal_id": principal_id,
|
||||
"permission_level": level,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return created_permissions
|
||||
|
||||
|
||||
def _evaluate_trigger(
|
||||
trigger_condition: dict,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Evaluate a trigger condition against an entity.
|
||||
|
||||
Simple implementation: always returns True for now.
|
||||
In production, this would query entity attributes and evaluate conditions.
|
||||
"""
|
||||
# For now, always apply if trigger_condition exists
|
||||
# Future: evaluate against entity fields
|
||||
return True
|
||||
Reference in New Issue
Block a user