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
+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