From e0003b9384f97c18db30c47ec0af6b6a7e7a1eca Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 29 Jul 2026 02:42:16 +0200 Subject: [PATCH] sprint12+13: zentrale rechte settings page + ABAC engine backend (model, migration 0055, service, routes) --- alembic/versions/0055_entity_policies.py | 55 +++ app/main.py | 2 + app/models/__init__.py | 2 + app/models/entity_policy.py | 99 +++++ app/routes/entity_permissions.py | 12 + app/routes/policies.py | 95 +++++ app/schemas/policy.py | 44 +++ app/services/entity_permission_service.py | 15 + app/services/policy_service.py | 317 ++++++++++++++++ frontend/src/api/entityPermissionHooks.ts | 9 + frontend/src/api/entityPermissions.ts | 5 + frontend/src/pages/SettingsRechte.tsx | 418 ++++++++++++++++++++++ frontend/src/routes/index.tsx | 2 + 13 files changed, 1075 insertions(+) create mode 100644 alembic/versions/0055_entity_policies.py create mode 100644 app/models/entity_policy.py create mode 100644 app/routes/policies.py create mode 100644 app/schemas/policy.py create mode 100644 app/services/policy_service.py create mode 100644 frontend/src/pages/SettingsRechte.tsx diff --git a/alembic/versions/0055_entity_policies.py b/alembic/versions/0055_entity_policies.py new file mode 100644 index 0000000..eafa184 --- /dev/null +++ b/alembic/versions/0055_entity_policies.py @@ -0,0 +1,55 @@ +"""Create entity_policies table for ABAC engine. + +Revision ID: 0055 +Revises: 0054 +Create Date: 2026-07-29 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID + +revision = "0055" +down_revision = "0054" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "entity_policies", + 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("principal_type", sa.String(10), nullable=False), + sa.Column("principal_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("effect", sa.String(10), nullable=False, server_default=sa.text("'allow'")), + sa.Column("conditions", JSONB, nullable=True), + sa.Column("priority", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False), + sa.Column("enabled", sa.Boolean, nullable=False, server_default=sa.text("true")), + 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( + "principal_type IN ('user', 'group', 'role')", + name="ck_epol_principal_type", + ), + sa.CheckConstraint( + "effect IN ('allow', 'deny')", + name="ck_epol_effect", + ), + ) + op.create_index("ix_epol_entity_type", "entity_policies", ["entity_type"]) + op.create_index("ix_epol_principal", "entity_policies", ["principal_type", "principal_id"]) + op.create_index("ix_epol_tenant", "entity_policies", ["tenant_id"]) + op.create_index("ix_epol_priority", "entity_policies", ["priority"]) + op.create_index("ix_epol_enabled", "entity_policies", ["enabled"]) + + +def downgrade() -> None: + op.drop_index("ix_epol_enabled", table_name="entity_policies") + op.drop_index("ix_epol_priority", table_name="entity_policies") + op.drop_index("ix_epol_tenant", table_name="entity_policies") + op.drop_index("ix_epol_principal", table_name="entity_policies") + op.drop_index("ix_epol_entity_type", table_name="entity_policies") + op.drop_table("entity_policies") diff --git a/app/main.py b/app/main.py index 33c5e27..6623308 100644 --- a/app/main.py +++ b/app/main.py @@ -62,6 +62,7 @@ from app.routes import ( webhooks, backups, owner_transfer, + policies, ) @@ -403,6 +404,7 @@ def create_app() -> FastAPI: app.include_router(saved_filters.router) app.include_router(saved_views.router) app.include_router(webhooks.router) + app.include_router(policies.router) app.include_router(errors.router) # ── Register plugin routes for all built-in plugins ── diff --git a/app/models/__init__.py b/app/models/__init__.py index c0ecf5a..577c8d9 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.contact_folder import ContactFolder from app.models.contact_folder_permission import ContactFolderPermission from app.models.contact_merge import ContactMergeHistory from app.models.entity_permission import EntityPermission +from app.models.entity_policy import EntityPolicy from app.models.owned_mixin import OwnedMixin from app.models.entity_history import EntityHistory from app.models.currency import Currency @@ -51,6 +52,7 @@ __all__ = [ "ContactFolderPermission", "ContactMergeHistory", "EntityPermission", + "EntityPolicy", "OwnedMixin", "EntityHistory", "Currency", diff --git a/app/models/entity_policy.py b/app/models/entity_policy.py new file mode 100644 index 0000000..cbe2237 --- /dev/null +++ b/app/models/entity_policy.py @@ -0,0 +1,99 @@ +"""ABAC entity policy model — attribute-based access control policies. + +Each policy defines a rule for a specific entity type: +- allow policies: at least one must match for access +- deny policies: if any matches, access is denied (deny takes precedence) + +Conditions use JSONB with format: +{ + "operator": "AND" | "OR", + "rules": [ + {"field": "status", "op": "eq", "value": "active"}, + {"field": "amount", "op": "gte", "value": 1000}, + {"field": "tags", "op": "contains", "value": "vip"} + ] +} +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + Index, + Integer, + String, + Text, + 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 EntityPolicy(Base, TenantMixin): + """ABAC policy entry for any entity type in the system. + + entity_type examples: 'contact', 'dms_file', 'mailbox', 'calendar_event', + 'task', 'workflow', 'contact_folder', etc. + + principal_type: 'user', 'group', 'role' + + effect: 'allow' | 'deny' + - allow: grants access if conditions match + - deny: blocks access if conditions match (deny takes precedence over allow) + + conditions: JSONB with operator (AND/OR) and rules array + priority: higher priority policies are evaluated first + """ + + __tablename__ = "entity_policies" + __table_args__ = ( + CheckConstraint( + "principal_type IN ('user', 'group', 'role')", + name="ck_epol_principal_type", + ), + CheckConstraint( + "effect IN ('allow', 'deny')", + name="ck_epol_effect", + ), + Index("ix_epol_entity_type", "entity_type"), + Index("ix_epol_principal", "principal_type", "principal_id"), + Index("ix_epol_tenant", "tenant_id"), + Index("ix_epol_priority", "priority"), + Index("ix_epol_enabled", "enabled"), + ) + + 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) + principal_type: Mapped[str] = mapped_column(String(10), nullable=False) + principal_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), nullable=False + ) + effect: Mapped[str] = mapped_column( + String(10), nullable=False, default="allow" + ) + conditions: Mapped[dict | None] = mapped_column( + JSONB, nullable=True, default=None + ) + priority: Mapped[int] = mapped_column( + Integer, nullable=False, default=0 + ) + enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + onupdate=func.now(), + ) diff --git a/app/routes/entity_permissions.py b/app/routes/entity_permissions.py index 44a6d23..9e671ba 100644 --- a/app/routes/entity_permissions.py +++ b/app/routes/entity_permissions.py @@ -150,6 +150,18 @@ async def get_entity_access( } +@router.get("/all") +@require_permission("settings:read") +async def list_all_permissions( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List ALL permission entries for the current tenant.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + items = await entity_permission_service.list_all_permissions(db, tenant_id) + return {"items": items, "total": len(items)} + + @router.get("/registry") async def list_entity_registry( current_user: dict = Depends(get_current_user), diff --git a/app/routes/policies.py b/app/routes/policies.py new file mode 100644 index 0000000..9e88c56 --- /dev/null +++ b/app/routes/policies.py @@ -0,0 +1,95 @@ +"""ABAC policy routes — attribute-based access control management.""" + +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.policy import PolicyCreate, PolicyUpdate +from app.services import policy_service + +router = APIRouter(prefix="/api/v1/policies", tags=["policies"]) + + +@router.get("/{entity_type}") +async def list_policies( + entity_type: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List all ABAC policies for a given entity type.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + items = await policy_service.list_policies(db, tenant_id, entity_type) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return {"items": items, "total": len(items)} + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_policy( + body: PolicyCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new ABAC policy.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + return await policy_service.create_policy( + db, + tenant_id, + name=body.name, + entity_type=body.entity_type, + principal_type=body.principal_type, + principal_id=body.principal_id, + effect=body.effect, + conditions=body.conditions, + priority=body.priority, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/{policy_id}") +async def update_policy( + policy_id: str, + body: PolicyUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update an existing ABAC policy.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + return await policy_service.update_policy( + db, + tenant_id, + policy_id, + name=body.name, + entity_type=body.entity_type, + principal_type=body.principal_type, + principal_id=body.principal_id, + effect=body.effect, + conditions=body.conditions, + priority=body.priority, + enabled=body.enabled, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_policy( + policy_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete an ABAC policy.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + await policy_service.delete_policy(db, tenant_id, policy_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/app/schemas/policy.py b/app/schemas/policy.py new file mode 100644 index 0000000..f4fe090 --- /dev/null +++ b/app/schemas/policy.py @@ -0,0 +1,44 @@ +"""Schemas for ABAC entity policies.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class PolicyCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + entity_type: str = Field(..., min_length=1, max_length=50) + principal_type: str = Field(..., pattern="^(user|group|role)$") + principal_id: str + effect: str = Field("allow", pattern="^(allow|deny)$") + conditions: dict[str, Any] | None = None + priority: int = 0 + + +class PolicyUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=200) + entity_type: str | None = Field(None, min_length=1, max_length=50) + principal_type: str | None = Field(None, pattern="^(user|group|role)$") + principal_id: str | None = None + effect: str | None = Field(None, pattern="^(allow|deny)$") + conditions: dict[str, Any] | None = None + priority: int | None = None + enabled: bool | None = None + + +class PolicyResponse(BaseModel): + id: str + name: str + entity_type: str + principal_type: str + principal_id: str + effect: str + conditions: dict[str, Any] | None = None + priority: int + tenant_id: str + enabled: bool + created_at: str | None = None + updated_at: str | None = None diff --git a/app/services/entity_permission_service.py b/app/services/entity_permission_service.py index 52dc682..a7a0a0f 100644 --- a/app/services/entity_permission_service.py +++ b/app/services/entity_permission_service.py @@ -700,6 +700,21 @@ async def check_entity_access( return _rank(access) >= _rank(required_level) +async def list_all_permissions( + db: AsyncSession, tenant_id: uuid.UUID +) -> list[dict]: + """List ALL permission entries for a tenant (global view).""" + result = await db.execute( + select(EntityPermission) + .where(EntityPermission.tenant_id == tenant_id) + .order_by(EntityPermission.entity_type, EntityPermission.created_at) + ) + perms = result.scalars().all() + + names = await _load_principal_names(db, perms) + return [_serialize_permission(p, names.get(p.principal_id)) for p in perms] + + async def cleanup_expired_permissions(db: AsyncSession) -> int: """Delete all expired permission entries. Returns count deleted.""" now = datetime.now(UTC) diff --git a/app/services/policy_service.py b/app/services/policy_service.py new file mode 100644 index 0000000..a781f2f --- /dev/null +++ b/app/services/policy_service.py @@ -0,0 +1,317 @@ +"""ABAC policy service — attribute-based access control for entities. + +This service handles: +- CRUD for entity_policies +- apply_policy_filter: translates JSONB conditions into SQLAlchemy filters +- build_sql_condition: recursive JSONB → SQLAlchemy expression translation + +Policy evaluation: +- allow policies: OR-joined (at least one must match for access) +- deny policies: NOT (none may match — deny takes precedence) +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy import and_, not_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute + +from app.models.entity_policy import EntityPolicy + +logger = logging.getLogger(__name__) + +# Supported operators for condition translation +_SUPPORTED_OPS = { + "eq", "neq", "in", "not_in", "gt", "gte", "lt", "lte", + "contains", "starts_with", "is_null", "is_not_null", +} + + +def _serialize_policy(p: EntityPolicy) -> dict: + return { + "id": str(p.id), + "name": p.name, + "entity_type": p.entity_type, + "principal_type": p.principal_type, + "principal_id": str(p.principal_id), + "effect": p.effect, + "conditions": p.conditions, + "priority": p.priority, + "tenant_id": str(p.tenant_id), + "enabled": p.enabled, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + } + + +def build_sql_condition( + conditions: dict[str, Any], + model: type, +) -> Any: + """Recursively translate a JSONB conditions block into a SQLAlchemy filter expression. + + Conditions format: + { + "operator": "AND" | "OR", + "rules": [ + {"field": "status", "op": "eq", "value": "active"}, + {"field": "amount", "op": "gte", "value": 1000}, + ... + ] + } + + Nested conditions are supported via rules that contain a nested conditions block. + """ + if not conditions or "operator" not in conditions or "rules" not in conditions: + return None + + operator = conditions["operator"] + rules = conditions["rules"] + + if not rules: + return None + + clauses: list[Any] = [] + + for rule in rules: + # Nested conditions block + if "operator" in rule and "rules" in rule: + nested = build_sql_condition(rule, model) + if nested is not None: + clauses.append(nested) + continue + + field_name = rule.get("field") + op = rule.get("op") + value = rule.get("value") + + if not field_name or not op: + continue + + if op not in _SUPPORTED_OPS: + logger.warning(f"Unsupported condition operator: {op}") + continue + + # Get the model attribute + attr: InstrumentedAttribute | None = getattr(model, field_name, None) + if attr is None: + logger.warning(f"Unknown field '{field_name}' on {model.__name__}") + continue + + if op == "eq": + clauses.append(attr == value) + elif op == "neq": + clauses.append(attr != value) + elif op == "in": + if isinstance(value, list): + clauses.append(attr.in_(value)) + else: + clauses.append(attr == value) + elif op == "not_in": + if isinstance(value, list): + clauses.append(not_(attr.in_(value))) + else: + clauses.append(attr != value) + elif op == "gt": + clauses.append(attr > value) + elif op == "gte": + clauses.append(attr >= value) + elif op == "lt": + clauses.append(attr < value) + elif op == "lte": + clauses.append(attr <= value) + elif op == "contains": + clauses.append(attr.contains(value)) + elif op == "starts_with": + clauses.append(attr.startswith(value)) + elif op == "is_null": + clauses.append(attr.is_(None)) + elif op == "is_not_null": + clauses.append(attr.isnot(None)) + + if not clauses: + return None + + if operator == "AND": + return and_(*clauses) + else: # OR + return or_(*clauses) + + +async def list_policies( + db: AsyncSession, + tenant_id: uuid.UUID, + entity_type: str | None = None, +) -> list[dict]: + """List all policies for a tenant, optionally filtered by entity_type.""" + query = select(EntityPolicy).where(EntityPolicy.tenant_id == tenant_id) + if entity_type: + query = query.where(EntityPolicy.entity_type == entity_type) + query = query.order_by(EntityPolicy.priority.desc(), EntityPolicy.created_at) + result = await db.execute(query) + policies = result.scalars().all() + return [_serialize_policy(p) for p in policies] + + +async def create_policy( + db: AsyncSession, + tenant_id: uuid.UUID, + name: str, + entity_type: str, + principal_type: str, + principal_id: str, + effect: str = "allow", + conditions: dict[str, Any] | None = None, + priority: int = 0, +) -> dict: + """Create a new ABAC policy.""" + policy = EntityPolicy( + tenant_id=tenant_id, + name=name, + entity_type=entity_type, + principal_type=principal_type, + principal_id=uuid.UUID(principal_id), + effect=effect, + conditions=conditions, + priority=priority, + ) + db.add(policy) + await db.commit() + await db.refresh(policy) + return _serialize_policy(policy) + + +async def update_policy( + db: AsyncSession, + tenant_id: uuid.UUID, + policy_id: str, + **kwargs: Any, +) -> dict: + """Update an existing ABAC policy.""" + result = await db.execute( + select(EntityPolicy) + .where(EntityPolicy.id == uuid.UUID(policy_id)) + .where(EntityPolicy.tenant_id == tenant_id) + ) + policy = result.scalar_one_or_none() + if policy is None: + raise ValueError(f"Policy {policy_id} not found") + + # Update only provided fields + updatable_fields = { + "name", "entity_type", "principal_type", "principal_id", + "effect", "conditions", "priority", "enabled", + } + for key, value in kwargs.items(): + if key in updatable_fields and value is not None: + if key == "principal_id": + setattr(policy, key, uuid.UUID(value)) + else: + setattr(policy, key, value) + + await db.commit() + await db.refresh(policy) + return _serialize_policy(policy) + + +async def delete_policy( + db: AsyncSession, + tenant_id: uuid.UUID, + policy_id: str, +) -> None: + """Delete an ABAC policy.""" + result = await db.execute( + select(EntityPolicy) + .where(EntityPolicy.id == uuid.UUID(policy_id)) + .where(EntityPolicy.tenant_id == tenant_id) + ) + policy = result.scalar_one_or_none() + if policy is None: + raise ValueError(f"Policy {policy_id} not found") + await db.delete(policy) + await db.commit() + + +async def apply_policy_filter( + db: AsyncSession, + query: Any, + entity_type: str, + user_id: uuid.UUID, + tenant_id: uuid.UUID, + model: type, +) -> Any: + """Apply ABAC policy filters to an existing SQLAlchemy query. + + Logic: + 1. Load all enabled policies for this entity_type + tenant that match the user + (via principal_type='user' with principal_id=user_id, or via group/role) + 2. Separate into allow and deny policies + 3. For allow policies: OR-join their conditions (at least one must match) + 4. For deny policies: NOT (none may match — deny takes precedence) + 5. Apply: query = query.where(allow_filter & deny_filter) + + If no policies match, the query is returned unchanged (no ABAC restriction). + """ + # Load policies for this user on this entity_type + # For simplicity, we load user-direct policies + group/role policies + # In a full implementation, we'd also resolve group memberships + stmt = select(EntityPolicy).where( + EntityPolicy.tenant_id == tenant_id, + EntityPolicy.entity_type == entity_type, + EntityPolicy.enabled == True, # noqa: E712 + ).where( + or_( + and_( + EntityPolicy.principal_type == "user", + EntityPolicy.principal_id == user_id, + ), + # Group/role policies would need membership resolution + # For now, we also match group/role by principal_id + EntityPolicy.principal_id == user_id, + ) + ) + result = await db.execute(stmt) + policies = result.scalars().all() + + if not policies: + return query # No ABAC restriction + + allow_clauses: list[Any] = [] + deny_clauses: list[Any] = [] + + for policy in policies: + if not policy.conditions: + # Policy without conditions matches everything + if policy.effect == "allow": + allow_clauses.append(True) + else: + deny_clauses.append(True) + continue + + condition = build_sql_condition(policy.conditions, model) + if condition is None: + continue + + if policy.effect == "allow": + allow_clauses.append(condition) + else: + deny_clauses.append(condition) + + filters: list[Any] = [] + + if allow_clauses: + # At least one allow policy must match + filters.append(or_(*allow_clauses)) + + if deny_clauses: + # No deny policy may match + filters.append(not_(or_(*deny_clauses))) + + if filters: + query = query.where(and_(*filters)) + + return query diff --git a/frontend/src/api/entityPermissionHooks.ts b/frontend/src/api/entityPermissionHooks.ts index fc49a11..84e7ac4 100644 --- a/frontend/src/api/entityPermissionHooks.ts +++ b/frontend/src/api/entityPermissionHooks.ts @@ -10,6 +10,7 @@ import { deleteEntityPermission, fetchEntityAccess, fetchEntityRegistry, + fetchAllEntityPermissions, type CreateEntityPermissionPayload, type UpdateEntityPermissionPayload, } from './entityPermissions'; @@ -123,6 +124,14 @@ export function useEntityAccess(entityType: string, entityId: string) { /** * Fetch the registry of all supported entity types. */ +export function useAllEntityPermissions() { + return useQuery({ + queryKey: ['allEntityPermissions'], + queryFn: () => fetchAllEntityPermissions(), + }); +} + + export function useEntityRegistry() { return useQuery({ queryKey: entityPermissionKeys.registry(), diff --git a/frontend/src/api/entityPermissions.ts b/frontend/src/api/entityPermissions.ts index 8501984..2c59dc1 100644 --- a/frontend/src/api/entityPermissions.ts +++ b/frontend/src/api/entityPermissions.ts @@ -133,6 +133,11 @@ export function fetchEntityAccess( /** * Fetch the registry of all supported entity types. */ +export function fetchAllEntityPermissions(): Promise<{ items: EntityPermission[]; total: number }> { + return apiGet<{ items: EntityPermission[]; total: number }>('/permissions/all'); +} + + export function fetchEntityRegistry(): Promise { return apiGet('/permissions/registry'); } diff --git a/frontend/src/pages/SettingsRechte.tsx b/frontend/src/pages/SettingsRechte.tsx new file mode 100644 index 0000000..3474bf2 --- /dev/null +++ b/frontend/src/pages/SettingsRechte.tsx @@ -0,0 +1,418 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SettingsRolesPage } from './SettingsRoles'; +import { SettingsGroupsPage } from './SettingsGroups'; +import { useAllEntityPermissions, useEntityRegistry } from '@/api/entityPermissionHooks'; +import { useAuditLog, type AuditLogEntry } from '@/api/audit'; +import { usePermission } from '@/hooks/usePermission'; +import { Table, type TableColumn } from '@/components/ui/Table'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Select } from '@/components/ui/Select'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { useToast } from '@/components/ui/Toast'; +import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { apiDelete } from '@/api/client'; +import { Search, Trash2, Shield, Users, Share2, History } from 'lucide-react'; +import clsx from 'clsx'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +interface EntityPermission { + id: string; + entity_type: string; + entity_id: string; + principal_type: string; + principal_id: string; + principal_name: string | null; + permission_level: string; + expires_at: string | null; + created_by: string | null; + created_at: string | null; +} + +interface EntityRegistryEntry { + entity_type: string; + display_name: string; + description: string | null; +} + +// ─── Permission Level Badge ──────────────────────────────────────────────── + +function PermissionLevelBadge({ level }: { level: string }) { + const colors: Record = { + read: 'bg-blue-100 text-blue-800', + write: 'bg-yellow-100 text-yellow-800', + admin: 'bg-purple-100 text-purple-800', + delete: 'bg-red-100 text-red-800', + owner: 'bg-green-100 text-green-800', + }; + const labels: Record = { + read: 'Lesen', + write: 'Schreiben', + admin: 'Admin', + delete: 'Löschen', + owner: 'Besitzer', + }; + return ( + + {labels[level] || level} + + ); +} + +// ─── Principal Type Badge ──────────────────────────────────────────────────── + +function PrincipalTypeBadge({ type }: { type: string }) { + const colors: Record = { + user: 'bg-cyan-100 text-cyan-800', + group: 'bg-indigo-100 text-indigo-800', + role: 'bg-amber-100 text-amber-800', + }; + const labels: Record = { + user: 'Benutzer', + group: 'Gruppe', + role: 'Rolle', + }; + return ( + + {labels[type] || type} + + ); +} + +// ─── Freigaben Tab ───────────────────────────────────────────────────────── + +function FreigabenTab() { + const { t } = useTranslation(); + const toast = useToast(); + const { hasPermission } = usePermission(); + const { data: permsData, isLoading: permsLoading, refetch: refetchPerms } = useAllEntityPermissions(); + const { data: registryData, isLoading: registryLoading } = useEntityRegistry(); + const [filterEntityType, setFilterEntityType] = useState(''); + const [filterPrincipal, setFilterPrincipal] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + const entityTypeMap = useMemo(() => { + const map = new Map(); + if (registryData) { + for (const entry of registryData) { + map.set(entry.entity_type, entry.display_name); + } + } + return map; + }, [registryData]); + + const entityTypeOptions = useMemo(() => { + const types = new Set(); + if (permsData?.items) { + for (const p of permsData.items) { + types.add(p.entity_type); + } + } + return Array.from(types).sort().map((t) => ({ + value: t, + label: entityTypeMap.get(t) || t, + })); + }, [permsData, entityTypeMap]); + + const filteredPerms = useMemo(() => { + if (!permsData?.items) return []; + let items = permsData.items; + if (filterEntityType) { + items = items.filter((p) => p.entity_type === filterEntityType); + } + if (filterPrincipal) { + const q = filterPrincipal.toLowerCase(); + items = items.filter( + (p) => + (p.principal_name && p.principal_name.toLowerCase().includes(q)) || + p.principal_id.toLowerCase().includes(q) + ); + } + return items; + }, [permsData, filterEntityType, filterPrincipal]); + + const handleDelete = async () => { + if (!confirmDelete) return; + setDeleting(true); + try { + await apiDelete(`/permissions/${confirmDelete.entity_type}/${confirmDelete.entity_id}/${confirmDelete.id}`); + toast.success('Berechtigung gelöscht'); + setConfirmDelete(null); + refetchPerms(); + } catch (err: any) { + toast.error(err.message || 'Fehler beim Löschen'); + } finally { + setDeleting(false); + } + }; + + const columns: TableColumn[] = [ + { + key: 'entity_type', + header: 'Entity-Typ', + render: (row) => entityTypeMap.get(row.entity_type) || row.entity_type, + }, + { key: 'entity_id', header: 'Entity-ID', render: (row) => row.entity_id.slice(0, 8) + '…' }, + { + key: 'principal_type', + header: 'Principal', + render: (row) => ( +
+ + {row.principal_name || row.principal_id.slice(0, 8) + '…'} +
+ ), + }, + { + key: 'permission_level', + header: 'Berechtigungsstufe', + render: (row) => , + }, + { + key: 'expires_at', + header: 'Läuft ab', + render: (row) => { + if (!row.expires_at) return '—'; + const expires = new Date(row.expires_at); + const now = new Date(); + const isExpired = expires < now; + return ( + + {expires.toLocaleDateString('de-DE')} + {isExpired && ' (abgelaufen)'} + + ); + }, + }, + { + key: 'actions', + header: '', + render: (row) => + hasPermission('settings:write') ? ( + + ) : null, + }, + ]; + + if (permsLoading || registryLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ +
+
+ setFilterPrincipal(e.target.value)} + /> +
+
+ + row.id} + emptyMessage="Keine Freigaben gefunden" + /> + + + {confirmDelete && ( + setConfirmDelete(null)} + onConfirm={handleDelete} + title="Berechtigung löschen" + message={`Soll die Berechtigung für ${entityTypeMap.get(confirmDelete.entity_type) || confirmDelete.entity_type} wirklich gelöscht werden?`} + confirmLabel={deleting ? 'Wird gelöscht...' : 'Löschen'} + variant="danger" + /> + )} + + ); +} + +// ─── Audit Tab ───────────────────────────────────────────────────────────── + +function AuditTab() { + const { t } = useTranslation(); + const [page, setPage] = useState(1); + const pageSize = 25; + const { data, isLoading, isError, error } = useAuditLog(page, pageSize, { + action: 'permission_grant,permission_revoke,permission_update', + }); + + const columns: TableColumn = [ + { + key: 'timestamp', + header: 'Zeitpunkt', + render: (row) => new Date(row.timestamp).toLocaleString('de-DE'), + }, + { key: 'user', header: 'Benutzer' }, + { + key: 'action', + header: 'Aktion', + render: (row) => { + const labels: Record = { + permission_grant: 'Freigabe erteilt', + permission_revoke: 'Freigabe entzogen', + permission_update: 'Freigabe geändert', + }; + const colors: Record = { + permission_grant: 'bg-green-100 text-green-800', + permission_revoke: 'bg-red-100 text-red-800', + permission_update: 'bg-yellow-100 text-yellow-800', + }; + return ( + + {labels[row.action] || row.action} + + ); + }, + }, + { key: 'entity', header: 'Entity' }, + { + key: 'details', + header: 'Details', + render: (row) => { + if (!row.details) return '—'; + try { + const parsed = JSON.parse(row.details); + return ( + + {Object.entries(parsed).map(([k, v]) => `${k}: ${v}`).join(', ')} + + ); + } catch { + return {row.details}; + } + }, + }, + ]; + + const totalPages = data?.total ? Math.ceil(data.total / pageSize) : 1; + + return ( +
+ +
row.id} + loading={isLoading} + emptyMessage="Keine Audit-Einträge gefunden" + /> + + {totalPages > 1 && ( +
+ + Seite {page} von {totalPages} + +
+ + +
+
+ )} + + + ); +} + +// ─── Main Page ────────────────────────────────────────────────────────────── + +export function SettingsRechtePage() { + const { t } = useTranslation(); + const { hasPermission } = usePermission(); + const [activeTab, setActiveTab] = useState('rollen'); + + const tabs = [ + { key: 'rollen', label: 'Rollen', icon: Shield, permission: 'roles:read' }, + { key: 'gruppen', label: 'Gruppen', icon: Users, permission: 'groups:read' }, + { key: 'freigaben', label: 'Freigaben', icon: Share2, permission: 'settings:read' }, + { key: 'audit', label: 'Audit', icon: History, permission: 'audit:read' }, + ].filter((tab) => hasPermission(tab.permission)); + + return ( +
+

Rechteverwaltung

+ +
+ +
+ +
+ {activeTab === 'rollen' && } + {activeTab === 'gruppen' && } + {activeTab === 'freigaben' && } + {activeTab === 'audit' && } +
+
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 35d6226..816aafd 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -61,6 +61,7 @@ const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage }))); const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage }))); const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage }))); +const SettingsRechtePage = React.lazy(() => import('@/pages/SettingsRechte').then(m => ({ default: m.SettingsRechtePage }))); /** Centered spinner fallback for lazy-loaded routes */ function PageLoader() { @@ -166,6 +167,7 @@ const router = createBrowserRouter([ { path: 'custom-fields', element: withSuspense() }, { path: 'webhooks', element: withSuspense() }, { path: 'backup', element: withSuspense() }, + { path: 'rechte', element: {withSuspense()} }, { path: '*', element: }, ], },