diff --git a/alembic/versions/0056_permission_templates.py b/alembic/versions/0056_permission_templates.py new file mode 100644 index 0000000..39324f8 --- /dev/null +++ b/alembic/versions/0056_permission_templates.py @@ -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") diff --git a/alembic/versions/0057_permission_delegations.py b/alembic/versions/0057_permission_delegations.py new file mode 100644 index 0000000..2453289 --- /dev/null +++ b/alembic/versions/0057_permission_delegations.py @@ -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") diff --git a/alembic/versions/0058_resolution_strategy.py b/alembic/versions/0058_resolution_strategy.py new file mode 100644 index 0000000..a583867 --- /dev/null +++ b/alembic/versions/0058_resolution_strategy.py @@ -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") diff --git a/app/core/permissions.py b/app/core/permissions.py index d8ddc1e..76e69c2 100644 --- a/app/core/permissions.py +++ b/app/core/permissions.py @@ -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, } diff --git a/app/main.py b/app/main.py index 6623308..e237008 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index 577c8d9..53c674a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/permission_delegation.py b/app/models/permission_delegation.py new file mode 100644 index 0000000..8f60d7f --- /dev/null +++ b/app/models/permission_delegation.py @@ -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(), + ) diff --git a/app/models/permission_template.py b/app/models/permission_template.py new file mode 100644 index 0000000..a5c491d --- /dev/null +++ b/app/models/permission_template.py @@ -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(), + ) diff --git a/app/models/tenant.py b/app/models/tenant.py index 74fe916..817b2af 100644 --- a/app/models/tenant.py +++ b/app/models/tenant.py @@ -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", + ), + ) diff --git a/app/routes/delegations.py b/app/routes/delegations.py new file mode 100644 index 0000000..88f9273 --- /dev/null +++ b/app/routes/delegations.py @@ -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), + } diff --git a/app/routes/entity_permissions.py b/app/routes/entity_permissions.py index 9e671ba..8745a2e 100644 --- a/app/routes/entity_permissions.py +++ b/app/routes/entity_permissions.py @@ -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)) diff --git a/app/routes/permission_templates.py b/app/routes/permission_templates.py new file mode 100644 index 0000000..e7e09de --- /dev/null +++ b/app/routes/permission_templates.py @@ -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)) diff --git a/app/schemas/delegation.py b/app/schemas/delegation.py new file mode 100644 index 0000000..03facca --- /dev/null +++ b/app/schemas/delegation.py @@ -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 diff --git a/app/schemas/permission_template.py b/app/schemas/permission_template.py new file mode 100644 index 0000000..1dc758f --- /dev/null +++ b/app/schemas/permission_template.py @@ -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 diff --git a/app/services/bulk_permission_service.py b/app/services/bulk_permission_service.py new file mode 100644 index 0000000..9bfa6c1 --- /dev/null +++ b/app/services/bulk_permission_service.py @@ -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), + } diff --git a/app/services/delegation_service.py b/app/services/delegation_service.py new file mode 100644 index 0000000..685f3ae --- /dev/null +++ b/app/services/delegation_service.py @@ -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 diff --git a/app/services/entity_permission_service.py b/app/services/entity_permission_service.py index a7a0a0f..47c5575 100644 --- a/app/services/entity_permission_service.py +++ b/app/services/entity_permission_service.py @@ -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) diff --git a/app/services/permission_template_service.py b/app/services/permission_template_service.py new file mode 100644 index 0000000..e6ab01b --- /dev/null +++ b/app/services/permission_template_service.py @@ -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 diff --git a/frontend/src/api/policies.ts b/frontend/src/api/policies.ts new file mode 100644 index 0000000..1154d31 --- /dev/null +++ b/frontend/src/api/policies.ts @@ -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 { + return apiGet(`/policies/${entityType}`); +} + +/** + * Fetch a single policy by ID. + */ +export function fetchPolicy(entityType: string, policyId: string): Promise { + return apiGet(`/policies/${entityType}/${policyId}`); +} + +/** + * Create a new policy for the given entity type. + */ +export function createPolicy( + entityType: string, + payload: CreatePolicyPayload +): Promise { + return apiPost(`/policies/${entityType}`, payload); +} + +/** + * Update an existing policy. + */ +export function updatePolicy( + entityType: string, + policyId: string, + payload: UpdatePolicyPayload +): Promise { + return apiPut(`/policies/${entityType}/${policyId}`, payload); +} + +/** + * Delete a policy. + */ +export function deletePolicy(entityType: string, policyId: string): Promise { + return apiDelete(`/policies/${entityType}/${policyId}`); +} diff --git a/frontend/src/api/policyHooks.ts b/frontend/src/api/policyHooks.ts new file mode 100644 index 0000000..7b4f368 --- /dev/null +++ b/frontend/src/api/policyHooks.ts @@ -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) }); + }, + }); +} diff --git a/frontend/src/components/common/ABACRuleEditor.tsx b/frontend/src/components/common/ABACRuleEditor.tsx new file mode 100644 index 0000000..fab6ca0 --- /dev/null +++ b/frontend/src/components/common/ABACRuleEditor.tsx @@ -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 ( +
+
+ onChange({ ...condition, field: e.target.value })} + size="sm" + /> +
+
+ onChange({ ...condition, value: e.target.value })} + size="sm" + /> +
+ {canRemove && ( + + )} +
+ ); +} + +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 ( +
0 && 'ml-4 bg-secondary-50/50' + )}> + {/* Header: Logic toggle + actions */} +
+
+ + + {t('abac.groupConditions', 'Conditions')} + +
+
+ + {canRemove && onRemove && ( + + )} +
+
+ + {/* Conditions */} + {group.conditions.map((cond, idx) => ( + updateCondition(idx, c)} + onRemove={() => removeCondition(idx)} + canRemove={group.conditions.length > 1} + /> + ))} + + {/* Nested groups */} + {group.groups?.map((sub, idx) => ( + { + 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 */} + +
+ ); +} + +// ─── 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( + initial?.principal_type || 'user' + ); + const [principalId, setPrincipalId] = useState(initial?.principal_id || ''); + const [effect, setEffect] = useState<'allow' | 'deny'>(initial?.effect || 'allow'); + const [conditions, setConditions] = useState( + initial?.conditions || null + ); + const [priority, setPriority] = useState(initial?.priority ?? 0); + const [enabled, setEnabled] = useState(initial?.enabled ?? true); + const [error, setError] = useState(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 ( +
+ {/* Name */} + setName(e.target.value)} + placeholder={t('abac.policyNamePlaceholder', 'e.g. Vertrieb kann Kontakte sehen')} + required + /> + + {/* Principal Type + ID */} +
+ setPrincipalId(e.target.value)} + placeholder={t('abac.selectPrincipal', 'Select...')} + /> +
+ + {/* Effect + Priority */} +
+ setPriority(parseInt(e.target.value) || 0)} + /> +
+ + {/* Enabled */} + + + {/* Conditions Builder */} +
+
+ + {!conditions && ( + + )} +
+ + {conditions && ( +
+ setConditions(null)} + depth={0} + canRemove={true} + /> +
+ )} +
+ + {/* Preview */} + {previewText && ( +
+

+ {t('abac.preview', 'Preview')} +

+

{previewText}

+
+ )} + + {/* Error */} + {error && ( +

+ {error} +

+ )} + + {/* Actions */} +
+ + +
+
+ ); +} + +// ─── 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(null); + const [deletingPolicy, setDeletingPolicy] = useState(null); + const [expandedPolicies, setExpandedPolicies] = useState>(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 ( + + + + + } + > + {/* Loading state */} + {isLoading && ( +
+
+ + {t('abac.loading', 'Loading policies...')} + +
+ )} + + {/* Error state */} + {fetchError && !isLoading && ( +
+

+ {t('abac.fetchError', 'Failed to load policies')}: {String(fetchError)} +

+
+ )} + + {/* Empty state */} + {!isLoading && !fetchError && policies.length === 0 && !showForm && ( +
+ +

+ {t('abac.noPolicies', 'No policies defined for this entity type.')} +

+ +
+ )} + + {/* Policy list */} + {!isLoading && !fetchError && policies.length > 0 && !showForm && ( +
+ {policies.map((policy) => { + const isExpanded = expandedPolicies.has(policy.id); + return ( +
+ {/* Policy header */} +
toggleExpand(policy.id)} + > +
+ {isExpanded ? ( + + ) : ( + + )} +
+

+ {policy.name} +

+

+ {describePolicy(policy)} +

+
+
+
+ + {policy.effect === 'allow' ? ( + + ) : ( + + )} + {policy.effect} + + {!policy.enabled && ( + {t('abac.disabled', 'Disabled')} + )} + P{policy.priority} + + +
+
+ + {/* Expanded details */} + {isExpanded && ( +
+
+
+ + {t('abac.principal', 'Principal')}: + {' '} + + {policy.principal_name || policy.principal_id} + +
+
+ + {t('abac.principalType', 'Type')}: + {' '} + {policy.principal_type} +
+
+ + {t('abac.priority', 'Priority')}: + {' '} + {policy.priority} +
+
+ + {t('abac.enabled', 'Enabled')}: + {' '} + + {policy.enabled ? '✓' : '✗'} + +
+
+ {policy.conditions && ( +
+ + {t('abac.conditions', 'Conditions')}: + +

+ {describeConditionGroup(policy.conditions)} +

+
+ )} +
+ )} +
+ ); + })} +
+ )} + + {/* Create/Edit Form Modal */} + + + + + {/* Delete Confirmation */} + setDeletingPolicy(null)} + /> + + ); +}