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
@@ -0,0 +1,42 @@
"""Create permission_templates table.
Revision ID: 0056
Revises: 0055
Create Date: 2026-07-29
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
revision = "0056"
down_revision = "0055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"permission_templates",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("entity_type", sa.String(50), nullable=False),
sa.Column("trigger_condition", JSONB, nullable=True),
sa.Column("auto_share_with", JSONB, nullable=True),
sa.Column("level", sa.String(20), nullable=False, server_default=sa.text("'read'")),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.CheckConstraint(
"level IN ('read', 'write', 'admin', 'delete')",
name="ck_pt_level",
),
)
op.create_index("ix_pt_entity_type", "permission_templates", ["entity_type"])
op.create_index("ix_pt_tenant", "permission_templates", ["tenant_id"])
def downgrade() -> None:
op.drop_index("ix_pt_tenant", table_name="permission_templates")
op.drop_index("ix_pt_entity_type", table_name="permission_templates")
op.drop_table("permission_templates")
@@ -0,0 +1,47 @@
"""Create permission_delegations table.
Revision ID: 0057
Revises: 0056
Create Date: 2026-07-29
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
revision = "0057"
down_revision = "0056"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"permission_delegations",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("from_user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("to_user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("scope", JSONB, nullable=True),
sa.Column("active", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.CheckConstraint(
"end_at > start_at",
name="ck_pd_end_after_start",
),
)
op.create_index("ix_pd_from_user", "permission_delegations", ["from_user_id"])
op.create_index("ix_pd_to_user", "permission_delegations", ["to_user_id"])
op.create_index("ix_pd_tenant", "permission_delegations", ["tenant_id"])
op.create_index("ix_pd_active", "permission_delegations", ["active"])
def downgrade() -> None:
op.drop_index("ix_pd_active", table_name="permission_delegations")
op.drop_index("ix_pd_tenant", table_name="permission_delegations")
op.drop_index("ix_pd_to_user", table_name="permission_delegations")
op.drop_index("ix_pd_from_user", table_name="permission_delegations")
op.drop_table("permission_delegations")
@@ -0,0 +1,39 @@
"""Add resolution_strategy field to tenants table.
Revision ID: 0058
Revises: 0057
Create Date: 2026-07-29
"""
from alembic import op
import sqlalchemy as sa
revision = "0058"
down_revision = "0057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"tenants",
sa.Column(
"resolution_strategy",
sa.String(30),
nullable=False,
server_default=sa.text("'highest_wins'"),
),
)
op.create_check_constraint(
"ck_tenant_resolution_strategy",
"tenants",
sa.schema.CheckConstraint(
"resolution_strategy IN ('highest_wins', 'deny_overrides_allow', 'direct_overrides_group', 'most_restrictive_wins')",
name="ck_tenant_resolution_strategy",
),
)
def downgrade() -> None:
op.drop_constraint("ck_tenant_resolution_strategy", "tenants")
op.drop_column("tenants", "resolution_strategy")
+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
+124
View File
@@ -0,0 +1,124 @@
/**
* ABAC Policy API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target
* the Policies routes under `/policies/...`.
*/
import { apiDelete, apiGet, apiPost, apiPut } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export type PrincipalType = 'user' | 'group' | 'role';
export type ConditionOperator =
| 'eq'
| 'neq'
| 'in'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'starts_with'
| 'is_null';
export type ConditionGroupLogic = 'AND' | 'OR';
export interface Condition {
id?: string;
field: string;
operator: ConditionOperator;
value: string;
}
export interface ConditionGroup {
id?: string;
logic: ConditionGroupLogic;
conditions: Condition[];
groups?: ConditionGroup[];
}
export interface ABACPolicy {
id: string;
name: string;
entity_type: string;
principal_type: PrincipalType;
principal_id: string;
principal_name?: string | null;
effect: 'allow' | 'deny';
conditions: ConditionGroup | null;
priority: number;
enabled: boolean;
created_at?: string | null;
updated_at?: string | null;
}
export interface PolicyListResponse {
items: ABACPolicy[];
total: number;
}
export interface CreatePolicyPayload {
name: string;
principal_type: PrincipalType;
principal_id: string;
effect: 'allow' | 'deny';
conditions: ConditionGroup | null;
priority: number;
enabled: boolean;
}
export interface UpdatePolicyPayload {
name?: string;
principal_type?: PrincipalType;
principal_id?: string;
effect?: 'allow' | 'deny';
conditions?: ConditionGroup | null;
priority?: number;
enabled?: boolean;
}
// ─── API Functions ─────────────────────────────────────────────────────────
/**
* Fetch all policies for a given entity type.
*/
export function fetchPolicies(entityType: string): Promise<PolicyListResponse> {
return apiGet<PolicyListResponse>(`/policies/${entityType}`);
}
/**
* Fetch a single policy by ID.
*/
export function fetchPolicy(entityType: string, policyId: string): Promise<ABACPolicy> {
return apiGet<ABACPolicy>(`/policies/${entityType}/${policyId}`);
}
/**
* Create a new policy for the given entity type.
*/
export function createPolicy(
entityType: string,
payload: CreatePolicyPayload
): Promise<ABACPolicy> {
return apiPost<ABACPolicy>(`/policies/${entityType}`, payload);
}
/**
* Update an existing policy.
*/
export function updatePolicy(
entityType: string,
policyId: string,
payload: UpdatePolicyPayload
): Promise<ABACPolicy> {
return apiPut<ABACPolicy>(`/policies/${entityType}/${policyId}`, payload);
}
/**
* Delete a policy.
*/
export function deletePolicy(entityType: string, policyId: string): Promise<void> {
return apiDelete<void>(`/policies/${entityType}/${policyId}`);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* React Query hooks for the ABAC Policy API.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchPolicies,
fetchPolicy,
createPolicy,
updatePolicy,
deletePolicy,
type CreatePolicyPayload,
type UpdatePolicyPayload,
} from './policies';
// ─── Query Key Factory ─────────────────────────────────────────────────────
export const policyKeys = {
all: ['policies'] as const,
list: (entityType: string) => [...policyKeys.all, 'list', entityType] as const,
detail: (entityType: string, policyId: string) =>
[...policyKeys.all, 'detail', entityType, policyId] as const,
};
// ─── Hooks ─────────────────────────────────────────────────────────────────
/**
* Fetch all policies for a given entity type.
*/
export function usePolicies(entityType: string) {
return useQuery({
queryKey: policyKeys.list(entityType),
queryFn: () => fetchPolicies(entityType),
enabled: !!entityType,
});
}
/**
* Fetch a single policy by ID.
*/
export function usePolicy(entityType: string, policyId: string | null) {
return useQuery({
queryKey: policyKeys.detail(entityType, policyId!),
queryFn: () => fetchPolicy(entityType, policyId!),
enabled: !!entityType && !!policyId,
});
}
/**
* Create a new policy.
*/
export function useCreatePolicy(entityType: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreatePolicyPayload) => createPolicy(entityType, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: policyKeys.list(entityType) });
},
});
}
/**
* Update an existing policy.
*/
export function useUpdatePolicy(entityType: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
policyId,
data,
}: {
policyId: string;
data: UpdatePolicyPayload;
}) => updatePolicy(entityType, policyId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: policyKeys.list(entityType) });
},
});
}
/**
* Delete a policy.
*/
export function useDeletePolicy(entityType: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (policyId: string) => deletePolicy(entityType, policyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: policyKeys.list(entityType) });
},
});
}
@@ -0,0 +1,884 @@
/**
* ABACRuleEditor — UI zum Erstellen und Bearbeiten von ABAC Policies.
*
* Features:
* - Liste aller Policies für einen Entity-Type
* - Neue Policy erstellen / bestehende bearbeiten
* - Conditions Builder mit AND/OR Gruppen
* - Policy löschen mit ConfirmDialog
* - Text-basierte Vorschau der Policy
*/
import React, { useState, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import {
Plus,
Pencil,
Trash2,
X,
Shield,
ShieldCheck,
ShieldX,
GripVertical,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
ArrowUpDown,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePolicies,
useCreatePolicy,
useUpdatePolicy,
useDeletePolicy,
} from '../../api/policyHooks';
import { useUsers } from '../../api/users';
import { useGroups } from '../../api/groups';
import { useRoles } from '../../api/roles';
import {
type ABACPolicy,
type PrincipalType,
type ConditionOperator,
type ConditionGroupLogic,
type Condition,
type ConditionGroup,
type CreatePolicyPayload,
type UpdatePolicyPayload,
} from '../../api/policies';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { Select, type SelectOption } from '../ui/Select';
import { Input } from '../ui/Input';
import { Modal } from '../ui/Modal';
import { ConfirmDialog } from '../ui/ConfirmDialog';
// ─── Constants ─────────────────────────────────────────────────────────────
const OPERATOR_OPTIONS: SelectOption[] = [
{ value: 'eq', label: '=' },
{ value: 'neq', label: '≠' },
{ value: 'in', label: 'in' },
{ value: 'gt', label: '>' },
{ value: 'gte', label: '≥' },
{ value: 'lt', label: '<' },
{ value: 'lte', label: '≤' },
{ value: 'contains', label: 'contains' },
{ value: 'starts_with', label: 'starts with' },
{ value: 'is_null', label: 'is null' },
];
const PRINCIPAL_TYPE_OPTIONS: SelectOption[] = [
{ value: 'user', label: 'User' },
{ value: 'group', label: 'Group' },
{ value: 'role', label: 'Role' },
];
const EFFECT_OPTIONS: SelectOption[] = [
{ value: 'allow', label: 'Allow' },
{ value: 'deny', label: 'Deny' },
];
const LOGIC_OPTIONS: SelectOption[] = [
{ value: 'AND', label: 'AND' },
{ value: 'OR', label: 'OR' },
];
// ─── Helpers ───────────────────────────────────────────────────────────────
function generateConditionId(): string {
return `cond_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function generateGroupId(): string {
return `grp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function createEmptyCondition(): Condition {
return { id: generateConditionId(), field: '', operator: 'eq', value: '' };
}
function createEmptyGroup(logic: ConditionGroupLogic = 'AND'): ConditionGroup {
return {
id: generateGroupId(),
logic,
conditions: [createEmptyCondition()],
groups: [],
};
}
/**
* Generate a human-readable description of a condition group.
*/
function describeConditionGroup(group: ConditionGroup | null): string {
if (!group) return '—';
const parts: string[] = [];
for (const cond of group.conditions) {
if (!cond.field) continue;
const opLabel = OPERATOR_OPTIONS.find((o) => o.value === cond.operator)?.label || cond.operator;
if (cond.operator === 'is_null') {
parts.push(`${cond.field} is null`);
} else if (cond.operator === 'in') {
parts.push(`${cond.field} ${opLabel} (${cond.value})`);
} else {
parts.push(`${cond.field} ${opLabel} ${cond.value}`);
}
}
for (const sub of group.groups || []) {
const subDesc = describeConditionGroup(sub);
if (subDesc !== '—') {
parts.push(`(${subDesc})`);
}
}
if (parts.length === 0) return '—';
return parts.join(` ${group.logic} `);
}
/**
* Generate a full human-readable policy description.
*/
function describePolicy(policy: ABACPolicy): string {
const principalLabel = policy.principal_name || policy.principal_id;
const effectLabel = policy.effect === 'allow' ? 'darf' : 'darf nicht';
const condDesc = describeConditionGroup(policy.conditions);
if (condDesc === '—') {
return `${policy.principal_type}${principalLabel}${effectLabel} auf ${policy.entity_type} zugreifen`;
}
return `${policy.principal_type}${principalLabel}${effectLabel} auf ${policy.entity_type} zugreifen, wenn ${condDesc}`;
}
// ─── Sub-Components ────────────────────────────────────────────────────────
interface ConditionRowProps {
condition: Condition;
onChange: (condition: Condition) => void;
onRemove: () => void;
canRemove: boolean;
}
function ConditionRow({ condition, onChange, onRemove, canRemove }: ConditionRowProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2 py-1">
<div className="flex-1">
<Input
placeholder={t('abac.fieldPlaceholder', 'Field')}
value={condition.field}
onChange={(e) => onChange({ ...condition, field: e.target.value })}
size="sm"
/>
</div>
<div className="w-32">
<Select
options={OPERATOR_OPTIONS}
value={condition.operator}
onChange={(e) => onChange({ ...condition, operator: e.target.value as ConditionOperator })}
className="text-sm"
/>
</div>
<div className="flex-1">
<Input
placeholder={t('abac.valuePlaceholder', 'Value')}
value={condition.value}
onChange={(e) => onChange({ ...condition, value: e.target.value })}
size="sm"
/>
</div>
{canRemove && (
<button
type="button"
onClick={onRemove}
className="mt-1 text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.removeCondition', 'Remove condition')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
interface ConditionGroupEditorProps {
group: ConditionGroup;
onChange: (group: ConditionGroup) => void;
onRemove?: () => void;
depth: number;
canRemove: boolean;
}
function ConditionGroupEditor({
group,
onChange,
onRemove,
depth,
canRemove,
}: ConditionGroupEditorProps) {
const { t } = useTranslation();
const addCondition = useCallback(() => {
onChange({
...group,
conditions: [...group.conditions, createEmptyCondition()],
});
}, [group, onChange]);
const updateCondition = useCallback(
(index: number, condition: Condition) => {
const updated = [...group.conditions];
updated[index] = condition;
onChange({ ...group, conditions: updated });
},
[group, onChange]
);
const removeCondition = useCallback(
(index: number) => {
if (group.conditions.length <= 1) return;
const updated = group.conditions.filter((_, i) => i !== index);
onChange({ ...group, conditions: updated });
},
[group, onChange]
);
const toggleLogic = useCallback(() => {
onChange({
...group,
logic: group.logic === 'AND' ? 'OR' : 'AND',
});
}, [group, onChange]);
return (
<div className={clsx(
'border rounded-md p-3',
depth > 0 && 'ml-4 bg-secondary-50/50'
)}>
{/* Header: Logic toggle + actions */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleLogic}
className={clsx(
'px-2 py-0.5 text-xs font-medium rounded border transition-colors',
group.logic === 'AND'
? 'bg-primary-100 text-primary-700 border-primary-300'
: 'bg-accent-100 text-accent-700 border-accent-300'
)}
>
{group.logic}
</button>
<span className="text-xs text-secondary-500">
{t('abac.groupConditions', 'Conditions')}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={addCondition}
>
{t('abac.addCondition', 'Add')}
</Button>
{canRemove && onRemove && (
<button
type="button"
onClick={onRemove}
className="text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.removeGroup', 'Remove group')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Conditions */}
{group.conditions.map((cond, idx) => (
<ConditionRow
key={cond.id}
condition={cond}
onChange={(c) => updateCondition(idx, c)}
onRemove={() => removeCondition(idx)}
canRemove={group.conditions.length > 1}
/>
))}
{/* Nested groups */}
{group.groups?.map((sub, idx) => (
<ConditionGroupEditor
key={sub.id}
group={sub}
onChange={(g) => {
const updated = [...(group.groups || [])];
updated[idx] = g;
onChange({ ...group, groups: updated });
}}
onRemove={() => {
const updated = (group.groups || []).filter((_, i) => i !== idx);
onChange({ ...group, groups: updated });
}}
depth={depth + 1}
canRemove={true}
/>
))}
{/* Add nested group */}
<button
type="button"
onClick={() => {
onChange({
...group,
groups: [...(group.groups || []), createEmptyGroup('AND')],
});
}}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
{t('abac.addNestedGroup', 'Add nested group')}
</button>
</div>
);
}
// ─── Policy Form ───────────────────────────────────────────────────────────
interface PolicyFormProps {
initial?: ABACPolicy | null;
entityType: string;
onSave: () => void;
onCancel: () => void;
}
function PolicyForm({ initial, entityType, onSave, onCancel }: PolicyFormProps) {
const { t } = useTranslation();
const createPolicy = useCreatePolicy(entityType);
const updatePolicy = useUpdatePolicy(entityType);
// Fetch principals for selectors
const { data: usersData } = useUsers();
const { data: groupsData } = useGroups();
const { data: rolesData } = useRoles();
const [name, setName] = useState(initial?.name || '');
const [principalType, setPrincipalType] = useState<PrincipalType>(
initial?.principal_type || 'user'
);
const [principalId, setPrincipalId] = useState(initial?.principal_id || '');
const [effect, setEffect] = useState<'allow' | 'deny'>(initial?.effect || 'allow');
const [conditions, setConditions] = useState<ConditionGroup | null>(
initial?.conditions || null
);
const [priority, setPriority] = useState(initial?.priority ?? 0);
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [error, setError] = useState<string | null>(null);
// Build principal options based on selected type
const principalOptions: SelectOption[] = useMemo(() => {
if (principalType === 'user') {
return (usersData?.items || []).map((u) => ({
value: u.id,
label: u.name || u.email,
}));
}
if (principalType === 'group') {
return (groupsData?.items || []).map((g) => ({
value: g.id,
label: g.name,
}));
}
if (principalType === 'role') {
return (rolesData?.items || []).map((r) => ({
value: r.id,
label: r.name,
}));
}
return [];
}, [principalType, usersData, groupsData, rolesData]);
const handleSave = useCallback(async () => {
setError(null);
if (!name.trim()) {
setError(t('abac.nameRequired', 'Name is required'));
return;
}
if (!principalId) {
setError(t('abac.principalRequired', 'Principal is required'));
return;
}
try {
if (initial) {
const payload: UpdatePolicyPayload = {
name: name.trim(),
principal_type: principalType,
principal_id: principalId,
effect,
conditions,
priority,
enabled,
};
await updatePolicy.mutateAsync({ policyId: initial.id, data: payload });
} else {
const payload: CreatePolicyPayload = {
name: name.trim(),
principal_type: principalType,
principal_id: principalId,
effect,
conditions,
priority,
enabled,
};
await createPolicy.mutateAsync(payload);
}
onSave();
} catch (err: any) {
setError(err?.message || t('abac.saveError', 'Failed to save policy'));
}
}, [
initial,
name,
principalType,
principalId,
effect,
conditions,
priority,
enabled,
createPolicy,
updatePolicy,
onSave,
t,
]);
const isSaving = createPolicy.isPending || updatePolicy.isPending;
// Generate preview text
const previewText = useMemo(() => {
if (!name.trim() && !principalId) return '';
const mockPolicy: ABACPolicy = {
id: initial?.id || 'new',
name: name.trim() || '(unnamed)',
entity_type: entityType,
principal_type: principalType,
principal_id: principalId,
principal_name:
principalOptions.find((o) => o.value === principalId)?.label || null,
effect,
conditions,
priority,
enabled,
};
return describePolicy(mockPolicy);
}, [name, principalType, principalId, effect, conditions, priority, enabled, entityType, initial, principalOptions]);
return (
<div className="space-y-4">
{/* Name */}
<Input
label={t('abac.policyName', 'Policy Name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('abac.policyNamePlaceholder', 'e.g. Vertrieb kann Kontakte sehen')}
required
/>
{/* Principal Type + ID */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.principalType', 'Principal Type')}
options={PRINCIPAL_TYPE_OPTIONS}
value={principalType}
onChange={(e) => {
setPrincipalType(e.target.value as PrincipalType);
setPrincipalId('');
}}
/>
<Select
label={t('abac.principal', 'Principal')}
options={principalOptions}
value={principalId}
onChange={(e) => setPrincipalId(e.target.value)}
placeholder={t('abac.selectPrincipal', 'Select...')}
/>
</div>
{/* Effect + Priority */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.effect', 'Effect')}
options={EFFECT_OPTIONS}
value={effect}
onChange={(e) => setEffect(e.target.value as 'allow' | 'deny')}
/>
<Input
label={t('abac.priority', 'Priority')}
type="number"
value={String(priority)}
onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
/>
</div>
{/* Enabled */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-secondary-700">
{t('abac.enabled', 'Enabled')}
</span>
</label>
{/* Conditions Builder */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-secondary-700">
{t('abac.conditions', 'Conditions')}
</label>
{!conditions && (
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={() => setConditions(createEmptyGroup('AND'))}
>
{t('abac.addConditions', 'Add conditions')}
</Button>
)}
</div>
{conditions && (
<div className="space-y-2">
<ConditionGroupEditor
group={conditions}
onChange={setConditions}
onRemove={() => setConditions(null)}
depth={0}
canRemove={true}
/>
</div>
)}
</div>
{/* Preview */}
{previewText && (
<div className="bg-secondary-50 border border-secondary-200 rounded-md p-3">
<p className="text-xs font-medium text-secondary-500 mb-1">
{t('abac.preview', 'Preview')}
</p>
<p className="text-sm text-secondary-800">{previewText}</p>
</div>
)}
{/* Error */}
{error && (
<p className="text-sm text-danger-600" role="alert">
{error}
</p>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onCancel}>
{t('abac.cancel', 'Cancel')}
</Button>
<Button variant="primary" onClick={handleSave} isLoading={isSaving}>
{initial ? t('abac.update', 'Update') : t('abac.create', 'Create')}
</Button>
</div>
</div>
);
}
// ─── Main Component ────────────────────────────────────────────────────────
export interface ABACRuleEditorProps {
entityType: string;
onClose: () => void;
}
export function ABACRuleEditor({ entityType, onClose }: ABACRuleEditorProps) {
const { t } = useTranslation();
const { data: policiesData, isLoading, error: fetchError } = usePolicies(entityType);
const deletePolicy = useDeletePolicy(entityType);
const [showForm, setShowForm] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<ABACPolicy | null>(null);
const [deletingPolicy, setDeletingPolicy] = useState<ABACPolicy | null>(null);
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
const policies = policiesData?.items || [];
const handleCreate = useCallback(() => {
setEditingPolicy(null);
setShowForm(true);
}, []);
const handleEdit = useCallback((policy: ABACPolicy) => {
setEditingPolicy(policy);
setShowForm(true);
}, []);
const handleFormSave = useCallback(() => {
setShowForm(false);
setEditingPolicy(null);
}, []);
const handleFormCancel = useCallback(() => {
setShowForm(false);
setEditingPolicy(null);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deletingPolicy) return;
try {
await deletePolicy.mutateAsync(deletingPolicy.id);
} catch {
// Error is handled by the mutation
}
setDeletingPolicy(null);
}, [deletingPolicy, deletePolicy]);
const toggleExpand = useCallback((policyId: string) => {
setExpandedPolicies((prev) => {
const next = new Set(prev);
if (next.has(policyId)) {
next.delete(policyId);
} else {
next.add(policyId);
}
return next;
});
}, []);
return (
<Card
title={t('abac.ruleEditor', 'ABAC Rule Editor')}
description={`${entityType}`}
actions={
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.newPolicy', 'New Policy')}
</Button>
<Button
variant="ghost"
size="sm"
icon={<X className="h-4 w-4" />}
onClick={onClose}
>
{t('abac.close', 'Close')}
</Button>
</div>
}
>
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-6 w-6 border-2 border-primary-600 border-t-transparent rounded-full" />
<span className="ml-3 text-sm text-secondary-500">
{t('abac.loading', 'Loading policies...')}
</span>
</div>
)}
{/* Error state */}
{fetchError && !isLoading && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<p className="text-sm text-danger-700">
{t('abac.fetchError', 'Failed to load policies')}: {String(fetchError)}
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !fetchError && policies.length === 0 && !showForm && (
<div className="text-center py-8">
<Shield className="h-12 w-12 text-secondary-300 mx-auto mb-3" />
<p className="text-sm text-secondary-500 mb-4">
{t('abac.noPolicies', 'No policies defined for this entity type.')}
</p>
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.createFirst', 'Create first policy')}
</Button>
</div>
)}
{/* Policy list */}
{!isLoading && !fetchError && policies.length > 0 && !showForm && (
<div className="space-y-2">
{policies.map((policy) => {
const isExpanded = expandedPolicies.has(policy.id);
return (
<div
key={policy.id}
className="border border-secondary-200 rounded-md hover:border-secondary-300 transition-colors"
>
{/* Policy header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={() => toggleExpand(policy.id)}
>
<div className="flex items-center gap-3 min-w-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-secondary-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-secondary-400 shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-secondary-900 truncate">
{policy.name}
</p>
<p className="text-xs text-secondary-500 truncate">
{describePolicy(policy)}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant={policy.effect === 'allow' ? 'success' : 'danger'}
>
{policy.effect === 'allow' ? (
<ShieldCheck className="h-3 w-3 mr-1" />
) : (
<ShieldX className="h-3 w-3 mr-1" />
)}
{policy.effect}
</Badge>
{!policy.enabled && (
<Badge variant="warning">{t('abac.disabled', 'Disabled')}</Badge>
)}
<span className="text-xs text-secondary-400">P{policy.priority}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleEdit(policy);
}}
className="text-secondary-400 hover:text-primary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.editPolicy', 'Edit policy')}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDeletingPolicy(policy);
}}
className="text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.deletePolicy', 'Delete policy')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="px-4 pb-3 pt-0 border-t border-secondary-100">
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="text-secondary-500">
{t('abac.principal', 'Principal')}:
</span>{' '}
<span className="text-secondary-800">
{policy.principal_name || policy.principal_id}
</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.principalType', 'Type')}:
</span>{' '}
<span className="text-secondary-800">{policy.principal_type}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.priority', 'Priority')}:
</span>{' '}
<span className="text-secondary-800">{policy.priority}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.enabled', 'Enabled')}:
</span>{' '}
<span className="text-secondary-800">
{policy.enabled ? '✓' : '✗'}
</span>
</div>
</div>
{policy.conditions && (
<div className="mt-2">
<span className="text-xs text-secondary-500">
{t('abac.conditions', 'Conditions')}:
</span>
<p className="text-xs text-secondary-800 mt-1">
{describeConditionGroup(policy.conditions)}
</p>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Create/Edit Form Modal */}
<Modal
open={showForm}
onClose={handleFormCancel}
title={
editingPolicy
? t('abac.editPolicyTitle', 'Edit Policy')
: t('abac.createPolicyTitle', 'Create Policy')
}
size="lg"
>
<PolicyForm
initial={editingPolicy}
entityType={entityType}
onSave={handleFormSave}
onCancel={handleFormCancel}
/>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
open={!!deletingPolicy}
title={t('abac.deletePolicyTitle', 'Delete Policy')}
message={
deletingPolicy
? t('abac.deleteConfirm', 'Are you sure you want to delete policy "{{name}}"?', {
name: deletingPolicy.name,
})
: ''
}
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeletingPolicy(null)}
/>
</Card>
);
}