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

View File

@@ -0,0 +1,41 @@
-- mTLS RBAC: roles, certificate-role assignments, and path-based access rules
CREATE TABLE `mtls_roles` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`created_by` integer REFERENCES `users`(`id`) ON DELETE SET NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `mtls_roles_name_unique` ON `mtls_roles` (`name`);
--> statement-breakpoint
CREATE TABLE `mtls_certificate_roles` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`issued_client_certificate_id` integer NOT NULL REFERENCES `issued_client_certificates`(`id`) ON DELETE CASCADE,
`mtls_role_id` integer NOT NULL REFERENCES `mtls_roles`(`id`) ON DELETE CASCADE,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `mtls_cert_role_unique` ON `mtls_certificate_roles` (`issued_client_certificate_id`, `mtls_role_id`);
--> statement-breakpoint
CREATE INDEX `mtls_certificate_roles_role_idx` ON `mtls_certificate_roles` (`mtls_role_id`);
--> statement-breakpoint
CREATE TABLE `mtls_access_rules` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`proxy_host_id` integer NOT NULL REFERENCES `proxy_hosts`(`id`) ON DELETE CASCADE,
`path_pattern` text NOT NULL,
`allowed_role_ids` text NOT NULL DEFAULT '[]',
`allowed_cert_ids` text NOT NULL DEFAULT '[]',
`deny_all` integer NOT NULL DEFAULT 0,
`priority` integer NOT NULL DEFAULT 0,
`description` text,
`created_by` integer REFERENCES `users`(`id`) ON DELETE SET NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `mtls_access_rules_proxy_host_idx` ON `mtls_access_rules` (`proxy_host_id`);
--> statement-breakpoint
CREATE UNIQUE INDEX `mtls_access_rules_host_path_unique` ON `mtls_access_rules` (`proxy_host_id`, `path_pattern`);

View File

@@ -113,6 +113,13 @@
"when": 1774300000000,
"tag": "0015_l4_proxy_hosts",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1775400000000,
"tag": "0016_mtls_rbac",
"breakpoints": true
}
]
}

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

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

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

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

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(),

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

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";

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 = {

View File

@@ -0,0 +1,300 @@
/**
* Integration tests for src/lib/models/mtls-access-rules.ts
* Tests all CRUD operations and the bulk query function.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import {
mtlsAccessRules,
proxyHosts,
users,
} from '../../src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
vi.mock('../../src/lib/db', async () => ({
get default() { return db; },
nowIso: () => new Date().toISOString(),
toIso: (v: string | null) => v,
}));
vi.mock('../../src/lib/caddy', () => ({ applyCaddyConfig: vi.fn() }));
vi.mock('../../src/lib/audit', () => ({ logAuditEvent: vi.fn() }));
let userId: number;
beforeEach(async () => {
db = createTestDb();
vi.clearAllMocks();
const now = new Date().toISOString();
const [user] = await db.insert(users).values({
email: 'admin@test', name: 'Admin', role: 'admin',
provider: 'credentials', subject: 'admin@test', status: 'active',
createdAt: now, updatedAt: now,
}).returning();
userId = user.id;
});
function nowIso() { return new Date().toISOString(); }
async function insertHost(name = 'test-host') {
const now = nowIso();
const [host] = await db.insert(proxyHosts).values({
name, domains: '["test.example.com"]', upstreams: '["http://localhost:8080"]',
createdAt: now, updatedAt: now,
}).returning();
return host;
}
const {
listMtlsAccessRules,
getMtlsAccessRule,
createMtlsAccessRule,
updateMtlsAccessRule,
deleteMtlsAccessRule,
getAccessRulesForHosts,
} = await import('../../src/lib/models/mtls-access-rules');
describe('mtls-access-rules CRUD', () => {
it('createMtlsAccessRule creates a rule', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '/admin/*',
allowed_role_ids: [1, 2],
allowed_cert_ids: [10],
priority: 5,
description: 'admin only',
}, userId);
expect(rule.proxy_host_id).toBe(host.id);
expect(rule.path_pattern).toBe('/admin/*');
expect(rule.allowed_role_ids).toEqual([1, 2]);
expect(rule.allowed_cert_ids).toEqual([10]);
expect(rule.priority).toBe(5);
expect(rule.description).toBe('admin only');
expect(rule.deny_all).toBe(false);
});
it('createMtlsAccessRule trims path_pattern', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: ' /api/* ',
}, userId);
expect(rule.path_pattern).toBe('/api/*');
});
it('createMtlsAccessRule defaults arrays to empty', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '*',
}, userId);
expect(rule.allowed_role_ids).toEqual([]);
expect(rule.allowed_cert_ids).toEqual([]);
expect(rule.deny_all).toBe(false);
expect(rule.priority).toBe(0);
});
it('createMtlsAccessRule with deny_all', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '/blocked/*',
deny_all: true,
}, userId);
expect(rule.deny_all).toBe(true);
});
it('listMtlsAccessRules returns rules ordered by priority desc then path asc', async () => {
const host = await insertHost();
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/b', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/a', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/c', priority: 1 }, userId);
const rules = await listMtlsAccessRules(host.id);
expect(rules).toHaveLength(3);
expect(rules[0].path_pattern).toBe('/a'); // priority 10 (highest)
expect(rules[1].path_pattern).toBe('/b'); // priority 1, path /b
expect(rules[2].path_pattern).toBe('/c'); // priority 1, path /c
});
it('listMtlsAccessRules returns empty array for host with no rules', async () => {
const host = await insertHost();
const rules = await listMtlsAccessRules(host.id);
expect(rules).toEqual([]);
});
it('listMtlsAccessRules only returns rules for the specified host', async () => {
const host1 = await insertHost('h1');
const host2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: host1.id, path_pattern: '/h1' }, userId);
await createMtlsAccessRule({ proxy_host_id: host2.id, path_pattern: '/h2' }, userId);
const rules = await listMtlsAccessRules(host1.id);
expect(rules).toHaveLength(1);
expect(rules[0].path_pattern).toBe('/h1');
});
it('getMtlsAccessRule returns a single rule', async () => {
const host = await insertHost();
const created = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
}, userId);
const fetched = await getMtlsAccessRule(created.id);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id);
expect(fetched!.path_pattern).toBe('/test');
});
it('getMtlsAccessRule returns null for non-existent rule', async () => {
expect(await getMtlsAccessRule(999)).toBeNull();
});
it('updateMtlsAccessRule updates fields', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/old', priority: 0,
}, userId);
const updated = await updateMtlsAccessRule(rule.id, {
path_pattern: '/new',
priority: 99,
allowed_role_ids: [5],
deny_all: true,
description: 'updated',
}, userId);
expect(updated.path_pattern).toBe('/new');
expect(updated.priority).toBe(99);
expect(updated.allowed_role_ids).toEqual([5]);
expect(updated.deny_all).toBe(true);
expect(updated.description).toBe('updated');
});
it('updateMtlsAccessRule partial update leaves other fields unchanged', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
allowed_role_ids: [1], priority: 5, description: 'original',
}, userId);
const updated = await updateMtlsAccessRule(rule.id, { priority: 10 }, userId);
expect(updated.path_pattern).toBe('/test');
expect(updated.allowed_role_ids).toEqual([1]);
expect(updated.description).toBe('original');
expect(updated.priority).toBe(10);
});
it('updateMtlsAccessRule throws for non-existent rule', async () => {
await expect(updateMtlsAccessRule(999, { priority: 1 }, 1)).rejects.toThrow();
});
it('deleteMtlsAccessRule removes the rule', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
}, userId);
await deleteMtlsAccessRule(rule.id, 1);
expect(await getMtlsAccessRule(rule.id)).toBeNull();
});
it('deleteMtlsAccessRule throws for non-existent rule', async () => {
await expect(deleteMtlsAccessRule(999, 1)).rejects.toThrow();
});
});
describe('getAccessRulesForHosts (bulk query)', () => {
it('returns empty map for empty host list', async () => {
const map = await getAccessRulesForHosts([]);
expect(map.size).toBe(0);
});
it('returns empty map when no rules exist', async () => {
const host = await insertHost();
const map = await getAccessRulesForHosts([host.id]);
expect(map.size).toBe(0);
});
it('groups rules by proxy host ID', async () => {
const h1 = await insertHost('h1');
const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/b' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/c' }, userId);
const map = await getAccessRulesForHosts([h1.id, h2.id]);
expect(map.get(h1.id)).toHaveLength(2);
expect(map.get(h2.id)).toHaveLength(1);
});
it('excludes hosts not in the query list', async () => {
const h1 = await insertHost('h1');
const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/b' }, userId);
const map = await getAccessRulesForHosts([h1.id]);
expect(map.has(h1.id)).toBe(true);
expect(map.has(h2.id)).toBe(false);
});
it('rules within a host are ordered by priority desc, path asc', async () => {
const h = await insertHost();
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/z', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/a', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/m', priority: 10 }, userId);
const map = await getAccessRulesForHosts([h.id]);
const rules = map.get(h.id)!;
expect(rules[0].path_pattern).toBe('/m'); // priority 10, path /m
expect(rules[1].path_pattern).toBe('/z'); // priority 10, path /z
expect(rules[2].path_pattern).toBe('/a'); // priority 1
});
});
describe('JSON parsing edge cases in access rules', () => {
it('handles malformed allowed_role_ids JSON gracefully', async () => {
const host = await insertHost();
const now = nowIso();
// Insert directly with bad JSON
await db.insert(mtlsAccessRules).values({
proxyHostId: host.id, pathPattern: '/test',
allowedRoleIds: 'not-json', allowedCertIds: '[]',
createdAt: now, updatedAt: now,
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([]);
});
it('filters non-numeric values from JSON arrays', async () => {
const host = await insertHost();
const now = nowIso();
await db.insert(mtlsAccessRules).values({
proxyHostId: host.id, pathPattern: '/test',
allowedRoleIds: '[1, "hello", null, 3]', allowedCertIds: '[]',
createdAt: now, updatedAt: now,
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([1, 3]);
});
it('handles non-array JSON', async () => {
const host = await insertHost();
const now = nowIso();
await db.insert(mtlsAccessRules).values({
proxyHostId: host.id, pathPattern: '/test',
allowedRoleIds: '{"foo": 1}', allowedCertIds: '"string"',
createdAt: now, updatedAt: now,
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([]);
expect(rules[0].allowed_cert_ids).toEqual([]);
});
});

View File

@@ -0,0 +1,428 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import {
mtlsRoles,
mtlsCertificateRoles,
mtlsAccessRules,
issuedClientCertificates,
caCertificates,
proxyHosts,
} from '@/src/lib/db/schema';
import { eq, and } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertCaCert(name = 'Test CA') {
const now = nowIso();
const [ca] = await db.insert(caCertificates).values({
name,
certificatePem: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----',
createdAt: now,
updatedAt: now,
}).returning();
return ca;
}
async function insertClientCert(caCertId: number, cn = 'test-client', fingerprint = 'AABB') {
const now = nowIso();
const [cert] = await db.insert(issuedClientCertificates).values({
caCertificateId: caCertId,
commonName: cn,
serialNumber: Date.now().toString(16),
fingerprintSha256: fingerprint,
certificatePem: '-----BEGIN CERTIFICATE-----\nCLIENT\n-----END CERTIFICATE-----',
validFrom: now,
validTo: now,
createdAt: now,
updatedAt: now,
}).returning();
return cert;
}
async function insertRole(name = 'admin') {
const now = nowIso();
const [role] = await db.insert(mtlsRoles).values({
name,
description: null,
createdAt: now,
updatedAt: now,
}).returning();
return role;
}
async function insertProxyHost(name = 'test-host') {
const now = nowIso();
const [host] = await db.insert(proxyHosts).values({
name,
domains: '["test.example.com"]',
upstreams: '["http://localhost:8080"]',
meta: JSON.stringify({ mtls: { enabled: true, ca_certificate_ids: [1] } }),
createdAt: now,
updatedAt: now,
}).returning();
return host;
}
// ── mtlsRoles ────────────────────────────────────────────────────────
describe('mtls_roles table', () => {
it('creates a role with unique name', async () => {
const role = await insertRole('admin');
expect(role.name).toBe('admin');
expect(role.id).toBeGreaterThan(0);
});
it('enforces unique name constraint', async () => {
await insertRole('admin');
await expect(insertRole('admin')).rejects.toThrow();
});
it('supports description field', async () => {
const now = nowIso();
const [role] = await db.insert(mtlsRoles).values({
name: 'viewer',
description: 'Read-only access',
createdAt: now,
updatedAt: now,
}).returning();
expect(role.description).toBe('Read-only access');
});
});
// ── mtlsCertificateRoles ─────────────────────────────────────────────
describe('mtls_certificate_roles table', () => {
it('assigns a cert to a role', async () => {
const ca = await insertCaCert();
const cert = await insertClientCert(ca.id);
const role = await insertRole();
const now = nowIso();
const [assignment] = await db.insert(mtlsCertificateRoles).values({
issuedClientCertificateId: cert.id,
mtlsRoleId: role.id,
createdAt: now,
}).returning();
expect(assignment.issuedClientCertificateId).toBe(cert.id);
expect(assignment.mtlsRoleId).toBe(role.id);
});
it('enforces unique constraint on (cert, role) pair', async () => {
const ca = await insertCaCert();
const cert = await insertClientCert(ca.id);
const role = await insertRole();
const now = nowIso();
await db.insert(mtlsCertificateRoles).values({
issuedClientCertificateId: cert.id,
mtlsRoleId: role.id,
createdAt: now,
});
await expect(
db.insert(mtlsCertificateRoles).values({
issuedClientCertificateId: cert.id,
mtlsRoleId: role.id,
createdAt: now,
})
).rejects.toThrow();
});
it('cascades on role deletion', async () => {
const ca = await insertCaCert();
const cert = await insertClientCert(ca.id);
const role = await insertRole();
const now = nowIso();
await db.insert(mtlsCertificateRoles).values({
issuedClientCertificateId: cert.id,
mtlsRoleId: role.id,
createdAt: now,
});
await db.delete(mtlsRoles).where(eq(mtlsRoles.id, role.id));
const remaining = await db.select().from(mtlsCertificateRoles);
expect(remaining.length).toBe(0);
});
it('cascades on cert deletion', async () => {
const ca = await insertCaCert();
const cert = await insertClientCert(ca.id);
const role = await insertRole();
const now = nowIso();
await db.insert(mtlsCertificateRoles).values({
issuedClientCertificateId: cert.id,
mtlsRoleId: role.id,
createdAt: now,
});
await db.delete(issuedClientCertificates).where(eq(issuedClientCertificates.id, cert.id));
const remaining = await db.select().from(mtlsCertificateRoles);
expect(remaining.length).toBe(0);
});
});
// ── mtlsAccessRules ──────────────────────────────────────────────────
describe('mtls_access_rules table', () => {
it('creates an access rule for a proxy host', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [rule] = await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/admin/*',
allowedRoleIds: JSON.stringify([1, 2]),
allowedCertIds: JSON.stringify([]),
denyAll: false,
priority: 10,
createdAt: now,
updatedAt: now,
}).returning();
expect(rule.pathPattern).toBe('/admin/*');
expect(rule.priority).toBe(10);
expect(JSON.parse(rule.allowedRoleIds)).toEqual([1, 2]);
});
it('enforces unique (proxyHostId, pathPattern)', async () => {
const host = await insertProxyHost();
const now = nowIso();
await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/admin/*',
createdAt: now,
updatedAt: now,
});
await expect(
db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/admin/*',
createdAt: now,
updatedAt: now,
})
).rejects.toThrow();
});
it('allows same path on different hosts', async () => {
const host1 = await insertProxyHost('host-1');
const host2 = await insertProxyHost('host-2');
const now = nowIso();
await db.insert(mtlsAccessRules).values({
proxyHostId: host1.id,
pathPattern: '/admin/*',
createdAt: now,
updatedAt: now,
});
const [rule2] = await db.insert(mtlsAccessRules).values({
proxyHostId: host2.id,
pathPattern: '/admin/*',
createdAt: now,
updatedAt: now,
}).returning();
expect(rule2.proxyHostId).toBe(host2.id);
});
it('cascades on proxy host deletion', async () => {
const host = await insertProxyHost();
const now = nowIso();
await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/admin/*',
createdAt: now,
updatedAt: now,
});
await db.delete(proxyHosts).where(eq(proxyHosts.id, host.id));
const remaining = await db.select().from(mtlsAccessRules);
expect(remaining.length).toBe(0);
});
it('stores deny_all flag correctly', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [rule] = await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/blocked/*',
denyAll: true,
createdAt: now,
updatedAt: now,
}).returning();
expect(rule.denyAll).toBe(true);
});
it('defaults allowed_role_ids and allowed_cert_ids to "[]"', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [rule] = await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/test',
createdAt: now,
updatedAt: now,
}).returning();
expect(rule.allowedRoleIds).toBe('[]');
expect(rule.allowedCertIds).toBe('[]');
});
it('defaults deny_all to false and priority to 0', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [rule] = await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/test',
createdAt: now,
updatedAt: now,
}).returning();
expect(rule.denyAll).toBe(false);
expect(rule.priority).toBe(0);
});
it('stores JSON arrays with numbers in allowed_role_ids', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [rule] = await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/test',
allowedRoleIds: JSON.stringify([1, 2, 3]),
allowedCertIds: JSON.stringify([10, 20]),
createdAt: now,
updatedAt: now,
}).returning();
expect(JSON.parse(rule.allowedRoleIds)).toEqual([1, 2, 3]);
expect(JSON.parse(rule.allowedCertIds)).toEqual([10, 20]);
});
it('supports description field', async () => {
const host = await insertProxyHost();
const now = nowIso();
const [rule] = await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/test',
description: 'Only for admins',
createdAt: now,
updatedAt: now,
}).returning();
expect(rule.description).toBe('Only for admins');
});
it('supports multiple rules with different priorities on same host', async () => {
const host = await insertProxyHost();
const now = nowIso();
await db.insert(mtlsAccessRules).values({ proxyHostId: host.id, pathPattern: '/a', priority: 1, createdAt: now, updatedAt: now });
await db.insert(mtlsAccessRules).values({ proxyHostId: host.id, pathPattern: '/b', priority: 100, createdAt: now, updatedAt: now });
await db.insert(mtlsAccessRules).values({ proxyHostId: host.id, pathPattern: '/c', priority: 50, createdAt: now, updatedAt: now });
const rows = await db.select().from(mtlsAccessRules);
expect(rows).toHaveLength(3);
});
});
// ── Additional schema relationship tests ─────────────────────────────
describe('cross-table relationships', () => {
it('cascades CA deletion through issued certs to certificate_roles', async () => {
const ca = await insertCaCert();
const cert = await insertClientCert(ca.id);
const role = await insertRole();
const now = nowIso();
await db.insert(mtlsCertificateRoles).values({
issuedClientCertificateId: cert.id,
mtlsRoleId: role.id,
createdAt: now,
});
// Delete the CA — should cascade: CA → issued certs → cert_roles
await db.delete(caCertificates).where(eq(caCertificates.id, ca.id));
const remainingCerts = await db.select().from(issuedClientCertificates);
expect(remainingCerts).toHaveLength(0);
const remainingAssignments = await db.select().from(mtlsCertificateRoles);
expect(remainingAssignments).toHaveLength(0);
// The role itself should still exist
const remainingRoles = await db.select().from(mtlsRoles);
expect(remainingRoles).toHaveLength(1);
});
it('allows a cert to be assigned to multiple roles simultaneously', async () => {
const ca = await insertCaCert();
const cert = await insertClientCert(ca.id);
const r1 = await insertRole('role-a');
const r2 = await insertRole('role-b');
const r3 = await insertRole('role-c');
const now = nowIso();
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert.id, mtlsRoleId: r1.id, createdAt: now });
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert.id, mtlsRoleId: r2.id, createdAt: now });
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert.id, mtlsRoleId: r3.id, createdAt: now });
const assignments = await db.select().from(mtlsCertificateRoles);
expect(assignments).toHaveLength(3);
});
it('allows multiple certs to be assigned to the same role', async () => {
const ca = await insertCaCert();
const cert1 = await insertClientCert(ca.id, 'alice', 'AA');
const cert2 = await insertClientCert(ca.id, 'bob', 'BB');
const cert3 = await insertClientCert(ca.id, 'charlie', 'CC');
const role = await insertRole();
const now = nowIso();
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert1.id, mtlsRoleId: role.id, createdAt: now });
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert2.id, mtlsRoleId: role.id, createdAt: now });
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert3.id, mtlsRoleId: role.id, createdAt: now });
const assignments = await db.select().from(mtlsCertificateRoles).where(eq(mtlsCertificateRoles.mtlsRoleId, role.id));
expect(assignments).toHaveLength(3);
});
it('role deletion does not affect proxy host access rules referencing the role', async () => {
const host = await insertProxyHost();
const role = await insertRole();
const now = nowIso();
// Create an access rule that references this role
await db.insert(mtlsAccessRules).values({
proxyHostId: host.id,
pathPattern: '/test',
allowedRoleIds: JSON.stringify([role.id]),
createdAt: now,
updatedAt: now,
});
// Delete the role — the access rule should still exist (JSON array, no FK)
await db.delete(mtlsRoles).where(eq(mtlsRoles.id, role.id));
const rules = await db.select().from(mtlsAccessRules);
expect(rules).toHaveLength(1);
// The role ID is still in the JSON, but the role no longer exists
expect(JSON.parse(rules[0].allowedRoleIds)).toEqual([role.id]);
});
});

View File

@@ -0,0 +1,348 @@
/**
* Integration tests for src/lib/models/mtls-roles.ts
* Tests all CRUD operations and the fingerprint/cert-id map builders
* using a real in-memory SQLite database.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import {
mtlsRoles,
mtlsCertificateRoles,
issuedClientCertificates,
caCertificates,
users,
} from '../../src/lib/db/schema';
import { eq, isNull } from 'drizzle-orm';
let db: TestDb;
// Mock the modules that mtls-roles.ts imports
vi.mock('../../src/lib/db', async () => {
// This gets re-evaluated per test via beforeEach
return {
get default() { return db; },
nowIso: () => new Date().toISOString(),
toIso: (v: string | null) => v,
};
});
vi.mock('../../src/lib/caddy', () => ({ applyCaddyConfig: vi.fn() }));
vi.mock('../../src/lib/audit', () => ({ logAuditEvent: vi.fn() }));
let userId: number;
beforeEach(async () => {
db = createTestDb();
vi.clearAllMocks();
// Seed a user to satisfy FK constraints on createdBy
const now = new Date().toISOString();
const [user] = await db.insert(users).values({
email: 'admin@test', name: 'Admin', role: 'admin',
provider: 'credentials', subject: 'admin@test', status: 'active',
createdAt: now, updatedAt: now,
}).returning();
userId = user.id;
});
function nowIso() { return new Date().toISOString(); }
async function seedCaAndCerts() {
const now = nowIso();
const [ca] = await db.insert(caCertificates).values({
name: 'Test CA',
certificatePem: '-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----',
createdAt: now, updatedAt: now,
}).returning();
const [cert1] = await db.insert(issuedClientCertificates).values({
caCertificateId: ca.id, commonName: 'alice', serialNumber: '001',
fingerprintSha256: 'AA:BB:CC:DD', certificatePem: '-----BEGIN CERTIFICATE-----\nALICE\n-----END CERTIFICATE-----',
validFrom: now, validTo: now, createdAt: now, updatedAt: now,
}).returning();
const [cert2] = await db.insert(issuedClientCertificates).values({
caCertificateId: ca.id, commonName: 'bob', serialNumber: '002',
fingerprintSha256: 'EE:FF:00:11', certificatePem: '-----BEGIN CERTIFICATE-----\nBOB\n-----END CERTIFICATE-----',
validFrom: now, validTo: now, createdAt: now, updatedAt: now,
}).returning();
const [revokedCert] = await db.insert(issuedClientCertificates).values({
caCertificateId: ca.id, commonName: 'revoked-user', serialNumber: '003',
fingerprintSha256: '99:88:77:66', certificatePem: '-----BEGIN CERTIFICATE-----\nREVOKED\n-----END CERTIFICATE-----',
validFrom: now, validTo: now, revokedAt: now, createdAt: now, updatedAt: now,
}).returning();
return { ca, cert1, cert2, revokedCert };
}
// Dynamically import after mocks are set up
const {
listMtlsRoles,
getMtlsRole,
createMtlsRole,
updateMtlsRole,
deleteMtlsRole,
assignRoleToCertificate,
removeRoleFromCertificate,
getCertificateRoles,
buildRoleFingerprintMap,
buildCertFingerprintMap,
buildRoleCertIdMap,
} = await import('../../src/lib/models/mtls-roles');
describe('mtls-roles model CRUD', () => {
it('createMtlsRole creates a role and returns it', async () => {
const role = await createMtlsRole({ name: 'admin', description: 'Admin role' }, userId);
expect(role.name).toBe('admin');
expect(role.description).toBe('Admin role');
expect(role.certificate_count).toBe(0);
expect(role.id).toBeGreaterThan(0);
});
it('createMtlsRole trims whitespace', async () => {
const role = await createMtlsRole({ name: ' padded ' }, userId);
expect(role.name).toBe('padded');
});
it('listMtlsRoles returns all roles sorted by name', async () => {
await createMtlsRole({ name: 'zebra' }, userId);
await createMtlsRole({ name: 'alpha' }, userId);
const roles = await listMtlsRoles();
expect(roles.length).toBe(2);
expect(roles[0].name).toBe('alpha');
expect(roles[1].name).toBe('zebra');
});
it('listMtlsRoles includes certificate counts', async () => {
const { cert1 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
const roles = await listMtlsRoles();
expect(roles[0].certificate_count).toBe(1);
});
it('listMtlsRoles returns empty array when no roles', async () => {
const roles = await listMtlsRoles();
expect(roles).toEqual([]);
});
it('getMtlsRole returns role with certificate_ids', async () => {
const { cert1, cert2 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
await assignRoleToCertificate(role.id, cert2.id, 1);
const fetched = await getMtlsRole(role.id);
expect(fetched).not.toBeNull();
expect(fetched!.certificate_ids).toHaveLength(2);
expect(fetched!.certificate_ids).toContain(cert1.id);
expect(fetched!.certificate_ids).toContain(cert2.id);
});
it('getMtlsRole returns null for non-existent role', async () => {
const result = await getMtlsRole(999);
expect(result).toBeNull();
});
it('updateMtlsRole updates name and description', async () => {
const role = await createMtlsRole({ name: 'old', description: 'old desc' }, userId);
const updated = await updateMtlsRole(role.id, { name: 'new', description: 'new desc' }, userId);
expect(updated.name).toBe('new');
expect(updated.description).toBe('new desc');
});
it('updateMtlsRole throws for non-existent role', async () => {
await expect(updateMtlsRole(999, { name: 'x' }, 1)).rejects.toThrow();
});
it('updateMtlsRole can set description to null', async () => {
const role = await createMtlsRole({ name: 'test', description: 'has desc' }, userId);
const updated = await updateMtlsRole(role.id, { description: null }, userId);
expect(updated.description).toBeNull();
});
it('deleteMtlsRole removes the role', async () => {
const role = await createMtlsRole({ name: 'admin' }, userId);
await deleteMtlsRole(role.id, 1);
const roles = await listMtlsRoles();
expect(roles).toEqual([]);
});
it('deleteMtlsRole throws for non-existent role', async () => {
await expect(deleteMtlsRole(999, 1)).rejects.toThrow();
});
});
describe('mtls-roles certificate assignments', () => {
it('assignRoleToCertificate creates assignment', async () => {
const { cert1 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
const fetched = await getMtlsRole(role.id);
expect(fetched!.certificate_ids).toContain(cert1.id);
});
it('assignRoleToCertificate throws for non-existent role', async () => {
const { cert1 } = await seedCaAndCerts();
await expect(assignRoleToCertificate(999, cert1.id, 1)).rejects.toThrow();
});
it('assignRoleToCertificate throws for non-existent cert', async () => {
const role = await createMtlsRole({ name: 'admin' }, userId);
await expect(assignRoleToCertificate(role.id, 999, 1)).rejects.toThrow();
});
it('assignRoleToCertificate throws on duplicate assignment', async () => {
const { cert1 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
await expect(assignRoleToCertificate(role.id, cert1.id, 1)).rejects.toThrow();
});
it('removeRoleFromCertificate removes assignment', async () => {
const { cert1 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
await removeRoleFromCertificate(role.id, cert1.id, 1);
const fetched = await getMtlsRole(role.id);
expect(fetched!.certificate_ids).toEqual([]);
});
it('removeRoleFromCertificate throws for non-existent role', async () => {
await expect(removeRoleFromCertificate(999, 1, 1)).rejects.toThrow();
});
it('getCertificateRoles returns roles for a cert', async () => {
const { cert1 } = await seedCaAndCerts();
const role1 = await createMtlsRole({ name: 'admin' }, userId);
const role2 = await createMtlsRole({ name: 'viewer' }, userId);
await assignRoleToCertificate(role1.id, cert1.id, 1);
await assignRoleToCertificate(role2.id, cert1.id, 1);
const roles = await getCertificateRoles(cert1.id);
expect(roles).toHaveLength(2);
expect(roles.map(r => r.name).sort()).toEqual(['admin', 'viewer']);
});
it('getCertificateRoles returns empty array for cert with no roles', async () => {
const { cert1 } = await seedCaAndCerts();
const roles = await getCertificateRoles(cert1.id);
expect(roles).toEqual([]);
});
it('a cert can be in multiple roles', async () => {
const { cert1 } = await seedCaAndCerts();
const r1 = await createMtlsRole({ name: 'r1' }, userId);
const r2 = await createMtlsRole({ name: 'r2' }, userId);
const r3 = await createMtlsRole({ name: 'r3' }, userId);
await assignRoleToCertificate(r1.id, cert1.id, 1);
await assignRoleToCertificate(r2.id, cert1.id, 1);
await assignRoleToCertificate(r3.id, cert1.id, 1);
const roles = await getCertificateRoles(cert1.id);
expect(roles).toHaveLength(3);
});
it('a role can have multiple certs', async () => {
const { cert1, cert2 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
await assignRoleToCertificate(role.id, cert2.id, 1);
const fetched = await getMtlsRole(role.id);
expect(fetched!.certificate_ids).toHaveLength(2);
});
});
describe('buildRoleFingerprintMap', () => {
it('returns empty map when no roles exist', async () => {
const map = await buildRoleFingerprintMap();
expect(map.size).toBe(0);
});
it('maps role IDs to normalized fingerprints of active certs', async () => {
const { cert1, cert2 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
await assignRoleToCertificate(role.id, cert2.id, 1);
const map = await buildRoleFingerprintMap();
expect(map.has(role.id)).toBe(true);
const fps = map.get(role.id)!;
expect(fps.size).toBe(2);
// Fingerprints are normalized: colons stripped, lowercased
expect(fps.has('aabbccdd')).toBe(true);
expect(fps.has('eeff0011')).toBe(true);
});
it('excludes revoked certs from fingerprint map', async () => {
const { revokedCert } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, revokedCert.id, 1);
const map = await buildRoleFingerprintMap();
// Role exists but has no active certs
expect(map.has(role.id)).toBe(false);
});
it('handles multiple roles with overlapping certs', async () => {
const { cert1, cert2 } = await seedCaAndCerts();
const r1 = await createMtlsRole({ name: 'r1' }, userId);
const r2 = await createMtlsRole({ name: 'r2' }, userId);
await assignRoleToCertificate(r1.id, cert1.id, 1);
await assignRoleToCertificate(r2.id, cert1.id, 1);
await assignRoleToCertificate(r2.id, cert2.id, 1);
const map = await buildRoleFingerprintMap();
expect(map.get(r1.id)!.size).toBe(1);
expect(map.get(r2.id)!.size).toBe(2);
});
});
describe('buildCertFingerprintMap', () => {
it('returns empty map when no certs exist', async () => {
const map = await buildCertFingerprintMap();
expect(map.size).toBe(0);
});
it('maps cert IDs to normalized fingerprints', async () => {
const { cert1, cert2 } = await seedCaAndCerts();
const map = await buildCertFingerprintMap();
expect(map.get(cert1.id)).toBe('aabbccdd');
expect(map.get(cert2.id)).toBe('eeff0011');
});
it('excludes revoked certs', async () => {
const { revokedCert } = await seedCaAndCerts();
const map = await buildCertFingerprintMap();
expect(map.has(revokedCert.id)).toBe(false);
});
});
describe('buildRoleCertIdMap', () => {
it('returns empty map when no roles exist', async () => {
const map = await buildRoleCertIdMap();
expect(map.size).toBe(0);
});
it('maps role IDs to cert IDs of active certs', async () => {
const { cert1, cert2 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
await assignRoleToCertificate(role.id, cert2.id, 1);
const map = await buildRoleCertIdMap();
expect(map.has(role.id)).toBe(true);
expect(map.get(role.id)!.has(cert1.id)).toBe(true);
expect(map.get(role.id)!.has(cert2.id)).toBe(true);
});
it('excludes revoked certs from role cert ID map', async () => {
const { revokedCert } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, revokedCert.id, 1);
const map = await buildRoleCertIdMap();
expect(map.has(role.id)).toBe(false);
});
});

View File

@@ -0,0 +1,186 @@
/**
* Unit tests for the new cert-based mTLS model (leaf override / trusted_client_cert_ids).
*
* Tests buildClientAuthentication with the mTlsDomainLeafOverride parameter
* to ensure the new "trust user X" model works correctly alongside the legacy CA model.
*/
import { describe, it, expect } from 'vitest';
import { buildClientAuthentication, pemToBase64Der } from '../../src/lib/caddy-mtls';
function makeCaPem(label: string): string {
return `-----BEGIN CERTIFICATE-----\n${label}\n-----END CERTIFICATE-----`;
}
function makeCaCertMap(...entries: [number, string][]) {
return new Map(entries.map(([id, label]) => [id, { id, certificatePem: makeCaPem(label) }]));
}
describe('buildClientAuthentication with leaf override (new cert-based model)', () => {
it('uses leaf override PEMs when mTlsDomainLeafOverride is provided', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const leafOverride = new Map([['app.example.com', [makeCaPem('USER_CERT_1'), makeCaPem('USER_CERT_2')]]]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set(),
leafOverride
);
expect(result).not.toBeNull();
expect(result!.mode).toBe('require_and_verify');
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
expect(result!.trusted_leaf_certs).toEqual(['USER_CERT_1', 'USER_CERT_2']);
});
it('ignores the legacy managed/unmanaged CA logic when leaf override is present', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
// CA 1 is managed with active certs, but leaf override should take precedence
const issuedClientCertMap = new Map([[1, [makeCaPem('OTHER_CERT')]]]);
const cAsWithAnyIssuedCerts = new Set([1]);
const leafOverride = new Map([['app.example.com', [makeCaPem('SPECIFIC_USER')]]]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
issuedClientCertMap,
cAsWithAnyIssuedCerts,
leafOverride
);
expect(result).not.toBeNull();
// Should only have the override leaf, not OTHER_CERT from the managed CA
expect(result!.trusted_leaf_certs).toEqual(['SPECIFIC_USER']);
expect(result!.trusted_leaf_certs).not.toContain('OTHER_CERT');
});
it('falls back to legacy CA logic when no leaf override exists for domain', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
// No leaf override for this domain
const leafOverride = new Map([['other.example.com', [makeCaPem('OTHER')]]]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set(),
leafOverride
);
// Falls back to unmanaged CA: no leaf certs
expect(result).not.toBeNull();
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
expect(result!.trusted_leaf_certs).toBeUndefined();
});
it('includes CAs from multiple domains in leaf override', () => {
const mTlsDomainMap = new Map([
['app.example.com', [1, 2]],
]);
const caCertMap = makeCaCertMap([1, 'CA_A'], [2, 'CA_B']);
const leafOverride = new Map([['app.example.com', [makeCaPem('USER_1')]]]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set(),
leafOverride
);
expect(result).not.toBeNull();
expect(result!.trusted_ca_certs).toContain('CA_A');
expect(result!.trusted_ca_certs).toContain('CA_B');
expect(result!.trusted_leaf_certs).toEqual(['USER_1']);
});
it('deduplicates leaf PEMs from multiple domains in same group', () => {
const sharedPem = makeCaPem('SHARED_CERT');
const mTlsDomainMap = new Map([
['a.example.com', [1]],
['b.example.com', [1]],
]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const leafOverride = new Map([
['a.example.com', [sharedPem]],
['b.example.com', [sharedPem]],
]);
const result = buildClientAuthentication(
['a.example.com', 'b.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set(),
leafOverride
);
expect(result).not.toBeNull();
// The Set-based dedup in the function should handle this
// Actually looking at the code, it uses a Set for PEMs
const leafCerts = result!.trusted_leaf_certs as string[];
// pemToBase64Der('SHARED_CERT') appears once since we used a Set
expect(leafCerts.length).toBe(1);
});
it('returns null when leaf override has PEMs but no CA exists in caCertMap', () => {
const mTlsDomainMap = new Map([['app.example.com', [99]]]);
const caCertMap = new Map<number, { id: number; certificatePem: string }>(); // CA 99 not found
const leafOverride = new Map([['app.example.com', [makeCaPem('USER')]]]);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set(),
leafOverride
);
expect(result).toBeNull();
});
it('handles empty leaf override map (same as no override)', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set(),
new Map()
);
// No override → unmanaged CA logic
expect(result).not.toBeNull();
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
expect(result!.trusted_leaf_certs).toBeUndefined();
});
it('handles undefined leaf override (backward compat)', () => {
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
const caCertMap = makeCaCertMap([1, 'CA_A']);
const result = buildClientAuthentication(
['app.example.com'],
mTlsDomainMap,
caCertMap,
new Map(),
new Set(),
undefined
);
expect(result).not.toBeNull();
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
});
});

View File

@@ -0,0 +1,369 @@
/**
* Unit tests for mTLS RBAC functions in src/lib/caddy-mtls.ts
*
* Covers:
* - resolveAllowedFingerprints: union of role + cert fingerprints
* - buildFingerprintCelExpression: CEL expression generation
* - buildMtlsRbacSubroutes: full subroute generation with path rules
* - normalizeFingerprint: colon stripping + lowercase
*/
import { describe, it, expect } from "vitest";
import {
resolveAllowedFingerprints,
buildFingerprintCelExpression,
buildMtlsRbacSubroutes,
normalizeFingerprint,
type MtlsAccessRuleLike,
} from "../../src/lib/caddy-mtls";
// ── Helpers ──────────────────────────────────────────────────────────
function makeRule(overrides: Partial<MtlsAccessRuleLike> = {}): MtlsAccessRuleLike {
return {
path_pattern: "/admin/*",
allowed_role_ids: [],
allowed_cert_ids: [],
deny_all: false,
...overrides,
};
}
// ── normalizeFingerprint ─────────────────────────────────────────────
describe("normalizeFingerprint", () => {
it("strips colons and lowercases", () => {
expect(normalizeFingerprint("AB:CD:EF:12")).toBe("abcdef12");
});
it("handles already-normalized input", () => {
expect(normalizeFingerprint("abcdef12")).toBe("abcdef12");
});
it("handles empty string", () => {
expect(normalizeFingerprint("")).toBe("");
});
});
// ── resolveAllowedFingerprints ───────────────────────────────────────
describe("resolveAllowedFingerprints", () => {
it("resolves fingerprints from roles", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["fp_a", "fp_b"])],
[2, new Set(["fp_c"])],
]);
const certFpMap = new Map<number, string>();
const rule = makeRule({ allowed_role_ids: [1, 2] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_a", "fp_b", "fp_c"]));
});
it("resolves fingerprints from direct cert IDs", () => {
const roleFpMap = new Map<number, Set<string>>();
const certFpMap = new Map<number, string>([
[10, "fp_x"],
[20, "fp_y"],
]);
const rule = makeRule({ allowed_cert_ids: [10, 20] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_x", "fp_y"]));
});
it("unions both roles and certs", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["fp_a"])],
]);
const certFpMap = new Map<number, string>([[10, "fp_b"]]);
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_a", "fp_b"]));
});
it("deduplicates when a cert is in a role AND directly allowed", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["fp_a"])],
]);
const certFpMap = new Map<number, string>([[10, "fp_a"]]);
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result.size).toBe(1);
expect(result.has("fp_a")).toBe(true);
});
it("returns empty set for unknown role/cert IDs", () => {
const roleFpMap = new Map<number, Set<string>>();
const certFpMap = new Map<number, string>();
const rule = makeRule({ allowed_role_ids: [999], allowed_cert_ids: [999] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result.size).toBe(0);
});
});
// ── buildFingerprintCelExpression ────────────────────────────────────
describe("buildFingerprintCelExpression", () => {
it("builds CEL expression with sorted fingerprints", () => {
const fps = new Set(["fp_b", "fp_a"]);
const expr = buildFingerprintCelExpression(fps);
expect(expr).toBe("{http.request.tls.client.fingerprint} in ['fp_a', 'fp_b']");
});
it("handles single fingerprint", () => {
const fps = new Set(["abc123"]);
const expr = buildFingerprintCelExpression(fps);
expect(expr).toBe("{http.request.tls.client.fingerprint} in ['abc123']");
});
});
// ── buildMtlsRbacSubroutes ──────────────────────────────────────────
describe("buildMtlsRbacSubroutes", () => {
const baseHandlers = [{ handler: "headers" }];
const reverseProxy = { handler: "reverse_proxy" };
it("returns null for empty rules", () => {
const result = buildMtlsRbacSubroutes(
[],
new Map(),
new Map(),
baseHandlers,
reverseProxy
);
expect(result).toBeNull();
});
it("generates allow + deny routes for a role-based rule", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["fp_admin"])],
]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
// Should have 3 routes: allow /admin/*, deny /admin/*, catch-all
expect(result!.length).toBe(3);
// Allow route has expression matcher
const allowRoute = result![0] as Record<string, unknown>;
const match = (allowRoute.match as Record<string, unknown>[])[0];
expect(match.path).toEqual(["/admin/*"]);
expect(match.expression).toContain("fp_admin");
expect(allowRoute.terminal).toBe(true);
// Deny route returns 403
const denyRoute = result![1] as Record<string, unknown>;
const denyMatch = (denyRoute.match as Record<string, unknown>[])[0];
expect(denyMatch.path).toEqual(["/admin/*"]);
const denyHandler = (denyRoute.handle as Record<string, unknown>[])[0];
expect(denyHandler.status_code).toBe("403");
// Catch-all has no match
const catchAll = result![2] as Record<string, unknown>;
expect(catchAll.match).toBeUndefined();
expect(catchAll.terminal).toBe(true);
});
it("generates 403 for deny_all rule", () => {
const rules = [makeRule({ deny_all: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
// deny route + catch-all = 2
expect(result!.length).toBe(2);
const denyRoute = result![0] as Record<string, unknown>;
const handler = (denyRoute.handle as Record<string, unknown>[])[0];
expect(handler.status_code).toBe("403");
});
it("generates 403 when rule has no matching fingerprints", () => {
const rules = [makeRule({ allowed_role_ids: [999] })]; // role doesn't exist
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
// deny route + catch-all = 2
expect(result!.length).toBe(2);
const denyRoute = result![0] as Record<string, unknown>;
const handler = (denyRoute.handle as Record<string, unknown>[])[0];
expect(handler.status_code).toBe("403");
});
it("handles multiple rules with different paths", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["fp_admin"])],
[2, new Set(["fp_api"])],
]);
const rules = [
makeRule({ path_pattern: "/admin/*", allowed_role_ids: [1] }),
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1, 2] }),
];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
// 2 rules × 2 routes each + 1 catch-all = 5
expect(result!.length).toBe(5);
});
it("uses direct cert fingerprints as overrides", () => {
const certFpMap = new Map<number, string>([[42, "fp_special"]]);
const rules = [makeRule({ allowed_cert_ids: [42] })];
const result = buildMtlsRbacSubroutes(rules, new Map(), certFpMap, baseHandlers, reverseProxy);
expect(result).not.toBeNull();
const allowRoute = result![0] as Record<string, unknown>;
const match = (allowRoute.match as Record<string, unknown>[])[0];
expect(match.expression).toContain("fp_special");
});
it("catch-all route includes base handlers + reverse proxy", () => {
const rules = [makeRule({ deny_all: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
const catchAll = result![result!.length - 1] as Record<string, unknown>;
const handlers = catchAll.handle as Record<string, unknown>[];
expect(handlers).toHaveLength(2); // baseHandlers[0] + reverseProxy
expect(handlers[0]).toEqual({ handler: "headers" });
expect(handlers[1]).toEqual({ handler: "reverse_proxy" });
});
it("allow route includes base handlers + reverse proxy", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
const allowRoute = result![0] as Record<string, unknown>;
const handlers = allowRoute.handle as Record<string, unknown>[];
expect(handlers).toHaveLength(2);
expect(handlers[1]).toEqual({ handler: "reverse_proxy" });
});
it("deny route body is 'mTLS access denied'", () => {
const rules = [makeRule({ deny_all: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
const denyHandler = (result![0] as any).handle[0];
expect(denyHandler.body).toBe("mTLS access denied");
});
it("handles mixed deny_all and role-based rules", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [
makeRule({ path_pattern: "/secret/*", deny_all: true }),
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1] }),
];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
// /secret/* deny + /api/* allow + /api/* deny + catch-all = 4
expect(result!.length).toBe(4);
// First route: deny /secret/*
expect((result![0] as any).match[0].path).toEqual(["/secret/*"]);
expect((result![0] as any).handle[0].status_code).toBe("403");
// Second route: allow /api/*
expect((result![1] as any).match[0].path).toEqual(["/api/*"]);
expect((result![1] as any).match[0].expression).toContain("fp");
});
it("handles rule with both roles and certs combined", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp_role"])]]);
const certFpMap = new Map<number, string>([[42, "fp_cert"]]);
const rules = [makeRule({ allowed_role_ids: [1], allowed_cert_ids: [42] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, certFpMap, baseHandlers, reverseProxy);
const match = (result![0] as any).match[0];
expect(match.expression).toContain("fp_role");
expect(match.expression).toContain("fp_cert");
});
it("preserves base handlers order in generated routes", () => {
const multiHandlers = [{ handler: "waf" }, { handler: "headers" }, { handler: "auth" }];
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), multiHandlers, reverseProxy);
const allowHandlers = (result![0] as any).handle;
expect(allowHandlers[0]).toEqual({ handler: "waf" });
expect(allowHandlers[1]).toEqual({ handler: "headers" });
expect(allowHandlers[2]).toEqual({ handler: "auth" });
expect(allowHandlers[3]).toEqual({ handler: "reverse_proxy" });
});
});
// ── normalizeFingerprint edge cases ──────────────────────────────────
describe("normalizeFingerprint edge cases", () => {
it("handles full SHA-256 fingerprint with colons", () => {
const fp = "AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89";
expect(normalizeFingerprint(fp)).toBe("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
});
it("handles mixed case without colons", () => {
expect(normalizeFingerprint("AbCdEf")).toBe("abcdef");
});
it("handles fingerprint with only colons", () => {
expect(normalizeFingerprint(":::")).toBe("");
});
});
// ── buildFingerprintCelExpression edge cases ─────────────────────────
describe("buildFingerprintCelExpression edge cases", () => {
it("handles empty fingerprint set", () => {
const expr = buildFingerprintCelExpression(new Set());
expect(expr).toBe("{http.request.tls.client.fingerprint} in []");
});
it("handles many fingerprints", () => {
const fps = new Set(Array.from({ length: 50 }, (_, i) => `fp_${String(i).padStart(3, "0")}`));
const expr = buildFingerprintCelExpression(fps);
expect(expr).toContain("fp_000");
expect(expr).toContain("fp_049");
// Verify sorted order
const idx0 = expr.indexOf("fp_000");
const idx49 = expr.indexOf("fp_049");
expect(idx0).toBeLessThan(idx49);
});
});
// ── resolveAllowedFingerprints edge cases ────────────────────────────
describe("resolveAllowedFingerprints edge cases", () => {
it("handles empty arrays in rule", () => {
const rule = makeRule({ allowed_role_ids: [], allowed_cert_ids: [] });
const result = resolveAllowedFingerprints(rule, new Map(), new Map());
expect(result.size).toBe(0);
});
it("handles role with empty fingerprint set", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set()]]);
const rule = makeRule({ allowed_role_ids: [1] });
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
expect(result.size).toBe(0);
});
it("merges fingerprints from multiple roles correctly", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["a", "b"])],
[2, new Set(["b", "c"])],
[3, new Set(["c", "d"])],
]);
const rule = makeRule({ allowed_role_ids: [1, 2, 3] });
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
expect(result).toEqual(new Set(["a", "b", "c", "d"]));
});
});