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:
fuomag9
2026-04-05 18:40:21 +02:00
parent a2b8d69aa6
commit 277ae6e79c
28 changed files with 3484 additions and 86 deletions

View File

@@ -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>
);

View File

@@ -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>
);
}