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

@@ -4,13 +4,15 @@ import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/ui/PageHeader";
import { SearchField } from "@/components/ui/SearchField";
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView, MtlsRole } from "./page";
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
import { StatusSummaryBar } from "./components/StatusSummaryBar";
import { AcmeTab } from "./components/AcmeTab";
import { ImportedTab } from "./components/ImportedTab";
import { CaTab } from "./components/CaTab";
import { MtlsRolesTab } from "@/components/mtls-roles/MtlsRolesTab";
type TabId = "acme" | "imported" | "ca";
type TabId = "acme" | "imported" | "ca" | "roles";
type Props = {
acmeHosts: AcmeHost[];
@@ -18,6 +20,8 @@ type Props = {
managedCerts: ManagedCertView[];
caCertificates: CaCertificateView[];
acmePagination: { total: number; page: number; perPage: number };
mtlsRoles: MtlsRole[];
issuedClientCerts: IssuedClientCertificate[];
};
function countExpiry(statuses: (CertExpiryStatus | null)[]) {
@@ -36,11 +40,14 @@ export default function CertificatesClient({
managedCerts,
caCertificates,
acmePagination,
mtlsRoles,
issuedClientCerts,
}: Props) {
const [activeTab, setActiveTab] = useState<TabId>("acme");
const [searchAcme, setSearchAcme] = useState("");
const [searchImported, setSearchImported] = useState("");
const [searchCa, setSearchCa] = useState("");
const [searchRoles, setSearchRoles] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const allStatuses: (CertExpiryStatus | null)[] = [
@@ -48,8 +55,8 @@ export default function CertificatesClient({
];
const { expired, expiringSoon, healthy } = countExpiry(allStatuses);
const search = activeTab === "acme" ? searchAcme : activeTab === "imported" ? searchImported : searchCa;
const setSearch = activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : setSearchCa;
const search = activeTab === "acme" ? searchAcme : activeTab === "imported" ? searchImported : activeTab === "roles" ? searchRoles : searchCa;
const setSearch = activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : activeTab === "roles" ? setSearchRoles : setSearchCa;
function handleTabChange(value: string) {
setActiveTab(value as TabId);
@@ -94,6 +101,12 @@ export default function CertificatesClient({
{caCertificates.length}
</span>
</TabsTrigger>
<TabsTrigger value="roles" className="gap-1.5">
Roles
<span className="rounded-full bg-muted px-1.5 py-0 text-xs font-bold tabular-nums">
{mtlsRoles.length}
</span>
</TabsTrigger>
</TabsList>
<SearchField
@@ -134,6 +147,13 @@ export default function CertificatesClient({
statusFilter={statusFilter}
/>
</TabsContent>
<TabsContent value="roles" className="mt-4">
<MtlsRolesTab
roles={mtlsRoles}
issuedCerts={issuedClientCerts}
search={searchRoles}
/>
</TabsContent>
</Tabs>
</div>
);

View File

@@ -6,9 +6,11 @@ import { requireAdmin } from '@/src/lib/auth';
import CertificatesClient from './CertificatesClient';
import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates';
import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates';
import { listMtlsRoles, type MtlsRole } from '@/src/lib/models/mtls-roles';
export type { CaCertificate };
export type { IssuedClientCertificate };
export type { MtlsRole };
export type CaCertificateView = CaCertificate & {
issuedCerts: IssuedClientCertificate[];
@@ -83,8 +85,9 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
const offset = (page - 1) * PER_PAGE;
const [caCerts, issuedClientCerts] = await Promise.all([
listCaCertificates(),
listIssuedClientCertificates()
listIssuedClientCertificates(),
]);
const mtlsRoles = await listMtlsRoles().catch(() => []);
const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([
db
@@ -176,6 +179,8 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
managedCerts={managedCerts}
caCertificates={caCertificateViews}
acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }}
mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts}
/>
);
}

View File

@@ -8,6 +8,8 @@ import type { Certificate } from "@/lib/models/certificates";
import type { ProxyHost } from "@/lib/models/proxy-hosts";
import type { CaCertificate } from "@/lib/models/ca-certificates";
import type { AuthentikSettings } from "@/lib/settings";
import type { MtlsRole } from "@/lib/models/mtls-roles";
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
import { toggleProxyHostAction } from "./actions";
import { PageHeader } from "@/components/ui/PageHeader";
import { SearchField } from "@/components/ui/SearchField";
@@ -35,13 +37,17 @@ type Props = {
pagination: { total: number; page: number; perPage: number };
initialSearch: string;
initialSort?: { sortBy: string; sortDir: "asc" | "desc" };
mtlsRoles?: MtlsRole[];
issuedClientCerts?: IssuedClientCertificate[];
};
export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch, initialSort }: Props) {
export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch, initialSort, mtlsRoles, issuedClientCerts }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [duplicateHost, setDuplicateHost] = useState<ProxyHost | null>(null);
const [editHost, setEditHost] = useState<ProxyHost | null>(null);
const [deleteHost, setDeleteHost] = useState<ProxyHost | null>(null);
// Counter forces CreateHostDialog to remount on each open, resetting useFormState
const [dialogKey, setDialogKey] = useState(0);
const [searchTerm, setSearchTerm] = useState(initialSearch);
const router = useRouter();
@@ -200,7 +206,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditHost(host)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDuplicateHost(host); setCreateOpen(true); }}>Duplicate</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDuplicateHost(host); { setDialogKey(k => k + 1); setCreateOpen(true); }; }}>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
@@ -248,7 +254,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditHost(host)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDuplicateHost(host); setCreateOpen(true); }}>Duplicate</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDuplicateHost(host); { setDialogKey(k => k + 1); setCreateOpen(true); }; }}>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setDeleteHost(host)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
@@ -264,7 +270,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
<PageHeader
title="Proxy Hosts"
description="Define HTTP(S) reverse proxies orchestrated by Caddy with automated certificates."
action={{ label: "Create Host", onClick: () => setCreateOpen(true) }}
action={{ label: "Create Host", onClick: () => { setDialogKey(k => k + 1); setCreateOpen(true); } }}
/>
<div className="flex items-center gap-2">
@@ -287,6 +293,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
/>
<CreateHostDialog
key={dialogKey}
open={createOpen}
onClose={() => { setCreateOpen(false); setTimeout(() => setDuplicateHost(null), 200); }}
initialData={duplicateHost}
@@ -294,6 +301,8 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
accessLists={accessLists}
authentikDefaults={authentikDefaults}
caCertificates={caCertificates}
mtlsRoles={mtlsRoles ?? []}
issuedClientCerts={issuedClientCerts ?? []}
/>
{editHost && (
@@ -304,6 +313,8 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
certificates={certificates}
accessLists={accessLists}
caCertificates={caCertificates}
mtlsRoles={mtlsRoles ?? []}
issuedClientCerts={issuedClientCerts ?? []}
/>
)}

View File

@@ -394,8 +394,9 @@ function parseMtlsConfig(formData: FormData): MtlsConfig | null {
if (!formData.has("mtls_present")) return null;
const enabled = formData.get("mtls_enabled") === "true";
if (!enabled) return null;
const ids = formData.getAll("mtls_ca_cert_id").map(Number).filter(n => Number.isFinite(n) && n > 0);
return { enabled, ca_certificate_ids: ids };
const certIds = formData.getAll("mtls_cert_id").map(Number).filter(n => Number.isFinite(n) && n > 0);
const roleIds = formData.getAll("mtls_role_id").map(Number).filter(n => Number.isFinite(n) && n > 0);
return { enabled, trusted_client_cert_ids: certIds, trusted_role_ids: roleIds };
}
function parseRedirectsConfig(formData: FormData): RedirectRule[] | null {

View File

@@ -4,6 +4,8 @@ import { listCertificates } from "@/src/lib/models/certificates";
import { listCaCertificates } from "@/src/lib/models/ca-certificates";
import { listAccessLists } from "@/src/lib/models/access-lists";
import { getAuthentikSettings } from "@/src/lib/settings";
import { listMtlsRoles } from "@/src/lib/models/mtls-roles";
import { listIssuedClientCertificates } from "@/src/lib/models/issued-client-certificates";
import { requireAdmin } from "@/src/lib/auth";
const PER_PAGE = 25;
@@ -29,6 +31,11 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
listAccessLists(),
getAuthentikSettings(),
]);
// These are safe to fail if the RBAC migration hasn't been applied yet
const [mtlsRoles, issuedClientCerts] = await Promise.all([
listMtlsRoles().catch(() => []),
listIssuedClientCertificates().catch(() => []),
]);
return (
<ProxyHostsClient
@@ -40,6 +47,8 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts}
/>
);
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { getCertificateRoles } from "@/src/lib/models/mtls-roles";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireApiAdmin(request);
const { id } = await params;
const roles = await getCertificateRoles(Number(id));
return NextResponse.json(roles);
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { removeRoleFromCertificate } from "@/src/lib/models/mtls-roles";
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; certId: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id, certId } = await params;
await removeRoleFromCertificate(Number(id), Number(certId), userId);
return NextResponse.json({ ok: true });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { assignRoleToCertificate, getMtlsRole } from "@/src/lib/models/mtls-roles";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
const body = await request.json();
if (!body.certificate_id || typeof body.certificate_id !== "number") {
return NextResponse.json({ error: "certificate_id is required" }, { status: 400 });
}
await assignRoleToCertificate(Number(id), body.certificate_id, userId);
const role = await getMtlsRole(Number(id));
return NextResponse.json(role, { status: 201 });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { getMtlsRole, updateMtlsRole, deleteMtlsRole } from "@/src/lib/models/mtls-roles";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireApiAdmin(request);
const { id } = await params;
const role = await getMtlsRole(Number(id));
if (!role) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(role);
} catch (error) {
return apiErrorResponse(error);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
const body = await request.json();
const role = await updateMtlsRole(Number(id), body, userId);
return NextResponse.json(role);
} catch (error) {
return apiErrorResponse(error);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
await deleteMtlsRole(Number(id), userId);
return NextResponse.json({ ok: true });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { listMtlsRoles, createMtlsRole } from "@/src/lib/models/mtls-roles";
export async function GET(request: NextRequest) {
try {
await requireApiAdmin(request);
const roles = await listMtlsRoles();
return NextResponse.json(roles);
} catch (error) {
return apiErrorResponse(error);
}
}
export async function POST(request: NextRequest) {
try {
const { userId } = await requireApiAdmin(request);
const body = await request.json();
if (!body.name || typeof body.name !== "string" || !body.name.trim()) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const role = await createMtlsRole(body, userId);
return NextResponse.json(role, { status: 201 });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { getMtlsAccessRule, updateMtlsAccessRule, deleteMtlsAccessRule } from "@/src/lib/models/mtls-access-rules";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; ruleId: string }> }
) {
try {
await requireApiAdmin(request);
const { ruleId } = await params;
const rule = await getMtlsAccessRule(Number(ruleId));
if (!rule) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(rule);
} catch (error) {
return apiErrorResponse(error);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string; ruleId: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { ruleId } = await params;
const body = await request.json();
const rule = await updateMtlsAccessRule(Number(ruleId), body, userId);
return NextResponse.json(rule);
} catch (error) {
return apiErrorResponse(error);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; ruleId: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { ruleId } = await params;
await deleteMtlsAccessRule(Number(ruleId), userId);
return NextResponse.json({ ok: true });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { listMtlsAccessRules, createMtlsAccessRule } from "@/src/lib/models/mtls-access-rules";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireApiAdmin(request);
const { id } = await params;
const rules = await listMtlsAccessRules(Number(id));
return NextResponse.json(rules);
} catch (error) {
return apiErrorResponse(error);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
const body = await request.json();
if (!body.path_pattern || typeof body.path_pattern !== "string" || !body.path_pattern.trim()) {
return NextResponse.json({ error: "path_pattern is required" }, { status: 400 });
}
const rule = await createMtlsAccessRule(
{ ...body, proxy_host_id: Number(id) },
userId
);
return NextResponse.json(rule, { status: 201 });
} catch (error) {
return apiErrorResponse(error);
}
}