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