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
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { ShieldCheck, Plus, Trash2, UserPlus } from "lucide-react";
import { useState, useEffect, useCallback } from "react";
import { AppDialog } from "@/components/ui/AppDialog";
import type { MtlsRole, MtlsRoleWithCertificates } from "@/lib/models/mtls-roles";
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
const ACCENT_COLORS = [
{ border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500", badge: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", avatar: "bg-amber-500/15 text-amber-600 dark:text-amber-400" },
{ border: "border-l-cyan-500", icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500", badge: "border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400", avatar: "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400" },
{ border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500", badge: "border-violet-500/30 bg-violet-500/10 text-violet-600 dark:text-violet-400", avatar: "bg-violet-500/15 text-violet-600 dark:text-violet-400" },
{ border: "border-l-emerald-500", icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500", badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", avatar: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400" },
{ border: "border-l-rose-500", icon: "border-rose-500/30 bg-rose-500/10 text-rose-500", badge: "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400", avatar: "bg-rose-500/15 text-rose-600 dark:text-rose-400" },
];
type Props = {
roles: MtlsRole[];
issuedCerts: IssuedClientCertificate[];
search: string;
};
export function MtlsRolesTab({ roles, issuedCerts, search }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const activeCerts = issuedCerts.filter(c => !c.revoked_at);
const filtered = roles.filter(r =>
!search ||
r.name.toLowerCase().includes(search.toLowerCase()) ||
r.description?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex flex-col gap-4">
{/* Create inline form */}
{createOpen ? (
<CreateRoleCard onClose={() => setCreateOpen(false)} />
) : (
<Button variant="outline" className="w-full border-dashed gap-2" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> Create New Role
</Button>
)}
{filtered.length === 0 && !createOpen && (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<ShieldCheck className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
{search ? "No roles match your search." : "No mTLS roles yet."}
</p>
<p className="text-xs text-muted-foreground">
Roles group client certificates for access control on proxy hosts.
</p>
</div>
)}
{filtered.map((role, idx) => (
<RoleCard key={role.id} role={role} accent={ACCENT_COLORS[idx % ACCENT_COLORS.length]} activeCerts={activeCerts} />
))}
</div>
);
}
/* ── Create role inline card ── */
function CreateRoleCard({ onClose }: { onClose: () => void }) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
async function handleCreate() {
if (!name.trim()) { setError("Name is required"); return; }
setSubmitting(true); setError("");
try {
const res = await fetch("/api/v1/mtls-roles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
});
if (!res.ok) { const d = await res.json().catch(() => ({})); setError(d.error || `Failed (${res.status})`); setSubmitting(false); return; }
onClose();
window.location.reload();
} catch { setError("Network error"); setSubmitting(false); }
}
return (
<Card className="border-l-2 border-l-primary">
<CardContent className="pt-5 pb-4 px-5 flex flex-col gap-3">
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label className="text-xs">Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. admin" className="h-8 text-sm" autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs">Description</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="Optional" className="h-8 text-sm" />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onClose}>Cancel</Button>
<Button size="sm" className="h-7 text-xs" onClick={handleCreate} disabled={submitting}>
{submitting ? "Creating..." : "Create Role"}
</Button>
</div>
</CardContent>
</Card>
);
}
/* ── Single role card ── */
function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeof ACCENT_COLORS[0]; activeCerts: IssuedClientCertificate[] }) {
const [assignedIds, setAssignedIds] = useState<Set<number>>(new Set());
const [loaded, setLoaded] = useState(false);
const [toggling, setToggling] = useState<number | null>(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(role.name);
const [description, setDescription] = useState(role.description ?? "");
const loadAssignments = useCallback(() => {
fetch(`/api/v1/mtls-roles/${role.id}`)
.then(r => r.ok ? r.json() : { certificate_ids: [] })
.then((data: MtlsRoleWithCertificates) => { setAssignedIds(new Set(data.certificate_ids)); setLoaded(true); })
.catch(() => setLoaded(true));
}, [role.id]);
useEffect(() => { loadAssignments(); }, [loadAssignments]);
async function handleToggle(certId: number) {
const isAssigned = assignedIds.has(certId);
setToggling(certId);
try {
if (isAssigned) {
await fetch(`/api/v1/mtls-roles/${role.id}/certificates/${certId}`, { method: "DELETE" });
setAssignedIds(prev => { const next = new Set(prev); next.delete(certId); return next; });
} else {
await fetch(`/api/v1/mtls-roles/${role.id}/certificates`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ certificate_id: certId }),
});
setAssignedIds(prev => new Set(prev).add(certId));
}
} catch { /* silent */ }
setToggling(null);
}
async function handleSave() {
await fetch(`/api/v1/mtls-roles/${role.id}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
});
setEditing(false);
window.location.reload();
}
async function handleDelete() {
if (!confirm(`Delete role "${role.name}"?`)) return;
await fetch(`/api/v1/mtls-roles/${role.id}`, { method: "DELETE" });
window.location.reload();
}
return (
<Card className={`border-l-2 ${accent.border}`}>
<CardContent className="flex flex-col gap-4 pt-5 pb-5 px-5">
{/* Header */}
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border ${accent.icon}`}>
<ShieldCheck className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{role.name}</p>
<p className="text-xs text-muted-foreground">
{assignedIds.size} {assignedIds.size === 1 ? "certificate" : "certificates"}
{role.description && ` · ${role.description}`}
</p>
</div>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-bold tabular-nums ${accent.badge}`}>
{assignedIds.size}
</span>
</div>
{/* Edit form */}
{editing ? (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label className="text-xs">Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} className="h-8 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs">Description</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} className="h-8 text-sm" placeholder="Optional" />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setEditing(false)}>Cancel</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSave}>Save</Button>
</div>
</div>
) : (
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setEditing(true)}>Edit</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-destructive" onClick={handleDelete}>Delete role</Button>
</div>
)}
<Separator />
{/* Certificates */}
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Certificates</p>
{!loaded ? (
<p className="text-sm text-muted-foreground py-2">Loading...</p>
) : activeCerts.length === 0 ? (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3 text-sm text-muted-foreground">
<UserPlus className="h-4 w-4 shrink-0" />
No client certificates issued yet.
</div>
) : (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{activeCerts.map(cert => {
const isAssigned = assignedIds.has(cert.id);
const isLoading = toggling === cert.id;
return (
<div key={cert.id} className={`flex items-center justify-between px-3 py-2 bg-muted/20 hover:bg-muted/40 transition-colors ${isLoading ? "opacity-50" : ""}`}>
<div className="flex items-center gap-2.5">
<Checkbox checked={isAssigned} disabled={isLoading} onCheckedChange={() => handleToggle(cert.id)} />
<div>
<p className="text-sm font-medium leading-tight">{cert.common_name}</p>
<p className="text-xs text-muted-foreground">
expires {new Date(cert.valid_to).toLocaleDateString()}
</p>
</div>
</div>
{isAssigned && <Badge variant="secondary" className="text-xs">Assigned</Badge>}
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
);
}
+25 -4
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>
);
+331 -37
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>
);
}
+191 -18
View File
@@ -1,9 +1,32 @@
/**
* mTLS helper functions for building Caddy TLS connection policies.
* mTLS helper functions for building Caddy TLS connection policies
* and HTTP-layer RBAC route enforcement.
*
* Extracted from caddy.ts so they can be unit-tested independently.
*/
/**
* Normalise a fingerprint to the format Caddy uses:
* lowercase hex without colons.
*
* Node's X509Certificate.fingerprint256 returns "AB:CD:EF:..." (uppercase, colons).
* Caddy's {http.request.tls.client.fingerprint} returns "abcdef..." (lowercase, no colons).
*/
export function normalizeFingerprint(fp: string): string {
return fp.replace(/:/g, "").toLowerCase();
}
/**
* Minimal type matching MtlsAccessRule from the models layer.
* Defined here to avoid importing from models (which pulls in db.ts).
*/
export type MtlsAccessRuleLike = {
path_pattern: string;
allowed_role_ids: number[];
allowed_cert_ids: number[];
deny_all: boolean;
};
/**
* Converts a PEM certificate to base64-encoded DER format expected by Caddy's
* `trusted_ca_certs` and `trusted_leaf_certs` fields.
@@ -36,7 +59,8 @@ export function buildClientAuthentication(
mTlsDomainMap: Map<string, number[]>,
caCertMap: Map<number, { id: number; certificatePem: string }>,
issuedClientCertMap: Map<number, string[]>,
cAsWithAnyIssuedCerts: Set<number>
cAsWithAnyIssuedCerts: Set<number>,
mTlsDomainLeafOverride?: Map<string, string[]>
): Record<string, unknown> | null {
const caCertIds = new Set<number>();
for (const domain of domains) {
@@ -47,27 +71,50 @@ export function buildClientAuthentication(
}
if (caCertIds.size === 0) return null;
// Check if any domain in this group uses the new cert-based model (has leaf override)
const leafOverridePems = new Set<string>();
let hasLeafOverride = false;
if (mTlsDomainLeafOverride) {
for (const domain of domains) {
const pems = mTlsDomainLeafOverride.get(domain.toLowerCase());
if (pems) {
hasLeafOverride = true;
for (const pem of pems) leafOverridePems.add(pem);
}
}
}
const trustedCaCerts: string[] = [];
const trustedLeafCerts: string[] = [];
for (const id of caCertIds) {
const ca = caCertMap.get(id);
if (!ca) continue;
if (hasLeafOverride) {
// New cert-based model: CAs were derived from selected certs.
// Add CAs for chain validation, pin to only the explicitly selected leaf certs.
for (const id of caCertIds) {
const ca = caCertMap.get(id);
if (ca) trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
}
for (const pem of leafOverridePems) {
trustedLeafCerts.push(pemToBase64Der(pem));
}
} else {
// Legacy CA-based model
for (const id of caCertIds) {
const ca = caCertMap.get(id);
if (!ca) continue;
if (cAsWithAnyIssuedCerts.has(id)) {
// Managed CA: enforce revocation via leaf pinning
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
if (activeLeafCerts.length === 0) {
// All certs revoked: exclude CA so chain validation fails for its certs
continue;
if (cAsWithAnyIssuedCerts.has(id)) {
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
if (activeLeafCerts.length === 0) {
continue;
}
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
for (const certPem of activeLeafCerts) {
trustedLeafCerts.push(pemToBase64Der(certPem));
}
} else {
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
}
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
for (const certPem of activeLeafCerts) {
trustedLeafCerts.push(pemToBase64Der(certPem));
}
} else {
// Unmanaged CA: trust any cert in the chain
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
}
}
@@ -108,3 +155,129 @@ export function groupMtlsDomainsByCaSet(
}
return groups;
}
// ── mTLS RBAC HTTP-layer route enforcement ───────────────────────────
/**
* For a single access rule, resolve the set of allowed fingerprints by unioning:
* - All active cert fingerprints from certs that hold any of the allowed roles
* - All active cert fingerprints from directly-allowed cert IDs
*/
export function resolveAllowedFingerprints(
rule: MtlsAccessRuleLike,
roleFingerprintMap: Map<number, Set<string>>,
certFingerprintMap: Map<number, string>
): Set<string> {
const allowed = new Set<string>();
for (const roleId of rule.allowed_role_ids) {
const fps = roleFingerprintMap.get(roleId);
if (fps) {
for (const fp of fps) allowed.add(fp);
}
}
for (const certId of rule.allowed_cert_ids) {
const fp = certFingerprintMap.get(certId);
if (fp) allowed.add(fp);
}
return allowed;
}
/**
* Builds a CEL expression that checks whether the client certificate's
* fingerprint is in the given set of allowed fingerprints.
*
* Uses Caddy's `{http.request.tls.client.fingerprint}` placeholder.
*/
export function buildFingerprintCelExpression(fingerprints: Set<string>): string {
const fps = Array.from(fingerprints).sort();
const quoted = fps.map((fp) => `'${fp}'`).join(", ");
return `{http.request.tls.client.fingerprint} in [${quoted}]`;
}
/**
* Given a proxy host's mTLS access rules, builds subroutes that enforce
* path-based RBAC at the HTTP layer (after TLS handshake).
*
* Returns null if there are no access rules (caller should use normal routing).
*
* The returned subroutes:
* - For each rule (ordered by priority desc), emit a path+fingerprint match
* route (allow) followed by a path-only route (deny 403).
* - After all rules, a catch-all route allows any valid cert (preserving
* backwards-compatible behavior for paths without rules).
*/
export function buildMtlsRbacSubroutes(
accessRules: MtlsAccessRuleLike[],
roleFingerprintMap: Map<number, Set<string>>,
certFingerprintMap: Map<number, string>,
baseHandlers: Record<string, unknown>[],
reverseProxyHandler: Record<string, unknown>
): Record<string, unknown>[] | null {
if (accessRules.length === 0) return null;
const subroutes: Record<string, unknown>[] = [];
// Rules are already sorted by priority desc, path asc
for (const rule of accessRules) {
if (rule.deny_all) {
// Explicit deny: any request matching this path gets 403
subroutes.push({
match: [{ path: [rule.path_pattern] }],
handle: [{
handler: "static_response",
status_code: "403",
body: "mTLS access denied",
}],
terminal: true,
});
continue;
}
const allowedFps = resolveAllowedFingerprints(rule, roleFingerprintMap, certFingerprintMap);
if (allowedFps.size === 0) {
// Rule exists but no certs match → deny all for this path
subroutes.push({
match: [{ path: [rule.path_pattern] }],
handle: [{
handler: "static_response",
status_code: "403",
body: "mTLS access denied",
}],
terminal: true,
});
continue;
}
// Allow route: path + fingerprint CEL match
const celExpr = buildFingerprintCelExpression(allowedFps);
subroutes.push({
match: [{ path: [rule.path_pattern], expression: celExpr }],
handle: [...baseHandlers, reverseProxyHandler],
terminal: true,
});
// Deny route: path matches but fingerprint didn't → 403
subroutes.push({
match: [{ path: [rule.path_pattern] }],
handle: [{
handler: "static_response",
status_code: "403",
body: "mTLS access denied",
}],
terminal: true,
});
}
// Catch-all: paths without explicit rules → any valid cert gets through
subroutes.push({
handle: [...baseHandlers, reverseProxyHandler],
terminal: true,
});
return subroutes;
}
+118 -15
View File
@@ -51,7 +51,9 @@ import {
l4ProxyHosts
} from "./db/schema";
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig, type RedirectRule, type RewriteConfig, type LocationRule } from "./models/proxy-hosts";
import { buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls";
import { buildClientAuthentication, groupMtlsDomainsByCaSet, buildMtlsRbacSubroutes, type MtlsAccessRuleLike } from "./caddy-mtls";
import { buildRoleFingerprintMap, buildCertFingerprintMap, buildRoleCertIdMap } from "./models/mtls-roles";
import { getAccessRulesForHosts } from "./models/mtls-access-rules";
import { buildWafHandler, resolveEffectiveWaf } from "./caddy-waf";
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
@@ -621,6 +623,11 @@ type BuildProxyRoutesOptions = {
globalUpstreamDnsResolutionSettings: UpstreamDnsResolutionSettings | null;
globalGeoBlock?: GeoBlockSettings | null;
globalWaf?: WafSettings | null;
mtlsRbac?: {
roleFingerprintMap: Map<number, Set<string>>;
certFingerprintMap: Map<number, string>;
accessRulesByHost: Map<number, MtlsAccessRuleLike[]>;
};
};
export function buildLocationReverseProxy(
@@ -1106,6 +1113,12 @@ async function buildProxyRoutes(
}
} else {
const locationRules = meta.location_rules ?? [];
// Check for mTLS RBAC access rules for this proxy host
const hostAccessRules = options.mtlsRbac?.accessRulesByHost.get(row.id);
const hasMtlsRbac = hostAccessRules && hostAccessRules.length > 0
&& options.mtlsRbac?.roleFingerprintMap && options.mtlsRbac?.certFingerprintMap;
for (const domainGroup of domainGroups) {
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
@@ -1120,12 +1133,41 @@ async function buildProxyRoutes(
terminal: true,
});
}
const route: CaddyHttpRoute = {
match: [{ host: domainGroup }],
handle: [...handlers, reverseProxyHandler],
terminal: true,
};
hostRoutes.push(route);
if (hasMtlsRbac) {
// mTLS RBAC: wrap in subroute with path-based fingerprint enforcement
const rbacSubroutes = buildMtlsRbacSubroutes(
hostAccessRules,
options.mtlsRbac!.roleFingerprintMap,
options.mtlsRbac!.certFingerprintMap,
handlers,
reverseProxyHandler
);
if (rbacSubroutes) {
hostRoutes.push({
match: [{ host: domainGroup }],
handle: [{
handler: "subroute",
routes: rbacSubroutes,
}],
terminal: true,
});
} else {
// Fallback: no subroutes generated, use normal routing
hostRoutes.push({
match: [{ host: domainGroup }],
handle: [...handlers, reverseProxyHandler],
terminal: true,
});
}
} else {
const route: CaddyHttpRoute = {
match: [{ host: domainGroup }],
handle: [...handlers, reverseProxyHandler],
terminal: true,
};
hostRoutes.push(route);
}
}
}
@@ -1142,14 +1184,15 @@ function buildTlsConnectionPolicies(
mTlsDomainMap: Map<string, number[]>,
caCertMap: Map<number, { id: number; certificatePem: string }>,
issuedClientCertMap: Map<number, string[]>,
cAsWithAnyIssuedCerts: Set<number>
cAsWithAnyIssuedCerts: Set<number>,
mTlsDomainLeafOverride: Map<string, string[]>
) {
const policies: Record<string, unknown>[] = [];
const readyCertificates = new Set<number>();
const importedCertPems: { certificate: string; key: string }[] = [];
const buildAuth = (domains: string[]) =>
buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts);
buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts, mTlsDomainLeafOverride);
/**
* Pushes one TLS policy per unique CA set found in `mTlsDomains`.
@@ -1628,6 +1671,7 @@ async function buildCaddyDocument() {
.from(caCertificates),
db
.select({
id: issuedClientCertificates.id,
caCertificateId: issuedClientCertificates.caCertificateId,
certificatePem: issuedClientCertificates.certificatePem
})
@@ -1692,18 +1736,71 @@ async function buildCaddyDocument() {
return map;
}, new Map());
// Build domain → CA cert IDs map for mTLS-enabled hosts
// Build a lookup: issued cert ID → { id, caCertificateId, certificatePem }
const issuedCertById = new Map(issuedClientCertRows.map(r => [r.id, r]));
// Resolve role IDs → cert IDs for trusted_role_ids in mTLS config
const roleCertIdMap = await buildRoleCertIdMap();
// Build domain → CA cert IDs map for mTLS-enabled hosts.
// New model (trusted_client_cert_ids + trusted_role_ids): derive CAs from selected certs and pin to those certs.
// Old model (ca_certificate_ids): trust entire CAs as before.
const mTlsDomainMap = new Map<string, number[]>();
// Per-domain override: which specific leaf cert PEMs to pin (new model only)
const mTlsDomainLeafOverride = new Map<string, string[]>();
for (const row of proxyHostRows) {
if (!row.enabled) continue;
const meta = parseJson<{ mtls?: MtlsConfig }>(row.meta, {});
if (!meta.mtls?.enabled || !meta.mtls.ca_certificate_ids?.length) continue;
if (!meta.mtls?.enabled) continue;
const domains = parseJson<string[]>(row.domains, []).map(d => d.trim().toLowerCase()).filter(Boolean);
for (const domain of domains) {
mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids);
if (domains.length === 0) continue;
// Collect all trusted cert IDs from both direct selection and roles
const allCertIds = new Set<number>();
if (meta.mtls.trusted_client_cert_ids) {
for (const id of meta.mtls.trusted_client_cert_ids) allCertIds.add(id);
}
if (meta.mtls.trusted_role_ids) {
for (const roleId of meta.mtls.trusted_role_ids) {
const certIds = roleCertIdMap.get(roleId);
if (certIds) for (const id of certIds) allCertIds.add(id);
}
}
if (allCertIds.size > 0) {
// New model: derive CAs from resolved cert IDs and collect leaf PEMs
const derivedCaIds = new Set<number>();
const leafPems: string[] = [];
for (const certId of allCertIds) {
const cert = issuedCertById.get(certId);
if (cert) {
derivedCaIds.add(cert.caCertificateId);
leafPems.push(cert.certificatePem);
}
}
if (derivedCaIds.size === 0) continue;
const caIdArr = Array.from(derivedCaIds);
for (const domain of domains) {
mTlsDomainMap.set(domain, caIdArr);
mTlsDomainLeafOverride.set(domain, leafPems);
}
} else if (meta.mtls.ca_certificate_ids?.length) {
// Legacy model: trust entire CAs (backward compat)
for (const domain of domains) {
mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids);
}
}
}
// Build mTLS RBAC data for HTTP-layer enforcement
const enabledProxyHostIds = proxyHostRows.filter((r) => r.enabled).map((r) => r.id);
const [roleFingerprintMap, certFingerprintMap, accessRulesByHost] = await Promise.all([
buildRoleFingerprintMap(),
buildCertFingerprintMap(),
getAccessRulesForHosts(enabledProxyHostIds),
]);
const { usage: certificateUsage, autoManagedDomains } = collectCertificateUsage(proxyHostRows, certificateMap);
const [generalSettings, dnsSettings, upstreamDnsResolutionSettings, globalGeoBlock, globalWaf] = await Promise.all([
getGeneralSettings(),
@@ -1723,7 +1820,8 @@ async function buildCaddyDocument() {
mTlsDomainMap,
caCertMap,
issuedClientCertMap,
cAsWithAnyIssuedCerts
cAsWithAnyIssuedCerts,
mTlsDomainLeafOverride
);
const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes(
@@ -1734,7 +1832,12 @@ async function buildCaddyDocument() {
globalDnsSettings: dnsSettings,
globalUpstreamDnsResolutionSettings: upstreamDnsResolutionSettings,
globalGeoBlock,
globalWaf
globalWaf,
mtlsRbac: {
roleFingerprintMap,
certFingerprintMap,
accessRulesByHost,
},
}
);
+64
View File
@@ -274,6 +274,70 @@ export const wafLogParseState = sqliteTable('waf_log_parse_state', {
value: text('value').notNull(),
});
// ── mTLS RBAC ──────────────────────────────────────────────────────────
export const mtlsRoles = sqliteTable(
"mtls_roles",
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({
nameUnique: uniqueIndex("mtls_roles_name_unique").on(table.name)
})
);
export const mtlsCertificateRoles = sqliteTable(
"mtls_certificate_roles",
{
id: integer("id").primaryKey({ autoIncrement: true }),
issuedClientCertificateId: integer("issued_client_certificate_id")
.references(() => issuedClientCertificates.id, { onDelete: "cascade" })
.notNull(),
mtlsRoleId: integer("mtls_role_id")
.references(() => mtlsRoles.id, { onDelete: "cascade" })
.notNull(),
createdAt: text("created_at").notNull()
},
(table) => ({
certRoleUnique: uniqueIndex("mtls_cert_role_unique").on(
table.issuedClientCertificateId,
table.mtlsRoleId
),
roleIdx: index("mtls_certificate_roles_role_idx").on(table.mtlsRoleId)
})
);
export const mtlsAccessRules = sqliteTable(
"mtls_access_rules",
{
id: integer("id").primaryKey({ autoIncrement: true }),
proxyHostId: integer("proxy_host_id")
.references(() => proxyHosts.id, { onDelete: "cascade" })
.notNull(),
pathPattern: text("path_pattern").notNull(),
allowedRoleIds: text("allowed_role_ids").notNull().default("[]"),
allowedCertIds: text("allowed_cert_ids").notNull().default("[]"),
denyAll: integer("deny_all", { mode: "boolean" }).notNull().default(false),
priority: integer("priority").notNull().default(0),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({
proxyHostIdx: index("mtls_access_rules_proxy_host_idx").on(table.proxyHostId),
hostPathUnique: uniqueIndex("mtls_access_rules_host_path_unique").on(
table.proxyHostId,
table.pathPattern
)
})
);
export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
+194
View File
@@ -0,0 +1,194 @@
import db, { nowIso, toIso } from "../db";
import { applyCaddyConfig } from "../caddy";
import { logAuditEvent } from "../audit";
import { mtlsAccessRules } from "../db/schema";
import { asc, desc, eq, inArray } from "drizzle-orm";
// ── Types ────────────────────────────────────────────────────────────
export type MtlsAccessRule = {
id: number;
proxy_host_id: number;
path_pattern: string;
allowed_role_ids: number[];
allowed_cert_ids: number[];
deny_all: boolean;
priority: number;
description: string | null;
created_at: string;
updated_at: string;
};
export type MtlsAccessRuleInput = {
proxy_host_id: number;
path_pattern: string;
allowed_role_ids?: number[];
allowed_cert_ids?: number[];
deny_all?: boolean;
priority?: number;
description?: string | null;
};
// ── Helpers ──────────────────────────────────────────────────────────
type RuleRow = typeof mtlsAccessRules.$inferSelect;
function parseJsonIds(raw: string): number[] {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.filter((n: unknown) => typeof n === "number" && Number.isFinite(n));
} catch { /* ignore */ }
return [];
}
function toMtlsAccessRule(row: RuleRow): MtlsAccessRule {
return {
id: row.id,
proxy_host_id: row.proxyHostId,
path_pattern: row.pathPattern,
allowed_role_ids: parseJsonIds(row.allowedRoleIds),
allowed_cert_ids: parseJsonIds(row.allowedCertIds),
deny_all: row.denyAll,
priority: row.priority,
description: row.description,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!,
};
}
// ── CRUD ─────────────────────────────────────────────────────────────
export async function listMtlsAccessRules(proxyHostId: number): Promise<MtlsAccessRule[]> {
const rows = await db
.select()
.from(mtlsAccessRules)
.where(eq(mtlsAccessRules.proxyHostId, proxyHostId))
.orderBy(desc(mtlsAccessRules.priority), asc(mtlsAccessRules.pathPattern));
return rows.map(toMtlsAccessRule);
}
export async function getMtlsAccessRule(id: number): Promise<MtlsAccessRule | null> {
const row = await db.query.mtlsAccessRules.findFirst({
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
});
return row ? toMtlsAccessRule(row) : null;
}
export async function createMtlsAccessRule(
input: MtlsAccessRuleInput,
actorUserId: number
): Promise<MtlsAccessRule> {
const now = nowIso();
const [record] = await db
.insert(mtlsAccessRules)
.values({
proxyHostId: input.proxy_host_id,
pathPattern: input.path_pattern.trim(),
allowedRoleIds: JSON.stringify(input.allowed_role_ids ?? []),
allowedCertIds: JSON.stringify(input.allowed_cert_ids ?? []),
denyAll: input.deny_all ?? false,
priority: input.priority ?? 0,
description: input.description ?? null,
createdBy: actorUserId,
createdAt: now,
updatedAt: now,
})
.returning();
if (!record) throw new Error("Failed to create mTLS access rule");
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "mtls_access_rule",
entityId: record.id,
summary: `Created mTLS access rule for path ${input.path_pattern} on proxy host ${input.proxy_host_id}`,
});
await applyCaddyConfig();
return toMtlsAccessRule(record);
}
export async function updateMtlsAccessRule(
id: number,
input: Partial<Omit<MtlsAccessRuleInput, "proxy_host_id">>,
actorUserId: number
): Promise<MtlsAccessRule> {
const existing = await db.query.mtlsAccessRules.findFirst({
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
});
if (!existing) throw new Error("mTLS access rule not found");
const now = nowIso();
const updates: Partial<typeof mtlsAccessRules.$inferInsert> = { updatedAt: now };
if (input.path_pattern !== undefined) updates.pathPattern = input.path_pattern.trim();
if (input.allowed_role_ids !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowed_role_ids);
if (input.allowed_cert_ids !== undefined) updates.allowedCertIds = JSON.stringify(input.allowed_cert_ids);
if (input.deny_all !== undefined) updates.denyAll = input.deny_all;
if (input.priority !== undefined) updates.priority = input.priority;
if (input.description !== undefined) updates.description = input.description ?? null;
await db.update(mtlsAccessRules).set(updates).where(eq(mtlsAccessRules.id, id));
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "mtls_access_rule",
entityId: id,
summary: `Updated mTLS access rule for path ${input.path_pattern ?? existing.pathPattern}`,
});
await applyCaddyConfig();
return (await getMtlsAccessRule(id))!;
}
export async function deleteMtlsAccessRule(
id: number,
actorUserId: number
): Promise<void> {
const existing = await db.query.mtlsAccessRules.findFirst({
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
});
if (!existing) throw new Error("mTLS access rule not found");
await db.delete(mtlsAccessRules).where(eq(mtlsAccessRules.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",
entityType: "mtls_access_rule",
entityId: id,
summary: `Deleted mTLS access rule for path ${existing.pathPattern}`,
});
await applyCaddyConfig();
}
/**
* Bulk-query access rules for multiple proxy hosts at once.
* Used during Caddy config generation.
*/
export async function getAccessRulesForHosts(
proxyHostIds: number[]
): Promise<Map<number, MtlsAccessRule[]>> {
if (proxyHostIds.length === 0) return new Map();
const rows = await db
.select()
.from(mtlsAccessRules)
.where(inArray(mtlsAccessRules.proxyHostId, proxyHostIds))
.orderBy(desc(mtlsAccessRules.priority), asc(mtlsAccessRules.pathPattern));
const map = new Map<number, MtlsAccessRule[]>();
for (const row of rows) {
const parsed = toMtlsAccessRule(row);
let bucket = map.get(parsed.proxy_host_id);
if (!bucket) {
bucket = [];
map.set(parsed.proxy_host_id, bucket);
}
bucket.push(parsed);
}
return map;
}
+345
View File
@@ -0,0 +1,345 @@
import db, { nowIso, toIso } from "../db";
import { applyCaddyConfig } from "../caddy";
import { logAuditEvent } from "../audit";
import {
mtlsRoles,
mtlsCertificateRoles,
issuedClientCertificates,
} from "../db/schema";
import { asc, eq, inArray, count, and, isNull } from "drizzle-orm";
import { normalizeFingerprint } from "../caddy-mtls";
// ── Types ────────────────────────────────────────────────────────────
export type MtlsRole = {
id: number;
name: string;
description: string | null;
certificate_count: number;
created_at: string;
updated_at: string;
};
export type MtlsRoleInput = {
name: string;
description?: string | null;
};
export type MtlsRoleWithCertificates = MtlsRole & {
certificate_ids: number[];
};
// ── Helpers ──────────────────────────────────────────────────────────
type RoleRow = typeof mtlsRoles.$inferSelect;
async function countCertsForRole(roleId: number): Promise<number> {
const [row] = await db
.select({ value: count() })
.from(mtlsCertificateRoles)
.where(eq(mtlsCertificateRoles.mtlsRoleId, roleId));
return row?.value ?? 0;
}
function toMtlsRole(row: RoleRow, certCount: number): MtlsRole {
return {
id: row.id,
name: row.name,
description: row.description,
certificate_count: certCount,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!,
};
}
// ── CRUD ─────────────────────────────────────────────────────────────
export async function listMtlsRoles(): Promise<MtlsRole[]> {
const rows = await db.query.mtlsRoles.findMany({
orderBy: (table) => asc(table.name),
});
if (rows.length === 0) return [];
const roleIds = rows.map((r) => r.id);
const counts = await db
.select({
roleId: mtlsCertificateRoles.mtlsRoleId,
cnt: count(),
})
.from(mtlsCertificateRoles)
.where(inArray(mtlsCertificateRoles.mtlsRoleId, roleIds))
.groupBy(mtlsCertificateRoles.mtlsRoleId);
const countMap = new Map(counts.map((c) => [c.roleId, c.cnt]));
return rows.map((r) => toMtlsRole(r, countMap.get(r.id) ?? 0));
}
export async function getMtlsRole(id: number): Promise<MtlsRoleWithCertificates | null> {
const row = await db.query.mtlsRoles.findFirst({
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
});
if (!row) return null;
const assignments = await db
.select({ certId: mtlsCertificateRoles.issuedClientCertificateId })
.from(mtlsCertificateRoles)
.where(eq(mtlsCertificateRoles.mtlsRoleId, id));
return {
...toMtlsRole(row, assignments.length),
certificate_ids: assignments.map((a) => a.certId),
};
}
export async function createMtlsRole(
input: MtlsRoleInput,
actorUserId: number
): Promise<MtlsRole> {
const now = nowIso();
const [record] = await db
.insert(mtlsRoles)
.values({
name: input.name.trim(),
description: input.description ?? null,
createdBy: actorUserId,
createdAt: now,
updatedAt: now,
})
.returning();
if (!record) throw new Error("Failed to create mTLS role");
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "mtls_role",
entityId: record.id,
summary: `Created mTLS role ${input.name}`,
});
return toMtlsRole(record, 0);
}
export async function updateMtlsRole(
id: number,
input: Partial<MtlsRoleInput>,
actorUserId: number
): Promise<MtlsRole> {
const existing = await db.query.mtlsRoles.findFirst({
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
});
if (!existing) throw new Error("mTLS role not found");
const now = nowIso();
await db
.update(mtlsRoles)
.set({
name: input.name?.trim() ?? existing.name,
description: input.description !== undefined ? (input.description ?? null) : existing.description,
updatedAt: now,
})
.where(eq(mtlsRoles.id, id));
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "mtls_role",
entityId: id,
summary: `Updated mTLS role ${input.name?.trim() ?? existing.name}`,
});
await applyCaddyConfig();
const certCount = await countCertsForRole(id);
const updated = await db.query.mtlsRoles.findFirst({
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
});
return toMtlsRole(updated!, certCount);
}
export async function deleteMtlsRole(id: number, actorUserId: number): Promise<void> {
const existing = await db.query.mtlsRoles.findFirst({
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
});
if (!existing) throw new Error("mTLS role not found");
await db.delete(mtlsRoles).where(eq(mtlsRoles.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",
entityType: "mtls_role",
entityId: id,
summary: `Deleted mTLS role ${existing.name}`,
});
await applyCaddyConfig();
}
// ── Certificate ↔ Role assignments ───────────────────────────────────
export async function assignRoleToCertificate(
roleId: number,
certId: number,
actorUserId: number
): Promise<void> {
const role = await db.query.mtlsRoles.findFirst({
where: (t, { eq: cmpEq }) => cmpEq(t.id, roleId),
});
if (!role) throw new Error("mTLS role not found");
const cert = await db.query.issuedClientCertificates.findFirst({
where: (t, { eq: cmpEq }) => cmpEq(t.id, certId),
});
if (!cert) throw new Error("Issued client certificate not found");
const now = nowIso();
await db
.insert(mtlsCertificateRoles)
.values({
issuedClientCertificateId: certId,
mtlsRoleId: roleId,
createdAt: now,
});
logAuditEvent({
userId: actorUserId,
action: "assign",
entityType: "mtls_certificate_role",
entityId: roleId,
summary: `Assigned cert ${cert.commonName} to role ${role.name}`,
data: { roleId, certId },
});
await applyCaddyConfig();
}
export async function removeRoleFromCertificate(
roleId: number,
certId: number,
actorUserId: number
): Promise<void> {
const role = await db.query.mtlsRoles.findFirst({
where: (t, { eq: cmpEq }) => cmpEq(t.id, roleId),
});
if (!role) throw new Error("mTLS role not found");
await db
.delete(mtlsCertificateRoles)
.where(
and(
eq(mtlsCertificateRoles.mtlsRoleId, roleId),
eq(mtlsCertificateRoles.issuedClientCertificateId, certId)
)
);
logAuditEvent({
userId: actorUserId,
action: "unassign",
entityType: "mtls_certificate_role",
entityId: roleId,
summary: `Removed cert from role ${role.name}`,
data: { roleId, certId },
});
await applyCaddyConfig();
}
export async function getCertificateRoles(certId: number): Promise<MtlsRole[]> {
const assignments = await db
.select({ roleId: mtlsCertificateRoles.mtlsRoleId })
.from(mtlsCertificateRoles)
.where(eq(mtlsCertificateRoles.issuedClientCertificateId, certId));
if (assignments.length === 0) return [];
const roleIds = assignments.map((a) => a.roleId);
const rows = await db
.select()
.from(mtlsRoles)
.where(inArray(mtlsRoles.id, roleIds))
.orderBy(asc(mtlsRoles.name));
return rows.map((r) => toMtlsRole(r, 0));
}
/**
* Builds a map of roleId → Set<normalizedFingerprint> for all active (non-revoked) certs.
* Used during Caddy config generation.
*/
export async function buildRoleFingerprintMap(): Promise<Map<number, Set<string>>> {
const rows = await db
.select({
roleId: mtlsCertificateRoles.mtlsRoleId,
fingerprint: issuedClientCertificates.fingerprintSha256,
})
.from(mtlsCertificateRoles)
.innerJoin(
issuedClientCertificates,
eq(mtlsCertificateRoles.issuedClientCertificateId, issuedClientCertificates.id)
)
.where(isNull(issuedClientCertificates.revokedAt));
const map = new Map<number, Set<string>>();
for (const row of rows) {
let set = map.get(row.roleId);
if (!set) {
set = new Set();
map.set(row.roleId, set);
}
set.add(normalizeFingerprint(row.fingerprint));
}
return map;
}
/**
* Builds a map of certId → normalizedFingerprint for all active (non-revoked) certs.
* Used during Caddy config generation for direct cert overrides.
*/
export async function buildCertFingerprintMap(): Promise<Map<number, string>> {
const rows = await db
.select({
id: issuedClientCertificates.id,
fingerprint: issuedClientCertificates.fingerprintSha256,
})
.from(issuedClientCertificates)
.where(isNull(issuedClientCertificates.revokedAt));
const map = new Map<number, string>();
for (const row of rows) {
map.set(row.id, normalizeFingerprint(row.fingerprint));
}
return map;
}
/**
* Builds a map of roleId → Set<certId> for all active (non-revoked) certs.
* Used during Caddy config generation to resolve trusted_role_ids → cert IDs.
*/
export async function buildRoleCertIdMap(): Promise<Map<number, Set<number>>> {
const rows = await db
.select({
roleId: mtlsCertificateRoles.mtlsRoleId,
certId: mtlsCertificateRoles.issuedClientCertificateId,
})
.from(mtlsCertificateRoles)
.innerJoin(
issuedClientCertificates,
eq(mtlsCertificateRoles.issuedClientCertificateId, issuedClientCertificates.id)
)
.where(isNull(issuedClientCertificates.revokedAt));
const map = new Map<number, Set<number>>();
for (const row of rows) {
let set = map.get(row.roleId);
if (!set) {
set = new Set();
map.set(row.roleId, set);
}
set.add(row.certId);
}
return map;
}
// normalizeFingerprint is imported from caddy-mtls.ts (the canonical location)
// and re-exported for convenience.
export { normalizeFingerprint } from "../caddy-mtls";
+6 -1
View File
@@ -218,7 +218,12 @@ type ProxyHostAuthentikMeta = {
export type MtlsConfig = {
enabled: boolean;
ca_certificate_ids: number[];
/** Trust specific issued client certificates (derives CAs automatically) */
trusted_client_cert_ids?: number[];
/** Trust all certificates belonging to these roles */
trusted_role_ids?: number[];
/** @deprecated Old model: trust entire CAs. Kept for backward compat migration. */
ca_certificate_ids?: number[];
};
type ProxyHostMeta = {