sprint14-19: ABAC UI rule editor + permission templates + bulk share + analytics + delegation + resolution strategies + migrations 0056-0058

This commit is contained in:
Agent Zero
2026-07-29 02:47:03 +02:00
parent e0003b9384
commit ddf73ee42e
21 changed files with 2399 additions and 4 deletions
+27 -2
View File
@@ -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,
}
+4
View File
@@ -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)
+4
View File
@@ -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",
+78
View File
@@ -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(),
)
+60
View File
@@ -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
View File
@@ -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",
),
)
+106
View File
@@ -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),
}
+65 -1
View File
@@ -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))
+114
View File
@@ -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))
+22
View File
@@ -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
+29
View File
@@ -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
+155
View File
@@ -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),
}
+197
View File
@@ -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
+77
View File
@@ -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)
+222
View File
@@ -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