sprint12+13: zentrale rechte settings page + ABAC engine backend (model, migration 0055, service, routes)
This commit is contained in:
@@ -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")
|
||||||
@@ -62,6 +62,7 @@ from app.routes import (
|
|||||||
webhooks,
|
webhooks,
|
||||||
backups,
|
backups,
|
||||||
owner_transfer,
|
owner_transfer,
|
||||||
|
policies,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +404,7 @@ 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(policies.router)
|
||||||
app.include_router(errors.router)
|
app.include_router(errors.router)
|
||||||
|
|
||||||
# ── Register plugin routes for all built-in plugins ──
|
# ── Register plugin routes for all built-in plugins ──
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.contact_folder import ContactFolder
|
|||||||
from app.models.contact_folder_permission import ContactFolderPermission
|
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.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
|
||||||
@@ -51,6 +52,7 @@ __all__ = [
|
|||||||
"ContactFolderPermission",
|
"ContactFolderPermission",
|
||||||
"ContactMergeHistory",
|
"ContactMergeHistory",
|
||||||
"EntityPermission",
|
"EntityPermission",
|
||||||
|
"EntityPolicy",
|
||||||
"OwnedMixin",
|
"OwnedMixin",
|
||||||
"EntityHistory",
|
"EntityHistory",
|
||||||
"Currency",
|
"Currency",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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")
|
@router.get("/registry")
|
||||||
async def list_entity_registry(
|
async def list_entity_registry(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -700,6 +700,21 @@ async def check_entity_access(
|
|||||||
return _rank(access) >= _rank(required_level)
|
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:
|
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,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
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
deleteEntityPermission,
|
deleteEntityPermission,
|
||||||
fetchEntityAccess,
|
fetchEntityAccess,
|
||||||
fetchEntityRegistry,
|
fetchEntityRegistry,
|
||||||
|
fetchAllEntityPermissions,
|
||||||
type CreateEntityPermissionPayload,
|
type CreateEntityPermissionPayload,
|
||||||
type UpdateEntityPermissionPayload,
|
type UpdateEntityPermissionPayload,
|
||||||
} from './entityPermissions';
|
} from './entityPermissions';
|
||||||
@@ -123,6 +124,14 @@ export function useEntityAccess(entityType: string, entityId: string) {
|
|||||||
/**
|
/**
|
||||||
* Fetch the registry of all supported entity types.
|
* Fetch the registry of all supported entity types.
|
||||||
*/
|
*/
|
||||||
|
export function useAllEntityPermissions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['allEntityPermissions'],
|
||||||
|
queryFn: () => fetchAllEntityPermissions(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useEntityRegistry() {
|
export function useEntityRegistry() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: entityPermissionKeys.registry(),
|
queryKey: entityPermissionKeys.registry(),
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export function fetchEntityAccess(
|
|||||||
/**
|
/**
|
||||||
* Fetch the registry of all supported entity types.
|
* 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<EntityRegistryEntry[]> {
|
export function fetchEntityRegistry(): Promise<EntityRegistryEntry[]> {
|
||||||
return apiGet<EntityRegistryEntry[]>('/permissions/registry');
|
return apiGet<EntityRegistryEntry[]>('/permissions/registry');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
read: 'Lesen',
|
||||||
|
write: 'Schreiben',
|
||||||
|
admin: 'Admin',
|
||||||
|
delete: 'Löschen',
|
||||||
|
owner: 'Besitzer',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge className={colors[level] || 'bg-secondary-100 text-secondary-800'}>
|
||||||
|
{labels[level] || level}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Principal Type Badge ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PrincipalTypeBadge({ type }: { type: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
user: 'bg-cyan-100 text-cyan-800',
|
||||||
|
group: 'bg-indigo-100 text-indigo-800',
|
||||||
|
role: 'bg-amber-100 text-amber-800',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
user: 'Benutzer',
|
||||||
|
group: 'Gruppe',
|
||||||
|
role: 'Rolle',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge className={colors[type] || 'bg-secondary-100 text-secondary-800'}>
|
||||||
|
{labels[type] || type}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<EntityPermission | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const entityTypeMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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<string>();
|
||||||
|
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<EntityPermission>[] = [
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PrincipalTypeBadge type={row.principal_type} />
|
||||||
|
<span>{row.principal_name || row.principal_id.slice(0, 8) + '…'}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permission_level',
|
||||||
|
header: 'Berechtigungsstufe',
|
||||||
|
render: (row) => <PermissionLevelBadge level={row.permission_level} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<span className={isExpired ? 'text-danger-600' : ''}>
|
||||||
|
{expires.toLocaleDateString('de-DE')}
|
||||||
|
{isExpired && ' (abgelaufen)'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (row) =>
|
||||||
|
hasPermission('settings:write') ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(row)}
|
||||||
|
className="p-1 rounded hover:bg-danger-50 text-danger-600"
|
||||||
|
aria-label="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (permsLoading || registryLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card title="Freigaben Übersicht">
|
||||||
|
<div className="flex gap-4 mb-4">
|
||||||
|
<div className="w-64">
|
||||||
|
<Select
|
||||||
|
label="Entity-Typ filtern"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Alle' },
|
||||||
|
...entityTypeOptions,
|
||||||
|
]}
|
||||||
|
value={filterEntityType}
|
||||||
|
onChange={(e) => setFilterEntityType(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-64">
|
||||||
|
<Input
|
||||||
|
label="Nach Principal suchen"
|
||||||
|
placeholder="Name oder ID..."
|
||||||
|
value={filterPrincipal}
|
||||||
|
onChange={(e) => setFilterPrincipal(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={filteredPerms}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
emptyMessage="Keine Freigaben gefunden"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onClose={() => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<AuditLogEntry> = [
|
||||||
|
{
|
||||||
|
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<string, string> = {
|
||||||
|
permission_grant: 'Freigabe erteilt',
|
||||||
|
permission_revoke: 'Freigabe entzogen',
|
||||||
|
permission_update: 'Freigabe geändert',
|
||||||
|
};
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Badge className={colors[row.action] || 'bg-secondary-100 text-secondary-800'}>
|
||||||
|
{labels[row.action] || row.action}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'entity', header: 'Entity' },
|
||||||
|
{
|
||||||
|
key: 'details',
|
||||||
|
header: 'Details',
|
||||||
|
render: (row) => {
|
||||||
|
if (!row.details) return '—';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.details);
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-mono">
|
||||||
|
{Object.entries(parsed).map(([k, v]) => `${k}: ${v}`).join(', ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return <span className="text-xs">{row.details}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalPages = data?.total ? Math.ceil(data.total / pageSize) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card title="Audit-Log für Berechtigungen">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={data?.items || []}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
loading={isLoading}
|
||||||
|
emptyMessage="Keine Audit-Einträge gefunden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<span className="text-sm text-secondary-600">
|
||||||
|
Seite {page} von {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="space-y-6" data-testid="settings-rechte-page">
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">Rechteverwaltung</h1>
|
||||||
|
|
||||||
|
<div className="border-b border-secondary-200">
|
||||||
|
<nav className="flex gap-4" role="tablist" aria-label="Rechteverwaltung Tabs">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary-500 text-primary-700'
|
||||||
|
: 'border-transparent text-secondary-600 hover:text-secondary-900 hover:border-secondary-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tabpanel">
|
||||||
|
{activeTab === 'rollen' && <SettingsRolesPage />}
|
||||||
|
{activeTab === 'gruppen' && <SettingsGroupsPage />}
|
||||||
|
{activeTab === 'freigaben' && <FreigabenTab />}
|
||||||
|
{activeTab === 'audit' && <AuditTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
|
||||||
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
|
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 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 */
|
/** Centered spinner fallback for lazy-loaded routes */
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
@@ -166,6 +167,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
|
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
|
||||||
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
|
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
|
||||||
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
|
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
|
||||||
|
{ path: 'rechte', element: <PermissionRoute permission="settings:read">{withSuspense(<SettingsRechtePage />)}</PermissionRoute> },
|
||||||
{ path: '*', element: <PluginRouteRenderer /> },
|
{ path: '*', element: <PluginRouteRenderer /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user