Add mTLS RBAC with path-based access control, role/cert trust model, and comprehensive tests
Implements full role-based access control for mTLS client certificates: - Database: mtls_roles, mtls_certificate_roles, mtls_access_rules tables with migration - Models: CRUD for roles, cert-role assignments, path-based access rules - Caddy config: HTTP-layer RBAC enforcement via CEL fingerprint matching in subroutes - New trust model: select individual certs or entire roles instead of CAs (derives CAs automatically) - REST API: /api/v1/mtls-roles, cert assignments, proxy-host access rules endpoints - UI: Roles management tab (card-based), cert/role trust picker, inline RBAC rule editor - Fix: dialog autoclose bug after creating proxy host (key-based remount) - Tests: 85 new tests (785 total) covering models, schema, RBAC route generation, leaf override, edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,8 @@ import { RedirectsFields } from "./RedirectsFields";
|
||||
import { LocationRulesFields } from "./LocationRulesFields";
|
||||
import { RewriteFields } from "./RewriteFields";
|
||||
import type { CaCertificate } from "@/lib/models/ca-certificates";
|
||||
import type { MtlsRole } from "@/lib/models/mtls-roles";
|
||||
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
|
||||
|
||||
export function CreateHostDialog({
|
||||
open,
|
||||
@@ -36,7 +38,9 @@ export function CreateHostDialog({
|
||||
accessLists,
|
||||
authentikDefaults,
|
||||
initialData,
|
||||
caCertificates = []
|
||||
caCertificates = [],
|
||||
mtlsRoles = [],
|
||||
issuedClientCerts = [],
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -45,6 +49,8 @@ export function CreateHostDialog({
|
||||
authentikDefaults: AuthentikSettings | null;
|
||||
initialData?: ProxyHost | null;
|
||||
caCertificates?: CaCertificate[];
|
||||
mtlsRoles?: MtlsRole[];
|
||||
issuedClientCerts?: IssuedClientCertificate[];
|
||||
}) {
|
||||
const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE);
|
||||
|
||||
@@ -164,7 +170,12 @@ export function CreateHostDialog({
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
|
||||
<GeoBlockFields />
|
||||
<WafFields value={initialData?.waf} />
|
||||
<MtlsFields value={initialData?.mtls} caCertificates={caCertificates} />
|
||||
<MtlsFields
|
||||
value={initialData?.mtls}
|
||||
caCertificates={caCertificates}
|
||||
mtlsRoles={mtlsRoles}
|
||||
issuedClientCerts={issuedClientCerts}
|
||||
/>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
@@ -176,7 +187,9 @@ export function EditHostDialog({
|
||||
onClose,
|
||||
certificates,
|
||||
accessLists,
|
||||
caCertificates = []
|
||||
caCertificates = [],
|
||||
mtlsRoles = [],
|
||||
issuedClientCerts = [],
|
||||
}: {
|
||||
open: boolean;
|
||||
host: ProxyHost;
|
||||
@@ -184,6 +197,8 @@ export function EditHostDialog({
|
||||
certificates: Certificate[];
|
||||
accessLists: AccessList[];
|
||||
caCertificates?: CaCertificate[];
|
||||
mtlsRoles?: MtlsRole[];
|
||||
issuedClientCerts?: IssuedClientCertificate[];
|
||||
}) {
|
||||
const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
|
||||
|
||||
@@ -298,7 +313,13 @@ export function EditHostDialog({
|
||||
}}
|
||||
/>
|
||||
<WafFields value={host.waf} />
|
||||
<MtlsFields value={host.mtls} caCertificates={caCertificates} />
|
||||
<MtlsFields
|
||||
value={host.mtls}
|
||||
caCertificates={caCertificates}
|
||||
proxyHostId={host.id}
|
||||
mtlsRoles={mtlsRoles}
|
||||
issuedClientCerts={issuedClientCerts}
|
||||
/>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { LockKeyhole, Plus, Pencil, Trash2, ShieldAlert, Ban, UserCheck, ShieldCheck } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AppDialog } from "@/components/ui/AppDialog";
|
||||
import type { CaCertificate } from "@/lib/models/ca-certificates";
|
||||
import type { MtlsConfig } from "@/lib/models/proxy-hosts";
|
||||
import type { MtlsAccessRule } from "@/lib/models/mtls-access-rules";
|
||||
import type { MtlsRole } from "@/lib/models/mtls-roles";
|
||||
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
|
||||
|
||||
type Props = {
|
||||
value?: MtlsConfig | null;
|
||||
caCertificates: CaCertificate[];
|
||||
issuedClientCerts?: IssuedClientCertificate[];
|
||||
proxyHostId?: number;
|
||||
mtlsRoles?: MtlsRole[];
|
||||
};
|
||||
|
||||
export function MtlsFields({ value, caCertificates }: Props) {
|
||||
export function MtlsFields({ value, caCertificates, issuedClientCerts = [], proxyHostId, mtlsRoles = [] }: Props) {
|
||||
const [enabled, setEnabled] = useState(value?.enabled ?? false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>(value?.ca_certificate_ids ?? []);
|
||||
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(value?.trusted_client_cert_ids ?? []);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(value?.trusted_role_ids ?? []);
|
||||
|
||||
function toggleId(id: number) {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
const [rules, setRules] = useState<MtlsAccessRule[]>([]);
|
||||
const [rulesLoaded, setRulesLoaded] = useState(false);
|
||||
const [addRuleOpen, setAddRuleOpen] = useState(false);
|
||||
const [editRule, setEditRule] = useState<MtlsAccessRule | null>(null);
|
||||
|
||||
const isEditMode = !!proxyHostId;
|
||||
const activeCerts = issuedClientCerts.filter(c => !c.revoked_at);
|
||||
|
||||
const certsByCA = new Map<number, IssuedClientCertificate[]>();
|
||||
for (const cert of activeCerts) {
|
||||
const list = certsByCA.get(cert.ca_certificate_id) ?? [];
|
||||
list.push(cert);
|
||||
certsByCA.set(cert.ca_certificate_id, list);
|
||||
}
|
||||
|
||||
const loadRules = useCallback(() => {
|
||||
if (!proxyHostId) return;
|
||||
fetch(`/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((data: MtlsAccessRule[]) => { setRules(data); setRulesLoaded(true); })
|
||||
.catch(() => { setRules([]); setRulesLoaded(true); });
|
||||
}, [proxyHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && enabled) loadRules();
|
||||
}, [isEditMode, enabled, loadRules]);
|
||||
|
||||
function toggleCert(certId: number) {
|
||||
setSelectedCertIds(prev => prev.includes(certId) ? prev.filter(i => i !== certId) : [...prev, certId]);
|
||||
}
|
||||
|
||||
function toggleRole(roleId: number) {
|
||||
setSelectedRoleIds(prev => prev.includes(roleId) ? prev.filter(i => i !== roleId) : [...prev, roleId]);
|
||||
}
|
||||
|
||||
function toggleAllFromCA(caId: number) {
|
||||
const caCerts = certsByCA.get(caId) ?? [];
|
||||
const caIds = caCerts.map(c => c.id);
|
||||
const allSelected = caIds.every(id => selectedCertIds.includes(id));
|
||||
setSelectedCertIds(prev => allSelected ? prev.filter(id => !caIds.includes(id)) : [...new Set([...prev, ...caIds])]);
|
||||
}
|
||||
|
||||
async function deleteRule(ruleId: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules/${ruleId}`, { method: "DELETE" });
|
||||
if (res.ok) setRules(prev => prev.filter(r => r.id !== ruleId));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const hasTrust = selectedCertIds.length > 0 || selectedRoleIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/60 bg-amber-500/5 p-4">
|
||||
<input type="hidden" name="mtls_present" value="1" />
|
||||
<input type="hidden" name="mtls_enabled" value={enabled ? "true" : "false"} />
|
||||
{enabled && selectedIds.map(id => (
|
||||
<input key={id} type="hidden" name="mtls_ca_cert_id" value={String(id)} />
|
||||
{enabled && selectedCertIds.map(id => (
|
||||
<input key={`c${id}`} type="hidden" name="mtls_cert_id" value={String(id)} />
|
||||
))}
|
||||
{enabled && selectedRoleIds.map(id => (
|
||||
<input key={`r${id}`} type="hidden" name="mtls_role_id" value={String(id)} />
|
||||
))}
|
||||
|
||||
{/* Header */}
|
||||
@@ -41,52 +102,285 @@ export function MtlsFields({ value, caCertificates }: Props) {
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold leading-snug">Mutual TLS (mTLS)</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Require clients to present a certificate signed by a trusted CA
|
||||
Require clients to present a trusted certificate to connect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
enabled ? "max-h-[1000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
|
||||
enabled ? "max-h-[4000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<Alert className="mb-4">
|
||||
<AlertDescription>
|
||||
mTLS requires TLS to be configured on this host (certificate must be set).
|
||||
Select roles and/or individual certificates to allow.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Trusted Client CA Certificates
|
||||
</span>
|
||||
{/* ── Trusted Roles ── */}
|
||||
{mtlsRoles.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-500" />
|
||||
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Trusted Roles
|
||||
</p>
|
||||
{selectedRoleIds.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs ml-auto">{selectedRoleIds.length} selected</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border bg-background mb-4">
|
||||
{mtlsRoles.map(role => (
|
||||
<div key={role.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-muted/30 border-b last:border-b-0">
|
||||
<Checkbox
|
||||
checked={selectedRoleIds.includes(role.id)}
|
||||
onCheckedChange={() => toggleRole(role.id)}
|
||||
/>
|
||||
<label className="flex-1 min-w-0 cursor-pointer" onClick={() => toggleRole(role.id)}>
|
||||
<span className="text-sm font-medium">{role.name}</span>
|
||||
{role.description && <span className="text-xs text-muted-foreground ml-2">— {role.description}</span>}
|
||||
</label>
|
||||
<Badge variant="outline" className="text-xs shrink-0">{role.certificate_count} certs</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{caCertificates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
No CA certificates configured. Add them on the Certificates page.
|
||||
{/* ── Trusted Certificates ── */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<UserCheck className="h-4 w-4 text-amber-500" />
|
||||
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Trusted Certificates
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col mt-1">
|
||||
{caCertificates.map(ca => (
|
||||
<div key={ca.id} className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
id={`ca-cert-${ca.id}`}
|
||||
checked={selectedIds.includes(ca.id)}
|
||||
onCheckedChange={() => toggleId(ca.id)}
|
||||
/>
|
||||
<label htmlFor={`ca-cert-${ca.id}`} className="text-sm cursor-pointer">
|
||||
{ca.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{selectedCertIds.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs ml-auto">{selectedCertIds.length} selected</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeCerts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">No client certificates issued yet.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Issue certificates from a CA on the Certificates page.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from(certsByCA.entries()).map(([caId, certs]) => {
|
||||
const ca = caCertificates.find(c => c.id === caId);
|
||||
const caName = ca?.name ?? `CA #${caId}`;
|
||||
const allSelected = certs.every(c => selectedCertIds.includes(c.id));
|
||||
const someSelected = certs.some(c => selectedCertIds.includes(c.id));
|
||||
|
||||
return (
|
||||
<div key={caId} className="rounded-md border bg-background">
|
||||
<div className="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 rounded-t-md">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => toggleAllFromCA(caId)}
|
||||
className={someSelected && !allSelected ? "opacity-60" : ""}
|
||||
/>
|
||||
<label
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wide cursor-pointer flex-1"
|
||||
onClick={() => toggleAllFromCA(caId)}
|
||||
>
|
||||
{caName}
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{certs.filter(c => selectedCertIds.includes(c.id)).length}/{certs.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t">
|
||||
{certs.map(cert => (
|
||||
<div key={cert.id} className="flex items-center gap-2.5 px-3 py-1.5 hover:bg-muted/30">
|
||||
<Checkbox
|
||||
checked={selectedCertIds.includes(cert.id)}
|
||||
onCheckedChange={() => toggleCert(cert.id)}
|
||||
className="ml-4"
|
||||
/>
|
||||
<label className="min-w-0 flex-1 cursor-pointer" onClick={() => toggleCert(cert.id)}>
|
||||
<span className="text-sm">{cert.common_name}</span>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
expires {new Date(cert.valid_to).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasTrust && activeCerts.length > 0 && (
|
||||
<p className="text-xs text-destructive mt-2">No roles or certificates selected — mTLS will block all connections.</p>
|
||||
)}
|
||||
|
||||
{/* ── RBAC rules ── */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-500" />
|
||||
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Path-Based Access Rules
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => setAddRuleOpen(true)}>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Restrict specific paths to certain roles or certificates. Paths without rules allow any trusted cert/role above.
|
||||
</p>
|
||||
|
||||
{!rulesLoaded ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-3">Loading...</p>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">No access rules configured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">All trusted certificates/roles have equal access to every path.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{rules.map(rule => (
|
||||
<div key={rule.id} className="group flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.path_pattern}</code>
|
||||
{rule.deny_all ? (
|
||||
<Badge variant="destructive" className="text-xs gap-1"><Ban className="h-3 w-3" /> Deny</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
|
||||
{rule.allowed_role_ids.map(roleId => {
|
||||
const role = mtlsRoles.find(r => r.id === roleId);
|
||||
return <Badge key={`r-${roleId}`} variant="secondary" className="text-xs">{role?.name ?? `#${roleId}`}</Badge>;
|
||||
})}
|
||||
{rule.allowed_cert_ids.map(certId => {
|
||||
const cert = issuedClientCerts.find(c => c.id === certId);
|
||||
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.common_name ?? `#${certId}`}</Badge>;
|
||||
})}
|
||||
{rule.allowed_role_ids.length === 0 && rule.allowed_cert_ids.length === 0 && (
|
||||
<span className="text-xs text-destructive italic">No roles/certs — effectively denied</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button type="button" size="icon" variant="ghost" className="h-6 w-6" onClick={() => setEditRule(rule)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-6 w-6 text-destructive hover:text-destructive" onClick={() => deleteRule(rule.id)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addRuleOpen && (
|
||||
<RuleDialog onClose={() => setAddRuleOpen(false)} proxyHostId={proxyHostId!} roles={mtlsRoles} activeCerts={activeCerts} title="Add Access Rule" submitLabel="Add Rule" onSaved={loadRules} />
|
||||
)}
|
||||
{editRule && (
|
||||
<RuleDialog onClose={() => setEditRule(null)} proxyHostId={proxyHostId!} roles={mtlsRoles} activeCerts={activeCerts} title="Edit Access Rule" submitLabel="Save" existing={editRule} onSaved={loadRules} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLabel, existing, onSaved }: {
|
||||
onClose: () => void; proxyHostId: number; roles: MtlsRole[]; activeCerts: IssuedClientCertificate[];
|
||||
title: string; submitLabel: string; existing?: MtlsAccessRule; onSaved: () => void;
|
||||
}) {
|
||||
const [pathPattern, setPathPattern] = useState(existing?.path_pattern ?? "*");
|
||||
const [priority, setPriority] = useState(String(existing?.priority ?? 0));
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowed_role_ids ?? []);
|
||||
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowed_cert_ids ?? []);
|
||||
const [denyAll, setDenyAll] = useState(existing?.deny_all ?? false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!pathPattern.trim()) { setError("Path pattern is required"); return; }
|
||||
setSubmitting(true); setError("");
|
||||
try {
|
||||
const url = existing
|
||||
? `/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules/${existing.id}`
|
||||
: `/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules`;
|
||||
const res = await fetch(url, {
|
||||
method: existing ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path_pattern: pathPattern.trim(), priority: Number(priority) || 0, description: description || null, allowed_role_ids: selectedRoleIds, allowed_cert_ids: selectedCertIds, deny_all: denyAll }),
|
||||
});
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); setError(data.error || `Failed (${res.status})`); setSubmitting(false); return; }
|
||||
onSaved(); onClose();
|
||||
} catch { setError("Network error"); setSubmitting(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<AppDialog open onClose={onClose} title={title} submitLabel={submitLabel} onSubmit={handleSubmit} isSubmitting={submitting}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Label>Path Pattern</Label>
|
||||
<Input value={pathPattern} onChange={e => setPathPattern(e.target.value)} placeholder="*" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Use * for all paths, /admin/* for prefix match</p>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<Label>Priority</Label>
|
||||
<Input type="number" value={priority} onChange={e => setPriority(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2.5">
|
||||
<Switch checked={denyAll} onCheckedChange={setDenyAll} />
|
||||
<Label className="text-sm cursor-pointer">Deny all access to this path</Label>
|
||||
</div>
|
||||
<div className={cn(denyAll && "opacity-30 pointer-events-none", "flex flex-col gap-4")}>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Allowed Roles</p>
|
||||
{roles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No mTLS roles yet. Create roles on the Certificates page.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{roles.map(role => (
|
||||
<div key={role.id} className="flex items-center gap-2 py-1 rounded hover:bg-muted/50 px-1">
|
||||
<Checkbox checked={selectedRoleIds.includes(role.id)} onCheckedChange={() => setSelectedRoleIds(prev => prev.includes(role.id) ? prev.filter(i => i !== role.id) : [...prev, role.id])} />
|
||||
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedRoleIds(prev => prev.includes(role.id) ? prev.filter(i => i !== role.id) : [...prev, role.id])}>{role.name}</label>
|
||||
{role.description && <span className="text-xs text-muted-foreground ml-1">— {role.description}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-0.5">Allowed Specific Certificates</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">These bypass role checks for this path</p>
|
||||
{activeCerts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active client certificates.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
|
||||
{activeCerts.map(cert => (
|
||||
<div key={cert.id} className="flex items-center gap-2 py-1 rounded hover:bg-muted/50 px-1">
|
||||
<Checkbox checked={selectedCertIds.includes(cert.id)} onCheckedChange={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])} />
|
||||
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.common_name}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user