From 277ae6e79cb3156559c2b400e5ad649d059ba8ac Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:40:21 +0200 Subject: [PATCH] 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) --- .../certificates/CertificatesClient.tsx | 28 +- app/(dashboard)/certificates/page.tsx | 7 +- .../proxy-hosts/ProxyHostsClient.tsx | 19 +- app/(dashboard)/proxy-hosts/actions.ts | 5 +- app/(dashboard)/proxy-hosts/page.tsx | 9 + .../client-certificates/[id]/roles/route.ts | 17 + .../[id]/certificates/[certId]/route.ts | 17 + .../v1/mtls-roles/[id]/certificates/route.ts | 22 + app/api/v1/mtls-roles/[id]/route.ts | 49 ++ app/api/v1/mtls-roles/route.ts | 27 ++ .../[id]/mtls-access-rules/[ruleId]/route.ts | 49 ++ .../[id]/mtls-access-rules/route.ts | 38 ++ drizzle/0016_mtls_rbac.sql | 41 ++ drizzle/meta/_journal.json | 7 + src/components/mtls-roles/MtlsRolesTab.tsx | 255 +++++++++++ src/components/proxy-hosts/HostDialogs.tsx | 29 +- src/components/proxy-hosts/MtlsConfig.tsx | 368 +++++++++++++-- src/lib/caddy-mtls.ts | 209 ++++++++- src/lib/caddy.ts | 133 +++++- src/lib/db/schema.ts | 64 +++ src/lib/models/mtls-access-rules.ts | 194 ++++++++ src/lib/models/mtls-roles.ts | 345 ++++++++++++++ src/lib/models/proxy-hosts.ts | 7 +- .../mtls-access-rules-model.test.ts | 300 ++++++++++++ tests/integration/mtls-rbac.test.ts | 428 ++++++++++++++++++ tests/integration/mtls-roles-model.test.ts | 348 ++++++++++++++ tests/unit/caddy-mtls-leaf-override.test.ts | 186 ++++++++ tests/unit/caddy-mtls-rbac.test.ts | 369 +++++++++++++++ 28 files changed, 3484 insertions(+), 86 deletions(-) create mode 100644 app/api/v1/client-certificates/[id]/roles/route.ts create mode 100644 app/api/v1/mtls-roles/[id]/certificates/[certId]/route.ts create mode 100644 app/api/v1/mtls-roles/[id]/certificates/route.ts create mode 100644 app/api/v1/mtls-roles/[id]/route.ts create mode 100644 app/api/v1/mtls-roles/route.ts create mode 100644 app/api/v1/proxy-hosts/[id]/mtls-access-rules/[ruleId]/route.ts create mode 100644 app/api/v1/proxy-hosts/[id]/mtls-access-rules/route.ts create mode 100644 drizzle/0016_mtls_rbac.sql create mode 100644 src/components/mtls-roles/MtlsRolesTab.tsx create mode 100644 src/lib/models/mtls-access-rules.ts create mode 100644 src/lib/models/mtls-roles.ts create mode 100644 tests/integration/mtls-access-rules-model.test.ts create mode 100644 tests/integration/mtls-rbac.test.ts create mode 100644 tests/integration/mtls-roles-model.test.ts create mode 100644 tests/unit/caddy-mtls-leaf-override.test.ts create mode 100644 tests/unit/caddy-mtls-rbac.test.ts diff --git a/app/(dashboard)/certificates/CertificatesClient.tsx b/app/(dashboard)/certificates/CertificatesClient.tsx index 1c88950b..a8f276f8 100644 --- a/app/(dashboard)/certificates/CertificatesClient.tsx +++ b/app/(dashboard)/certificates/CertificatesClient.tsx @@ -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("acme"); const [searchAcme, setSearchAcme] = useState(""); const [searchImported, setSearchImported] = useState(""); const [searchCa, setSearchCa] = useState(""); + const [searchRoles, setSearchRoles] = useState(""); const [statusFilter, setStatusFilter] = useState(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} + + Roles + + {mtlsRoles.length} + + + + + ); diff --git a/app/(dashboard)/certificates/page.tsx b/app/(dashboard)/certificates/page.tsx index 1d20c2cf..e86b96d6 100644 --- a/app/(dashboard)/certificates/page.tsx +++ b/app/(dashboard)/certificates/page.tsx @@ -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} /> ); } diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx index b3b741a2..349fe61d 100644 --- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx +++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx @@ -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(null); const [editHost, setEditHost] = useState(null); const [deleteHost, setDeleteHost] = useState(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 setEditHost(host)}>Edit - { setDuplicateHost(host); setCreateOpen(true); }}>Duplicate + { setDuplicateHost(host); { setDialogKey(k => k + 1); setCreateOpen(true); }; }}>Duplicate setEditHost(host)}>Edit - { setDuplicateHost(host); setCreateOpen(true); }}>Duplicate + { setDuplicateHost(host); { setDialogKey(k => k + 1); setCreateOpen(true); }; }}>Duplicate setDeleteHost(host)}>Delete @@ -264,7 +270,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC setCreateOpen(true) }} + action={{ label: "Create Host", onClick: () => { setDialogKey(k => k + 1); setCreateOpen(true); } }} />
@@ -287,6 +293,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC /> { 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 ?? []} /> )} diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 22432e72..cd908581 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -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 { diff --git a/app/(dashboard)/proxy-hosts/page.tsx b/app/(dashboard)/proxy-hosts/page.tsx index 2f7a6845..27e85a36 100644 --- a/app/(dashboard)/proxy-hosts/page.tsx +++ b/app/(dashboard)/proxy-hosts/page.tsx @@ -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 ( ); } diff --git a/app/api/v1/client-certificates/[id]/roles/route.ts b/app/api/v1/client-certificates/[id]/roles/route.ts new file mode 100644 index 00000000..82cf50e2 --- /dev/null +++ b/app/api/v1/client-certificates/[id]/roles/route.ts @@ -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); + } +} diff --git a/app/api/v1/mtls-roles/[id]/certificates/[certId]/route.ts b/app/api/v1/mtls-roles/[id]/certificates/[certId]/route.ts new file mode 100644 index 00000000..7b2254c6 --- /dev/null +++ b/app/api/v1/mtls-roles/[id]/certificates/[certId]/route.ts @@ -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); + } +} diff --git a/app/api/v1/mtls-roles/[id]/certificates/route.ts b/app/api/v1/mtls-roles/[id]/certificates/route.ts new file mode 100644 index 00000000..b2b5a61c --- /dev/null +++ b/app/api/v1/mtls-roles/[id]/certificates/route.ts @@ -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); + } +} diff --git a/app/api/v1/mtls-roles/[id]/route.ts b/app/api/v1/mtls-roles/[id]/route.ts new file mode 100644 index 00000000..f5efbaf6 --- /dev/null +++ b/app/api/v1/mtls-roles/[id]/route.ts @@ -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); + } +} diff --git a/app/api/v1/mtls-roles/route.ts b/app/api/v1/mtls-roles/route.ts new file mode 100644 index 00000000..52ad89cb --- /dev/null +++ b/app/api/v1/mtls-roles/route.ts @@ -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); + } +} diff --git a/app/api/v1/proxy-hosts/[id]/mtls-access-rules/[ruleId]/route.ts b/app/api/v1/proxy-hosts/[id]/mtls-access-rules/[ruleId]/route.ts new file mode 100644 index 00000000..676d64fb --- /dev/null +++ b/app/api/v1/proxy-hosts/[id]/mtls-access-rules/[ruleId]/route.ts @@ -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); + } +} diff --git a/app/api/v1/proxy-hosts/[id]/mtls-access-rules/route.ts b/app/api/v1/proxy-hosts/[id]/mtls-access-rules/route.ts new file mode 100644 index 00000000..cd8942a7 --- /dev/null +++ b/app/api/v1/proxy-hosts/[id]/mtls-access-rules/route.ts @@ -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); + } +} diff --git a/drizzle/0016_mtls_rbac.sql b/drizzle/0016_mtls_rbac.sql new file mode 100644 index 00000000..1e03e54a --- /dev/null +++ b/drizzle/0016_mtls_rbac.sql @@ -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`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f6f5949c..2e4deb80 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/src/components/mtls-roles/MtlsRolesTab.tsx b/src/components/mtls-roles/MtlsRolesTab.tsx new file mode 100644 index 00000000..efcf1cd9 --- /dev/null +++ b/src/components/mtls-roles/MtlsRolesTab.tsx @@ -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 ( +
+ {/* Create inline form */} + {createOpen ? ( + setCreateOpen(false)} /> + ) : ( + + )} + + {filtered.length === 0 && !createOpen && ( +
+ +

+ {search ? "No roles match your search." : "No mTLS roles yet."} +

+

+ Roles group client certificates for access control on proxy hosts. +

+
+ )} + + {filtered.map((role, idx) => ( + + ))} +
+ ); +} + +/* ── 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 ( + + + {error && {error}} +
+
+ + setName(e.target.value)} placeholder="e.g. admin" className="h-8 text-sm" autoFocus /> +
+
+ + setDescription(e.target.value)} placeholder="Optional" className="h-8 text-sm" /> +
+
+
+ + +
+
+
+ ); +} + +/* ── Single role card ── */ + +function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeof ACCENT_COLORS[0]; activeCerts: IssuedClientCertificate[] }) { + const [assignedIds, setAssignedIds] = useState>(new Set()); + const [loaded, setLoaded] = useState(false); + const [toggling, setToggling] = useState(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 ( + + + {/* Header */} +
+
+ +
+
+

{role.name}

+

+ {assignedIds.size} {assignedIds.size === 1 ? "certificate" : "certificates"} + {role.description && ` · ${role.description}`} +

+
+ + {assignedIds.size} + +
+ + {/* Edit form */} + {editing ? ( +
+
+
+ + setName(e.target.value)} className="h-8 text-sm" /> +
+
+ + setDescription(e.target.value)} className="h-8 text-sm" placeholder="Optional" /> +
+
+
+ + +
+
+ ) : ( +
+ + +
+ )} + + + + {/* Certificates */} +
+

Certificates

+ + {!loaded ? ( +

Loading...

+ ) : activeCerts.length === 0 ? ( +
+ + No client certificates issued yet. +
+ ) : ( +
+ {activeCerts.map(cert => { + const isAssigned = assignedIds.has(cert.id); + const isLoading = toggling === cert.id; + return ( +
+
+ handleToggle(cert.id)} /> +
+

{cert.common_name}

+

+ expires {new Date(cert.valid_to).toLocaleDateString()} +

+
+
+ {isAssigned && Assigned} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx index a01d8b9d..daac06d4 100644 --- a/src/components/proxy-hosts/HostDialogs.tsx +++ b/src/components/proxy-hosts/HostDialogs.tsx @@ -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({ - + ); @@ -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({ }} /> - + ); diff --git a/src/components/proxy-hosts/MtlsConfig.tsx b/src/components/proxy-hosts/MtlsConfig.tsx index 09e6c32f..c6b4c71d 100644 --- a/src/components/proxy-hosts/MtlsConfig.tsx +++ b/src/components/proxy-hosts/MtlsConfig.tsx @@ -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(value?.ca_certificate_ids ?? []); + const [selectedCertIds, setSelectedCertIds] = useState(value?.trusted_client_cert_ids ?? []); + const [selectedRoleIds, setSelectedRoleIds] = useState(value?.trusted_role_ids ?? []); - function toggleId(id: number) { - setSelectedIds(prev => - prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] - ); + const [rules, setRules] = useState([]); + const [rulesLoaded, setRulesLoaded] = useState(false); + const [addRuleOpen, setAddRuleOpen] = useState(false); + const [editRule, setEditRule] = useState(null); + + const isEditMode = !!proxyHostId; + const activeCerts = issuedClientCerts.filter(c => !c.revoked_at); + + const certsByCA = new Map(); + 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 (
- {enabled && selectedIds.map(id => ( - + {enabled && selectedCertIds.map(id => ( + + ))} + {enabled && selectedRoleIds.map(id => ( + ))} {/* Header */} @@ -41,52 +102,285 @@ export function MtlsFields({ value, caCertificates }: Props) {

Mutual TLS (mTLS)

- Require clients to present a certificate signed by a trusted CA + Require clients to present a trusted certificate to connect

- +
mTLS requires TLS to be configured on this host (certificate must be set). + Select roles and/or individual certificates to allow. - - Trusted Client CA Certificates - + {/* ── Trusted Roles ── */} + {mtlsRoles.length > 0 && ( + <> +
+ +

+ Trusted Roles +

+ {selectedRoleIds.length > 0 && ( + {selectedRoleIds.length} selected + )} +
+
+ {mtlsRoles.map(role => ( +
+ toggleRole(role.id)} + /> + + {role.certificate_count} certs +
+ ))} +
+ + )} - {caCertificates.length === 0 ? ( -

- No CA certificates configured. Add them on the Certificates page. + {/* ── Trusted Certificates ── */} +

+ +

+ Trusted Certificates

- ) : ( -
- {caCertificates.map(ca => ( -
- toggleId(ca.id)} - /> - -
- ))} + {selectedCertIds.length > 0 && ( + {selectedCertIds.length} selected + )} +
+ + {activeCerts.length === 0 ? ( +
+

No client certificates issued yet.

+

Issue certificates from a CA on the Certificates page.

+ ) : ( +
+ {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 ( +
+
+ toggleAllFromCA(caId)} + className={someSelected && !allSelected ? "opacity-60" : ""} + /> + + + {certs.filter(c => selectedCertIds.includes(c.id)).length}/{certs.length} + +
+
+ {certs.map(cert => ( +
+ toggleCert(cert.id)} + className="ml-4" + /> + + + expires {new Date(cert.valid_to).toLocaleDateString()} + +
+ ))} +
+
+ ); + })} +
+ )} + + {!hasTrust && activeCerts.length > 0 && ( +

No roles or certificates selected — mTLS will block all connections.

+ )} + + {/* ── RBAC rules ── */} + {isEditMode && ( + <> + +
+
+ +

+ Path-Based Access Rules +

+
+ +
+

+ Restrict specific paths to certain roles or certificates. Paths without rules allow any trusted cert/role above. +

+ + {!rulesLoaded ? ( +

Loading...

+ ) : rules.length === 0 ? ( +
+

No access rules configured

+

All trusted certificates/roles have equal access to every path.

+
+ ) : ( +
+ {rules.map(rule => ( +
+ {rule.path_pattern} + {rule.deny_all ? ( + Deny + ) : ( +
+ {rule.allowed_role_ids.map(roleId => { + const role = mtlsRoles.find(r => r.id === roleId); + return {role?.name ?? `#${roleId}`}; + })} + {rule.allowed_cert_ids.map(certId => { + const cert = issuedClientCerts.find(c => c.id === certId); + return {cert?.common_name ?? `#${certId}`}; + })} + {rule.allowed_role_ids.length === 0 && rule.allowed_cert_ids.length === 0 && ( + No roles/certs — effectively denied + )} +
+ )} +
+ + +
+
+ ))} +
+ )} + + {addRuleOpen && ( + setAddRuleOpen(false)} proxyHostId={proxyHostId!} roles={mtlsRoles} activeCerts={activeCerts} title="Add Access Rule" submitLabel="Add Rule" onSaved={loadRules} /> + )} + {editRule && ( + setEditRule(null)} proxyHostId={proxyHostId!} roles={mtlsRoles} activeCerts={activeCerts} title="Edit Access Rule" submitLabel="Save" existing={editRule} onSaved={loadRules} /> + )} + )}
); } + +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(existing?.allowed_role_ids ?? []); + const [selectedCertIds, setSelectedCertIds] = useState(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 ( + +
+ {error && {error}} +
+
+ + setPathPattern(e.target.value)} placeholder="*" /> +

Use * for all paths, /admin/* for prefix match

+
+
+ + setPriority(e.target.value)} /> +
+
+
+ + setDescription(e.target.value)} placeholder="Optional" /> +
+
+ + +
+
+
+

Allowed Roles

+ {roles.length === 0 ? ( +

No mTLS roles yet. Create roles on the Certificates page.

+ ) : ( +
+ {roles.map(role => ( +
+ setSelectedRoleIds(prev => prev.includes(role.id) ? prev.filter(i => i !== role.id) : [...prev, role.id])} /> + + {role.description && — {role.description}} +
+ ))} +
+ )} +
+
+

Allowed Specific Certificates

+

These bypass role checks for this path

+ {activeCerts.length === 0 ? ( +

No active client certificates.

+ ) : ( +
+ {activeCerts.map(cert => ( +
+ setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])} /> + +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/lib/caddy-mtls.ts b/src/lib/caddy-mtls.ts index c5fab29b..d9560b98 100644 --- a/src/lib/caddy-mtls.ts +++ b/src/lib/caddy-mtls.ts @@ -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, caCertMap: Map, issuedClientCertMap: Map, - cAsWithAnyIssuedCerts: Set + cAsWithAnyIssuedCerts: Set, + mTlsDomainLeafOverride?: Map ): Record | null { const caCertIds = new Set(); 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(); + 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>, + certFingerprintMap: Map +): Set { + const allowed = new Set(); + + 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 { + 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>, + certFingerprintMap: Map, + baseHandlers: Record[], + reverseProxyHandler: Record +): Record[] | null { + if (accessRules.length === 0) return null; + + const subroutes: Record[] = []; + + // 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; +} + diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 0c558ff2..e796307f 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -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>; + certFingerprintMap: Map; + accessRulesByHost: Map; + }; }; 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, caCertMap: Map, issuedClientCertMap: Map, - cAsWithAnyIssuedCerts: Set + cAsWithAnyIssuedCerts: Set, + mTlsDomainLeafOverride: Map ) { const policies: Record[] = []; const readyCertificates = new Set(); 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(); + // Per-domain override: which specific leaf cert PEMs to pin (new model only) + const mTlsDomainLeafOverride = new Map(); 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(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(); + 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(); + 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, + }, } ); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 9cbd3407..044bdc72 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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(), diff --git a/src/lib/models/mtls-access-rules.ts b/src/lib/models/mtls-access-rules.ts new file mode 100644 index 00000000..48ea71cf --- /dev/null +++ b/src/lib/models/mtls-access-rules.ts @@ -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 { + 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 { + 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 { + 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>, + actorUserId: number +): Promise { + 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 = { 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 { + 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> { + 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(); + 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; +} diff --git a/src/lib/models/mtls-roles.ts b/src/lib/models/mtls-roles.ts new file mode 100644 index 00000000..f43071e9 --- /dev/null +++ b/src/lib/models/mtls-roles.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + actorUserId: number +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 for all active (non-revoked) certs. + * Used during Caddy config generation. + */ +export async function buildRoleFingerprintMap(): Promise>> { + 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>(); + 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> { + const rows = await db + .select({ + id: issuedClientCertificates.id, + fingerprint: issuedClientCertificates.fingerprintSha256, + }) + .from(issuedClientCertificates) + .where(isNull(issuedClientCertificates.revokedAt)); + + const map = new Map(); + for (const row of rows) { + map.set(row.id, normalizeFingerprint(row.fingerprint)); + } + return map; +} + +/** + * Builds a map of roleId → Set for all active (non-revoked) certs. + * Used during Caddy config generation to resolve trusted_role_ids → cert IDs. + */ +export async function buildRoleCertIdMap(): Promise>> { + 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>(); + 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"; diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index 8f09b576..96d29ea9 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -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 = { diff --git a/tests/integration/mtls-access-rules-model.test.ts b/tests/integration/mtls-access-rules-model.test.ts new file mode 100644 index 00000000..d5e9e265 --- /dev/null +++ b/tests/integration/mtls-access-rules-model.test.ts @@ -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([]); + }); +}); diff --git a/tests/integration/mtls-rbac.test.ts b/tests/integration/mtls-rbac.test.ts new file mode 100644 index 00000000..67f6b9d7 --- /dev/null +++ b/tests/integration/mtls-rbac.test.ts @@ -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]); + }); +}); diff --git a/tests/integration/mtls-roles-model.test.ts b/tests/integration/mtls-roles-model.test.ts new file mode 100644 index 00000000..cb93b13c --- /dev/null +++ b/tests/integration/mtls-roles-model.test.ts @@ -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); + }); +}); diff --git a/tests/unit/caddy-mtls-leaf-override.test.ts b/tests/unit/caddy-mtls-leaf-override.test.ts new file mode 100644 index 00000000..48825f9e --- /dev/null +++ b/tests/unit/caddy-mtls-leaf-override.test.ts @@ -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(); // 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']); + }); +}); diff --git a/tests/unit/caddy-mtls-rbac.test.ts b/tests/unit/caddy-mtls-rbac.test.ts new file mode 100644 index 00000000..d8e1f790 --- /dev/null +++ b/tests/unit/caddy-mtls-rbac.test.ts @@ -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 { + 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>([ + [1, new Set(["fp_a", "fp_b"])], + [2, new Set(["fp_c"])], + ]); + const certFpMap = new Map(); + + 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>(); + const certFpMap = new Map([ + [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>([ + [1, new Set(["fp_a"])], + ]); + const certFpMap = new Map([[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>([ + [1, new Set(["fp_a"])], + ]); + const certFpMap = new Map([[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>(); + const certFpMap = new Map(); + + 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>([ + [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; + const match = (allowRoute.match as Record[])[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; + const denyMatch = (denyRoute.match as Record[])[0]; + expect(denyMatch.path).toEqual(["/admin/*"]); + const denyHandler = (denyRoute.handle as Record[])[0]; + expect(denyHandler.status_code).toBe("403"); + + // Catch-all has no match + const catchAll = result![2] as Record; + 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; + const handler = (denyRoute.handle as Record[])[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; + const handler = (denyRoute.handle as Record[])[0]; + expect(handler.status_code).toBe("403"); + }); + + it("handles multiple rules with different paths", () => { + const roleFpMap = new Map>([ + [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([[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; + const match = (allowRoute.match as Record[])[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; + const handlers = catchAll.handle as Record[]; + 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>([[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; + const handlers = allowRoute.handle as Record[]; + 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>([[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>([[1, new Set(["fp_role"])]]); + const certFpMap = new Map([[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>([[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>([[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>([ + [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"])); + }); +});