feat(policies): UI fuer ABAC-Richtlinien mit Conditions-Builder — Modul 10/16 des UI-Backlogs

This commit is contained in:
Agent Zero
2026-09-14 23:41:55 +02:00
parent abdf9e7d83
commit d734923636
7 changed files with 1127 additions and 0 deletions
+1
View File
@@ -51,6 +51,7 @@ export function SettingsPage() {
{ to: '/settings/api-tokens', label: 'API-Tokens', icon: '\ud83d\udd11' },
{ to: '/settings/tenants', label: 'Mandanten', icon: '\ud83c\udfe2' },
{ to: '/settings/permission-templates', label: 'Berechtigungs-Vorlagen', icon: '\ud83d\udd11' },
{ to: '/settings/policies', label: 'ABAC-Richtlinien', icon: '\ud83d\udee1\ufe0f' },
];
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
+630
View File
@@ -0,0 +1,630 @@
/**
* Policies settings page — ABAC entity policies (attribute-based access
* control) management (UI-Backlog module 10/16).
*
* Backend: /api/v1/policies (list per entity type, create, update, delete).
* Permissions: policies:read (list) / policies:write (create, update, delete).
*
* Policy evaluation: allow policies OR-joined (one must match), deny takes
* precedence. Conditions: { operator: AND|OR, rules: [{ field, op, value }] }
* with a per-entity field whitelist (ABAC_ALLOWED_FIELDS, mirrored from
* the backend service).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import {
ShieldCheck,
ShieldX,
Plus,
Pencil,
Trash2,
Inbox,
AlertTriangle,
} from 'lucide-react';
import {
usePolicies,
useCreatePolicy,
useUpdatePolicy,
useDeletePolicy,
POLICY_OPS,
ABAC_ALLOWED_FIELDS,
NULL_OPS,
type Policy,
type PrincipalType,
type PolicyEffect,
type PolicyRule,
type ConditionOperator,
} from '@/api/policies';
import { useUsers, type UserResponse } from '@/api/users';
import { useGroups, type Group } from '@/api/groups';
import { useRoles, type Role } from '@/api/roles';
import { usePermission } from '@/hooks/usePermission';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input';
const ENTITY_TYPES = Object.keys(ABAC_ALLOWED_FIELDS);
const EFFECT_BADGE: Record<PolicyEffect, string> = {
allow: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-300',
deny: 'bg-danger-100 text-danger-800 dark:bg-danger-900/30 dark:text-danger-300',
};
interface PrincipalOption {
value: string;
label: string;
}
function summarizeConditions(conditions: Policy['conditions']): string {
if (!conditions || !conditions.rules || conditions.rules.length === 0) return '—';
return conditions.rules
.map((r) => `${r.field} ${r.op}${NULL_OPS.includes(r.op as never) ? '' : ` ${String(r.value)}`}`)
.join(` ${conditions.operator} `);
}
function conditionsFromRules(operator: ConditionOperator, rules: PolicyRule[]): Policy['conditions'] {
if (rules.length === 0) return null;
return { operator, rules };
}
function rulesFromConditions(conditions: Policy['conditions']): { operator: ConditionOperator; rules: PolicyRule[] } {
if (!conditions || !Array.isArray(conditions.rules)) return { operator: 'AND', rules: [] };
return { operator: conditions.operator ?? 'AND', rules: conditions.rules };
}
function PolicyCard({
policy,
principalLabel,
canWrite,
isMutating,
onEdit,
onDelete,
onToggle,
}: {
policy: Policy;
principalLabel: string;
canWrite: boolean;
isMutating: boolean;
onEdit: (p: Policy) => void;
onDelete: (p: Policy) => void;
onToggle: (p: Policy) => void;
}) {
const { t } = useTranslation();
return (
<Card className="p-4 space-y-2" data-testid={`policy-card-${policy.id}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', EFFECT_BADGE[policy.effect])}
data-testid={`policy-effect-${policy.id}`}
>
{policy.effect === 'allow' ? (
<ShieldCheck className="w-3 h-3" aria-hidden="true" />
) : (
<ShieldX className="w-3 h-3" aria-hidden="true" />
)}
{t(`policies.effect_${policy.effect}`)}
</span>
{!policy.enabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-secondary-200 text-secondary-700 dark:bg-secondary-800 dark:text-secondary-300">
{t('policies.disabledBadge')}
</span>
)}
<span className="text-xs text-secondary-500">{t('policies.priority')}: {policy.priority}</span>
</div>
<p className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100 break-words">
{policy.name}
</p>
<p className="text-xs text-secondary-500 mt-1">
{t('policies.principal')}: <span className="font-medium">{policy.principal_type}</span>
{' · '}
<span title={policy.principal_id}>{principalLabel}</span>
</p>
<p className="text-xs text-secondary-500 mt-1 break-all" data-testid={`policy-conditions-${policy.id}`}>
{t('policies.conditions')}: {summarizeConditions(policy.conditions)}
</p>
</div>
{canWrite && (
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => onToggle(policy)}
disabled={isMutating}
aria-label={policy.enabled ? t('policies.disable') : t('policies.enable')}
data-testid={`policy-toggle-${policy.id}`}
>
{policy.enabled ? t('policies.disable') : t('policies.enable')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(policy)}
disabled={isMutating}
aria-label={t('policies.edit')}
data-testid={`policy-edit-${policy.id}`}
>
<Pencil className="w-4 h-4" aria-hidden="true" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(policy)}
disabled={isMutating}
aria-label={t('policies.delete')}
data-testid={`policy-delete-${policy.id}`}
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
)}
</div>
</Card>
);
}
interface FormState {
name: string;
principalType: PrincipalType;
principalId: string;
effect: PolicyEffect;
priority: string;
enabled: boolean;
operator: ConditionOperator;
rules: PolicyRule[];
}
const EMPTY_FORM: FormState = {
name: '',
principalType: 'user',
principalId: '',
effect: 'allow',
priority: '0',
enabled: true,
operator: 'AND',
rules: [],
};
function PolicyForm({
open,
entityType,
policy,
principalOptions,
onClose,
onSubmit,
isSubmitting,
}: {
open: boolean;
entityType: string;
policy: Policy | null;
principalOptions: PrincipalOption[];
onClose: () => void;
onSubmit: (payload: {
name: string;
principal_type: PrincipalType;
principal_id: string;
effect: PolicyEffect;
priority: number;
enabled: boolean;
conditions: Policy['conditions'];
}) => void;
isSubmitting: boolean;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(EMPTY_FORM);
useEffect(() => {
if (open) {
if (policy) {
const parsed = rulesFromConditions(policy.conditions);
setForm({
name: policy.name,
principalType: policy.principal_type,
principalId: policy.principal_id,
effect: policy.effect,
priority: String(policy.priority),
enabled: policy.enabled,
operator: parsed.operator,
rules: parsed.rules,
});
} else {
setForm(EMPTY_FORM);
}
}
}, [open, policy]);
const allowedFields = ABAC_ALLOWED_FIELDS[entityType] ?? [];
const valid =
form.name.trim().length > 0 &&
form.principalId.trim().length > 0 &&
form.rules.every((r) => r.field && r.op);
const setRule = (idx: number, patch: Partial<PolicyRule>) => {
setForm((f) => ({
...f,
rules: f.rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
}));
};
const submit = () => {
if (!valid) return;
const rules = form.rules.map((r) => ({
...r,
value: NULL_OPS.includes(r.op as never) ? null : r.value,
}));
onSubmit({
name: form.name.trim(),
principal_type: form.principalType,
principal_id: form.principalId.trim(),
effect: form.effect,
priority: parseInt(form.priority, 10) || 0,
enabled: form.enabled,
conditions: conditionsFromRules(form.operator, rules),
});
};
return (
<Modal
open={open}
onClose={onClose}
title={policy ? t('policies.editTitle', { name: policy.name }) : t('policies.createTitle')}
size="lg"
>
<div className="space-y-4">
<Input
label={t('policies.name')}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
maxLength={200}
data-testid="policy-form-name"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Select
label={t('policies.principalType')}
value={form.principalType}
onChange={(e) => {
const pt = e.target.value as PrincipalType;
setForm((f) => ({ ...f, principalType: pt, principalId: '' }));
}}
options={[
{ value: 'user', label: t('policies.principalUser') },
{ value: 'group', label: t('policies.principalGroup') },
{ value: 'role', label: t('policies.principalRole') },
]}
required
data-testid="policy-form-principal-type"
/>
<Select
label={t('policies.principal')}
value={form.principalId}
onChange={(e) => setForm((f) => ({ ...f, principalId: e.target.value }))}
options={principalOptions}
required
placeholder={t('policies.principalPlaceholder')}
data-testid="policy-form-principal"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Select
label={t('policies.effect')}
value={form.effect}
onChange={(e) => setForm((f) => ({ ...f, effect: e.target.value as PolicyEffect }))}
options={[
{ value: 'allow', label: t('policies.effect_allow') },
{ value: 'deny', label: t('policies.effect_deny') },
]}
required
data-testid="policy-form-effect"
/>
<Input
label={t('policies.priority')}
type="number"
value={form.priority}
onChange={(e) => setForm((f) => ({ ...f, priority: e.target.value }))}
data-testid="policy-form-priority"
/>
<Select
label={t('policies.enabled')}
value={form.enabled ? '1' : '0'}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.value === '1' }))}
options={[
{ value: '1', label: t('policies.enabledOption') },
{ value: '0', label: t('policies.disabledOption') },
]}
data-testid="policy-form-enabled"
/>
</div>
{/* Conditions builder */}
<div className="border-t border-secondary-200 dark:border-secondary-700 pt-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-secondary-900 dark:text-secondary-100">
{t('policies.conditions')}
</h3>
<Select
value={form.operator}
onChange={(e) => setForm((f) => ({ ...f, operator: e.target.value as ConditionOperator }))}
options={[
{ value: 'AND', label: t('policies.operatorAND') },
{ value: 'OR', label: t('policies.operatorOR') },
]}
className="w-32"
aria-label={t('policies.operatorLabel')}
data-testid="policy-form-operator"
/>
</div>
{form.rules.length === 0 && (
<p className="text-xs text-secondary-500">{t('policies.noRulesHint')}</p>
)}
{form.rules.map((rule, idx) => (
<div key={idx} className="flex flex-wrap items-end gap-2" data-testid={`policy-rule-${idx}`}>
<div className="w-44">
<Select
label={idx === 0 ? t('policies.field') : undefined}
value={rule.field}
onChange={(e) => setRule(idx, { field: e.target.value })}
options={allowedFields.map((f) => ({ value: f, label: f }))}
placeholder={t('policies.fieldPlaceholder')}
aria-label={t('policies.field')}
/>
</div>
<div className="w-40">
<Select
label={idx === 0 ? t('policies.op') : undefined}
value={rule.op}
onChange={(e) => setRule(idx, { op: e.target.value })}
options={POLICY_OPS.map((op) => ({ value: op, label: op }))}
aria-label={t('policies.op')}
/>
</div>
<div className="flex-1 min-w-32">
<Input
label={idx === 0 ? t('policies.value') : undefined}
value={NULL_OPS.includes(rule.op as never) ? '' : String(rule.value ?? '')}
onChange={(e) => setRule(idx, { value: e.target.value })}
disabled={NULL_OPS.includes(rule.op as never)}
aria-label={t('policies.value')}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setForm((f) => ({ ...f, rules: f.rules.filter((_, i) => i !== idx) }))}
aria-label={t('policies.removeRule')}
data-testid={`policy-rule-remove-${idx}`}
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={() => setForm((f) => ({ ...f, rules: [...f.rules, { field: allowedFields[0] ?? '', op: 'eq', value: '' }] }))}
data-testid="policy-add-rule"
>
<Plus className="w-4 h-4" aria-hidden="true" />
{t('policies.addRule')}
</Button>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={submit} disabled={!valid || isSubmitting} data-testid="policy-form-submit">
{policy ? t('policies.save') : t('policies.createSubmit')}
</Button>
</div>
</div>
</Modal>
);
}
export function SettingsPoliciesPage() {
const { t } = useTranslation();
const [entityType, setEntityType] = useState<string>('contact');
const [showCreate, setShowCreate] = useState(false);
const [editPolicy, setEditPolicy] = useState<Policy | null>(null);
const { hasPermission } = usePermission();
const canRead = hasPermission('policies:read');
const canWrite = hasPermission('policies:write');
const { data, isLoading, isError } = usePolicies(entityType, canRead);
const createMut = useCreatePolicy();
const updateMut = useUpdatePolicy();
const deleteMut = useDeletePolicy();
const { data: usersData } = useUsers(1, 100);
const { data: groupsData } = useGroups();
const { data: rolesData } = useRoles();
const isMutating = createMut.isPending || updateMut.isPending || deleteMut.isPending;
// Principal picker options depend on the selected principal type — built
// once here and passed down so the form can switch without re-fetching.
const principalOptions = useMemo<PrincipalOption[]>(() => {
const users: UserResponse[] = (usersData as any)?.items ?? [];
const groups: Group[] = groupsData?.items ?? [];
const roles: Role[] = rolesData?.items ?? [];
return [
...users.map((u) => ({ value: u.id, label: `${u.name || u.email} (user)` })),
...groups.map((g) => ({ value: g.id, label: `${g.name} (group)` })),
...roles.map((r) => ({ value: r.id, label: `${r.name} (role)` })),
];
}, [usersData, groupsData, rolesData]);
const principalLabelFor = (p: Policy): string => {
const opt = principalOptions.find((o) => o.value === p.principal_id);
return opt ? opt.label : `${p.principal_id.slice(0, 8)}`;
};
const handleCreate = (payload: {
name: string;
principal_type: PrincipalType;
principal_id: string;
effect: PolicyEffect;
priority: number;
enabled: boolean;
conditions: Policy['conditions'];
}) => {
createMut.mutate(
{ entity_type: entityType, conditions: payload.conditions, effect: payload.effect, name: payload.name, principal_id: payload.principal_id, principal_type: payload.principal_type, priority: payload.priority },
{ onSuccess: () => setShowCreate(false) },
);
};
const handleUpdate = (payload: {
name: string;
principal_type: PrincipalType;
principal_id: string;
effect: PolicyEffect;
priority: number;
enabled: boolean;
conditions: Policy['conditions'];
}) => {
if (!editPolicy) return;
updateMut.mutate(
{
id: editPolicy.id,
data: {
name: payload.name,
principal_type: payload.principal_type,
principal_id: payload.principal_id,
effect: payload.effect,
priority: payload.priority,
enabled: payload.enabled,
conditions: payload.conditions,
},
},
{ onSuccess: () => setEditPolicy(null) },
);
};
const handleToggle = (policy: Policy) => {
updateMut.mutate({ id: policy.id, data: { enabled: !policy.enabled } });
};
const handleDelete = (policy: Policy) => {
if (window.confirm(t('policies.deleteConfirm', { name: policy.name }))) {
deleteMut.mutate({ id: policy.id, entityType });
}
};
const items = data?.items ?? [];
return (
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-4" data-testid="policies-page">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-primary-600" aria-hidden="true" />
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{t('policies.title')}
</h1>
</div>
{canWrite && (
<Button onClick={() => setShowCreate(true)} data-testid="policy-create-btn">
<Plus className="w-4 h-4" aria-hidden="true" />
{t('policies.create')}
</Button>
)}
</div>
{/* Entity type tabs */}
<div
className="flex items-center gap-1 border-b border-secondary-200 dark:border-secondary-700 overflow-x-auto"
role="tablist"
aria-label={t('policies.entityType')}
>
{ENTITY_TYPES.map((et) => (
<button
key={et}
role="tab"
aria-selected={entityType === et}
onClick={() => setEntityType(et)}
className={clsx(
'px-3 py-2 text-sm border-b-2 -mb-px transition-colors min-h-touch whitespace-nowrap font-mono',
entityType === et
? 'border-primary-500 text-primary-600 dark:text-primary-400 font-medium'
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300',
)}
data-testid={`policy-tab-${et}`}
>
{et}
</button>
))}
</div>
{!canRead ? (
<Card className="p-8 text-center" data-testid="policies-no-permission">
<AlertTriangle className="w-8 h-8 mx-auto text-warning-500" aria-hidden="true" />
<p className="mt-2 text-sm text-secondary-600">{t('policies.noPermission')}</p>
</Card>
) : isLoading ? (
<div className="flex items-center justify-center py-12" role="status">
<span className="animate-spin h-6 w-6 border-2 border-primary-500 border-t-transparent rounded-full" aria-hidden="true" />
</div>
) : isError ? (
<Card className="p-8 text-center">
<AlertTriangle className="w-8 h-8 mx-auto text-danger-500" aria-hidden="true" />
<p className="mt-2 text-sm text-secondary-600">{t('policies.loadError')}</p>
</Card>
) : items.length === 0 ? (
<Card className="p-12 text-center">
<Inbox className="w-10 h-10 mx-auto text-secondary-300" aria-hidden="true" />
<p className="mt-3 text-sm text-secondary-500" data-testid="policies-empty">
{t('policies.empty')}
</p>
</Card>
) : (
<div className="space-y-3">
{items.map((policy) => (
<PolicyCard
key={policy.id}
policy={policy}
principalLabel={principalLabelFor(policy)}
canWrite={canWrite}
isMutating={isMutating}
onEdit={setEditPolicy}
onDelete={handleDelete}
onToggle={handleToggle}
/>
))}
</div>
)}
<PolicyForm
open={showCreate}
entityType={entityType}
policy={null}
principalOptions={principalOptions}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
isSubmitting={createMut.isPending}
/>
<PolicyForm
open={!!editPolicy}
entityType={editPolicy?.entity_type ?? entityType}
policy={editPolicy}
principalOptions={principalOptions}
onClose={() => setEditPolicy(null)}
onSubmit={handleUpdate}
isSubmitting={updateMut.isPending}
/>
</div>
);
}