Add mTLS RBAC with path-based access control, role/cert trust model, and comprehensive tests
Implements full role-based access control for mTLS client certificates: - Database: mtls_roles, mtls_certificate_roles, mtls_access_rules tables with migration - Models: CRUD for roles, cert-role assignments, path-based access rules - Caddy config: HTTP-layer RBAC enforcement via CEL fingerprint matching in subroutes - New trust model: select individual certs or entire roles instead of CAs (derives CAs automatically) - REST API: /api/v1/mtls-roles, cert assignments, proxy-host access rules endpoints - UI: Roles management tab (card-based), cert/role trust picker, inline RBAC rule editor - Fix: dialog autoclose bug after creating proxy host (key-based remount) - Tests: 85 new tests (785 total) covering models, schema, RBAC route generation, leaf override, edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,15 @@ import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { SearchField } from "@/components/ui/SearchField";
|
||||
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
|
||||
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView, MtlsRole } from "./page";
|
||||
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
|
||||
import { StatusSummaryBar } from "./components/StatusSummaryBar";
|
||||
import { AcmeTab } from "./components/AcmeTab";
|
||||
import { ImportedTab } from "./components/ImportedTab";
|
||||
import { CaTab } from "./components/CaTab";
|
||||
import { MtlsRolesTab } from "@/components/mtls-roles/MtlsRolesTab";
|
||||
|
||||
type TabId = "acme" | "imported" | "ca";
|
||||
type TabId = "acme" | "imported" | "ca" | "roles";
|
||||
|
||||
type Props = {
|
||||
acmeHosts: AcmeHost[];
|
||||
@@ -18,6 +20,8 @@ type Props = {
|
||||
managedCerts: ManagedCertView[];
|
||||
caCertificates: CaCertificateView[];
|
||||
acmePagination: { total: number; page: number; perPage: number };
|
||||
mtlsRoles: MtlsRole[];
|
||||
issuedClientCerts: IssuedClientCertificate[];
|
||||
};
|
||||
|
||||
function countExpiry(statuses: (CertExpiryStatus | null)[]) {
|
||||
@@ -36,11 +40,14 @@ export default function CertificatesClient({
|
||||
managedCerts,
|
||||
caCertificates,
|
||||
acmePagination,
|
||||
mtlsRoles,
|
||||
issuedClientCerts,
|
||||
}: Props) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("acme");
|
||||
const [searchAcme, setSearchAcme] = useState("");
|
||||
const [searchImported, setSearchImported] = useState("");
|
||||
const [searchCa, setSearchCa] = useState("");
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
const allStatuses: (CertExpiryStatus | null)[] = [
|
||||
@@ -48,8 +55,8 @@ export default function CertificatesClient({
|
||||
];
|
||||
const { expired, expiringSoon, healthy } = countExpiry(allStatuses);
|
||||
|
||||
const search = activeTab === "acme" ? searchAcme : activeTab === "imported" ? searchImported : searchCa;
|
||||
const setSearch = activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : setSearchCa;
|
||||
const search = activeTab === "acme" ? searchAcme : activeTab === "imported" ? searchImported : activeTab === "roles" ? searchRoles : searchCa;
|
||||
const setSearch = activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : activeTab === "roles" ? setSearchRoles : setSearchCa;
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
setActiveTab(value as TabId);
|
||||
@@ -94,6 +101,12 @@ export default function CertificatesClient({
|
||||
{caCertificates.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="roles" className="gap-1.5">
|
||||
Roles
|
||||
<span className="rounded-full bg-muted px-1.5 py-0 text-xs font-bold tabular-nums">
|
||||
{mtlsRoles.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<SearchField
|
||||
@@ -134,6 +147,13 @@ export default function CertificatesClient({
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="roles" className="mt-4">
|
||||
<MtlsRolesTab
|
||||
roles={mtlsRoles}
|
||||
issuedCerts={issuedClientCerts}
|
||||
search={searchRoles}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { Certificate } from "@/lib/models/certificates";
|
||||
import type { ProxyHost } from "@/lib/models/proxy-hosts";
|
||||
import type { CaCertificate } from "@/lib/models/ca-certificates";
|
||||
import type { AuthentikSettings } from "@/lib/settings";
|
||||
import type { MtlsRole } from "@/lib/models/mtls-roles";
|
||||
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
|
||||
import { toggleProxyHostAction } from "./actions";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { SearchField } from "@/components/ui/SearchField";
|
||||
@@ -35,13 +37,17 @@ type Props = {
|
||||
pagination: { total: number; page: number; perPage: number };
|
||||
initialSearch: string;
|
||||
initialSort?: { sortBy: string; sortDir: "asc" | "desc" };
|
||||
mtlsRoles?: MtlsRole[];
|
||||
issuedClientCerts?: IssuedClientCertificate[];
|
||||
};
|
||||
|
||||
export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch, initialSort }: Props) {
|
||||
export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch, initialSort, mtlsRoles, issuedClientCerts }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [duplicateHost, setDuplicateHost] = useState<ProxyHost | null>(null);
|
||||
const [editHost, setEditHost] = useState<ProxyHost | null>(null);
|
||||
const [deleteHost, setDeleteHost] = useState<ProxyHost | null>(null);
|
||||
// Counter forces CreateHostDialog to remount on each open, resetting useFormState
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
||||
|
||||
const router = useRouter();
|
||||
@@ -200,7 +206,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditHost(host)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { setDuplicateHost(host); setCreateOpen(true); }}>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { setDuplicateHost(host); { setDialogKey(k => k + 1); setCreateOpen(true); }; }}>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
@@ -248,7 +254,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditHost(host)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { setDuplicateHost(host); setCreateOpen(true); }}>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { setDuplicateHost(host); { setDialogKey(k => k + 1); setCreateOpen(true); }; }}>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setDeleteHost(host)}>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -264,7 +270,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
<PageHeader
|
||||
title="Proxy Hosts"
|
||||
description="Define HTTP(S) reverse proxies orchestrated by Caddy with automated certificates."
|
||||
action={{ label: "Create Host", onClick: () => setCreateOpen(true) }}
|
||||
action={{ label: "Create Host", onClick: () => { setDialogKey(k => k + 1); setCreateOpen(true); } }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -287,6 +293,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
/>
|
||||
|
||||
<CreateHostDialog
|
||||
key={dialogKey}
|
||||
open={createOpen}
|
||||
onClose={() => { setCreateOpen(false); setTimeout(() => setDuplicateHost(null), 200); }}
|
||||
initialData={duplicateHost}
|
||||
@@ -294,6 +301,8 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
accessLists={accessLists}
|
||||
authentikDefaults={authentikDefaults}
|
||||
caCertificates={caCertificates}
|
||||
mtlsRoles={mtlsRoles ?? []}
|
||||
issuedClientCerts={issuedClientCerts ?? []}
|
||||
/>
|
||||
|
||||
{editHost && (
|
||||
@@ -304,6 +313,8 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
certificates={certificates}
|
||||
accessLists={accessLists}
|
||||
caCertificates={caCertificates}
|
||||
mtlsRoles={mtlsRoles ?? []}
|
||||
issuedClientCerts={issuedClientCerts ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { listCertificates } from "@/src/lib/models/certificates";
|
||||
import { listCaCertificates } from "@/src/lib/models/ca-certificates";
|
||||
import { listAccessLists } from "@/src/lib/models/access-lists";
|
||||
import { getAuthentikSettings } from "@/src/lib/settings";
|
||||
import { listMtlsRoles } from "@/src/lib/models/mtls-roles";
|
||||
import { listIssuedClientCertificates } from "@/src/lib/models/issued-client-certificates";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
|
||||
const PER_PAGE = 25;
|
||||
@@ -29,6 +31,11 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
|
||||
listAccessLists(),
|
||||
getAuthentikSettings(),
|
||||
]);
|
||||
// These are safe to fail if the RBAC migration hasn't been applied yet
|
||||
const [mtlsRoles, issuedClientCerts] = await Promise.all([
|
||||
listMtlsRoles().catch(() => []),
|
||||
listIssuedClientCertificates().catch(() => []),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ProxyHostsClient
|
||||
@@ -40,6 +47,8 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
|
||||
pagination={{ total, page, perPage: PER_PAGE }}
|
||||
initialSearch={search ?? ""}
|
||||
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
|
||||
mtlsRoles={mtlsRoles}
|
||||
issuedClientCerts={issuedClientCerts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
17
app/api/v1/client-certificates/[id]/roles/route.ts
Normal file
17
app/api/v1/client-certificates/[id]/roles/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
|
||||
import { getCertificateRoles } from "@/src/lib/models/mtls-roles";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireApiAdmin(request);
|
||||
const { id } = await params;
|
||||
const roles = await getCertificateRoles(Number(id));
|
||||
return NextResponse.json(roles);
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
17
app/api/v1/mtls-roles/[id]/certificates/[certId]/route.ts
Normal file
17
app/api/v1/mtls-roles/[id]/certificates/[certId]/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
|
||||
import { removeRoleFromCertificate } from "@/src/lib/models/mtls-roles";
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; certId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await requireApiAdmin(request);
|
||||
const { id, certId } = await params;
|
||||
await removeRoleFromCertificate(Number(id), Number(certId), userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
22
app/api/v1/mtls-roles/[id]/certificates/route.ts
Normal file
22
app/api/v1/mtls-roles/[id]/certificates/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
|
||||
import { assignRoleToCertificate, getMtlsRole } from "@/src/lib/models/mtls-roles";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await requireApiAdmin(request);
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
if (!body.certificate_id || typeof body.certificate_id !== "number") {
|
||||
return NextResponse.json({ error: "certificate_id is required" }, { status: 400 });
|
||||
}
|
||||
await assignRoleToCertificate(Number(id), body.certificate_id, userId);
|
||||
const role = await getMtlsRole(Number(id));
|
||||
return NextResponse.json(role, { status: 201 });
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
49
app/api/v1/mtls-roles/[id]/route.ts
Normal file
49
app/api/v1/mtls-roles/[id]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
|
||||
import { getMtlsRole, updateMtlsRole, deleteMtlsRole } from "@/src/lib/models/mtls-roles";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireApiAdmin(request);
|
||||
const { id } = await params;
|
||||
const role = await getMtlsRole(Number(id));
|
||||
if (!role) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(role);
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await requireApiAdmin(request);
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const role = await updateMtlsRole(Number(id), body, userId);
|
||||
return NextResponse.json(role);
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await requireApiAdmin(request);
|
||||
const { id } = await params;
|
||||
await deleteMtlsRole(Number(id), userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
27
app/api/v1/mtls-roles/route.ts
Normal file
27
app/api/v1/mtls-roles/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
|
||||
import { listMtlsRoles, createMtlsRole } from "@/src/lib/models/mtls-roles";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await requireApiAdmin(request);
|
||||
const roles = await listMtlsRoles();
|
||||
return NextResponse.json(roles);
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await requireApiAdmin(request);
|
||||
const body = await request.json();
|
||||
if (!body.name || typeof body.name !== "string" || !body.name.trim()) {
|
||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
}
|
||||
const role = await createMtlsRole(body, userId);
|
||||
return NextResponse.json(role, { status: 201 });
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
38
app/api/v1/proxy-hosts/[id]/mtls-access-rules/route.ts
Normal file
38
app/api/v1/proxy-hosts/[id]/mtls-access-rules/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
|
||||
import { listMtlsAccessRules, createMtlsAccessRule } from "@/src/lib/models/mtls-access-rules";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireApiAdmin(request);
|
||||
const { id } = await params;
|
||||
const rules = await listMtlsAccessRules(Number(id));
|
||||
return NextResponse.json(rules);
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await requireApiAdmin(request);
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
if (!body.path_pattern || typeof body.path_pattern !== "string" || !body.path_pattern.trim()) {
|
||||
return NextResponse.json({ error: "path_pattern is required" }, { status: 400 });
|
||||
}
|
||||
const rule = await createMtlsAccessRule(
|
||||
{ ...body, proxy_host_id: Number(id) },
|
||||
userId
|
||||
);
|
||||
return NextResponse.json(rule, { status: 201 });
|
||||
} catch (error) {
|
||||
return apiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
41
drizzle/0016_mtls_rbac.sql
Normal file
41
drizzle/0016_mtls_rbac.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- mTLS RBAC: roles, certificate-role assignments, and path-based access rules
|
||||
|
||||
CREATE TABLE `mtls_roles` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`created_by` integer REFERENCES `users`(`id`) ON DELETE SET NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `mtls_roles_name_unique` ON `mtls_roles` (`name`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `mtls_certificate_roles` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`issued_client_certificate_id` integer NOT NULL REFERENCES `issued_client_certificates`(`id`) ON DELETE CASCADE,
|
||||
`mtls_role_id` integer NOT NULL REFERENCES `mtls_roles`(`id`) ON DELETE CASCADE,
|
||||
`created_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `mtls_cert_role_unique` ON `mtls_certificate_roles` (`issued_client_certificate_id`, `mtls_role_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `mtls_certificate_roles_role_idx` ON `mtls_certificate_roles` (`mtls_role_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `mtls_access_rules` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`proxy_host_id` integer NOT NULL REFERENCES `proxy_hosts`(`id`) ON DELETE CASCADE,
|
||||
`path_pattern` text NOT NULL,
|
||||
`allowed_role_ids` text NOT NULL DEFAULT '[]',
|
||||
`allowed_cert_ids` text NOT NULL DEFAULT '[]',
|
||||
`deny_all` integer NOT NULL DEFAULT 0,
|
||||
`priority` integer NOT NULL DEFAULT 0,
|
||||
`description` text,
|
||||
`created_by` integer REFERENCES `users`(`id`) ON DELETE SET NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `mtls_access_rules_proxy_host_idx` ON `mtls_access_rules` (`proxy_host_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `mtls_access_rules_host_path_unique` ON `mtls_access_rules` (`proxy_host_id`, `path_pattern`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
255
src/components/mtls-roles/MtlsRolesTab.tsx
Normal file
255
src/components/mtls-roles/MtlsRolesTab.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ShieldCheck, Plus, Trash2, UserPlus } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AppDialog } from "@/components/ui/AppDialog";
|
||||
import type { MtlsRole, MtlsRoleWithCertificates } from "@/lib/models/mtls-roles";
|
||||
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
|
||||
|
||||
const ACCENT_COLORS = [
|
||||
{ border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500", badge: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", avatar: "bg-amber-500/15 text-amber-600 dark:text-amber-400" },
|
||||
{ border: "border-l-cyan-500", icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500", badge: "border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400", avatar: "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400" },
|
||||
{ border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500", badge: "border-violet-500/30 bg-violet-500/10 text-violet-600 dark:text-violet-400", avatar: "bg-violet-500/15 text-violet-600 dark:text-violet-400" },
|
||||
{ border: "border-l-emerald-500", icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500", badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", avatar: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400" },
|
||||
{ border: "border-l-rose-500", icon: "border-rose-500/30 bg-rose-500/10 text-rose-500", badge: "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400", avatar: "bg-rose-500/15 text-rose-600 dark:text-rose-400" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
roles: MtlsRole[];
|
||||
issuedCerts: IssuedClientCertificate[];
|
||||
search: string;
|
||||
};
|
||||
|
||||
export function MtlsRolesTab({ roles, issuedCerts, search }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const activeCerts = issuedCerts.filter(c => !c.revoked_at);
|
||||
|
||||
const filtered = roles.filter(r =>
|
||||
!search ||
|
||||
r.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.description?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Create inline form */}
|
||||
{createOpen ? (
|
||||
<CreateRoleCard onClose={() => setCreateOpen(false)} />
|
||||
) : (
|
||||
<Button variant="outline" className="w-full border-dashed gap-2" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" /> Create New Role
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && !createOpen && (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-center">
|
||||
<ShieldCheck className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{search ? "No roles match your search." : "No mTLS roles yet."}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Roles group client certificates for access control on proxy hosts.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.map((role, idx) => (
|
||||
<RoleCard key={role.id} role={role} accent={ACCENT_COLORS[idx % ACCENT_COLORS.length]} activeCerts={activeCerts} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Create role inline card ── */
|
||||
|
||||
function CreateRoleCard({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) { setError("Name is required"); return; }
|
||||
setSubmitting(true); setError("");
|
||||
try {
|
||||
const res = await fetch("/api/v1/mtls-roles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||
});
|
||||
if (!res.ok) { const d = await res.json().catch(() => ({})); setError(d.error || `Failed (${res.status})`); setSubmitting(false); return; }
|
||||
onClose();
|
||||
window.location.reload();
|
||||
} catch { setError("Network error"); setSubmitting(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-l-2 border-l-primary">
|
||||
<CardContent className="pt-5 pb-4 px-5 flex flex-col gap-3">
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. admin" className="h-8 text-sm" autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="Optional" className="h-8 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating..." : "Create Role"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Single role card ── */
|
||||
|
||||
function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeof ACCENT_COLORS[0]; activeCerts: IssuedClientCertificate[] }) {
|
||||
const [assignedIds, setAssignedIds] = useState<Set<number>>(new Set());
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [toggling, setToggling] = useState<number | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(role.name);
|
||||
const [description, setDescription] = useState(role.description ?? "");
|
||||
|
||||
const loadAssignments = useCallback(() => {
|
||||
fetch(`/api/v1/mtls-roles/${role.id}`)
|
||||
.then(r => r.ok ? r.json() : { certificate_ids: [] })
|
||||
.then((data: MtlsRoleWithCertificates) => { setAssignedIds(new Set(data.certificate_ids)); setLoaded(true); })
|
||||
.catch(() => setLoaded(true));
|
||||
}, [role.id]);
|
||||
|
||||
useEffect(() => { loadAssignments(); }, [loadAssignments]);
|
||||
|
||||
async function handleToggle(certId: number) {
|
||||
const isAssigned = assignedIds.has(certId);
|
||||
setToggling(certId);
|
||||
try {
|
||||
if (isAssigned) {
|
||||
await fetch(`/api/v1/mtls-roles/${role.id}/certificates/${certId}`, { method: "DELETE" });
|
||||
setAssignedIds(prev => { const next = new Set(prev); next.delete(certId); return next; });
|
||||
} else {
|
||||
await fetch(`/api/v1/mtls-roles/${role.id}/certificates`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ certificate_id: certId }),
|
||||
});
|
||||
setAssignedIds(prev => new Set(prev).add(certId));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setToggling(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await fetch(`/api/v1/mtls-roles/${role.id}`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||
});
|
||||
setEditing(false);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Delete role "${role.name}"?`)) return;
|
||||
await fetch(`/api/v1/mtls-roles/${role.id}`, { method: "DELETE" });
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`border-l-2 ${accent.border}`}>
|
||||
<CardContent className="flex flex-col gap-4 pt-5 pb-5 px-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border ${accent.icon}`}>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{role.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedIds.size} {assignedIds.size === 1 ? "certificate" : "certificates"}
|
||||
{role.description && ` · ${role.description}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-bold tabular-nums ${accent.badge}`}>
|
||||
{assignedIds.size}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Edit form */}
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} className="h-8 text-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} className="h-8 text-sm" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setEditing(false)}>Cancel</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setEditing(true)}>Edit</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-destructive" onClick={handleDelete}>Delete role</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Certificates */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Certificates</p>
|
||||
|
||||
{!loaded ? (
|
||||
<p className="text-sm text-muted-foreground py-2">Loading...</p>
|
||||
) : activeCerts.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3 text-sm text-muted-foreground">
|
||||
<UserPlus className="h-4 w-4 shrink-0" />
|
||||
No client certificates issued yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
|
||||
{activeCerts.map(cert => {
|
||||
const isAssigned = assignedIds.has(cert.id);
|
||||
const isLoading = toggling === cert.id;
|
||||
return (
|
||||
<div key={cert.id} className={`flex items-center justify-between px-3 py-2 bg-muted/20 hover:bg-muted/40 transition-colors ${isLoading ? "opacity-50" : ""}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Checkbox checked={isAssigned} disabled={isLoading} onCheckedChange={() => handleToggle(cert.id)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-tight">{cert.common_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
expires {new Date(cert.valid_to).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAssigned && <Badge variant="secondary" className="text-xs">Assigned</Badge>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import { RedirectsFields } from "./RedirectsFields";
|
||||
import { LocationRulesFields } from "./LocationRulesFields";
|
||||
import { RewriteFields } from "./RewriteFields";
|
||||
import type { CaCertificate } from "@/lib/models/ca-certificates";
|
||||
import type { MtlsRole } from "@/lib/models/mtls-roles";
|
||||
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
|
||||
|
||||
export function CreateHostDialog({
|
||||
open,
|
||||
@@ -36,7 +38,9 @@ export function CreateHostDialog({
|
||||
accessLists,
|
||||
authentikDefaults,
|
||||
initialData,
|
||||
caCertificates = []
|
||||
caCertificates = [],
|
||||
mtlsRoles = [],
|
||||
issuedClientCerts = [],
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -45,6 +49,8 @@ export function CreateHostDialog({
|
||||
authentikDefaults: AuthentikSettings | null;
|
||||
initialData?: ProxyHost | null;
|
||||
caCertificates?: CaCertificate[];
|
||||
mtlsRoles?: MtlsRole[];
|
||||
issuedClientCerts?: IssuedClientCertificate[];
|
||||
}) {
|
||||
const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE);
|
||||
|
||||
@@ -164,7 +170,12 @@ export function CreateHostDialog({
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
|
||||
<GeoBlockFields />
|
||||
<WafFields value={initialData?.waf} />
|
||||
<MtlsFields value={initialData?.mtls} caCertificates={caCertificates} />
|
||||
<MtlsFields
|
||||
value={initialData?.mtls}
|
||||
caCertificates={caCertificates}
|
||||
mtlsRoles={mtlsRoles}
|
||||
issuedClientCerts={issuedClientCerts}
|
||||
/>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
@@ -176,7 +187,9 @@ export function EditHostDialog({
|
||||
onClose,
|
||||
certificates,
|
||||
accessLists,
|
||||
caCertificates = []
|
||||
caCertificates = [],
|
||||
mtlsRoles = [],
|
||||
issuedClientCerts = [],
|
||||
}: {
|
||||
open: boolean;
|
||||
host: ProxyHost;
|
||||
@@ -184,6 +197,8 @@ export function EditHostDialog({
|
||||
certificates: Certificate[];
|
||||
accessLists: AccessList[];
|
||||
caCertificates?: CaCertificate[];
|
||||
mtlsRoles?: MtlsRole[];
|
||||
issuedClientCerts?: IssuedClientCertificate[];
|
||||
}) {
|
||||
const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
|
||||
|
||||
@@ -298,7 +313,13 @@ export function EditHostDialog({
|
||||
}}
|
||||
/>
|
||||
<WafFields value={host.waf} />
|
||||
<MtlsFields value={host.mtls} caCertificates={caCertificates} />
|
||||
<MtlsFields
|
||||
value={host.mtls}
|
||||
caCertificates={caCertificates}
|
||||
proxyHostId={host.id}
|
||||
mtlsRoles={mtlsRoles}
|
||||
issuedClientCerts={issuedClientCerts}
|
||||
/>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { LockKeyhole, Plus, Pencil, Trash2, ShieldAlert, Ban, UserCheck, ShieldCheck } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AppDialog } from "@/components/ui/AppDialog";
|
||||
import type { CaCertificate } from "@/lib/models/ca-certificates";
|
||||
import type { MtlsConfig } from "@/lib/models/proxy-hosts";
|
||||
import type { MtlsAccessRule } from "@/lib/models/mtls-access-rules";
|
||||
import type { MtlsRole } from "@/lib/models/mtls-roles";
|
||||
import type { IssuedClientCertificate } from "@/lib/models/issued-client-certificates";
|
||||
|
||||
type Props = {
|
||||
value?: MtlsConfig | null;
|
||||
caCertificates: CaCertificate[];
|
||||
issuedClientCerts?: IssuedClientCertificate[];
|
||||
proxyHostId?: number;
|
||||
mtlsRoles?: MtlsRole[];
|
||||
};
|
||||
|
||||
export function MtlsFields({ value, caCertificates }: Props) {
|
||||
export function MtlsFields({ value, caCertificates, issuedClientCerts = [], proxyHostId, mtlsRoles = [] }: Props) {
|
||||
const [enabled, setEnabled] = useState(value?.enabled ?? false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>(value?.ca_certificate_ids ?? []);
|
||||
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(value?.trusted_client_cert_ids ?? []);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(value?.trusted_role_ids ?? []);
|
||||
|
||||
function toggleId(id: number) {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
const [rules, setRules] = useState<MtlsAccessRule[]>([]);
|
||||
const [rulesLoaded, setRulesLoaded] = useState(false);
|
||||
const [addRuleOpen, setAddRuleOpen] = useState(false);
|
||||
const [editRule, setEditRule] = useState<MtlsAccessRule | null>(null);
|
||||
|
||||
const isEditMode = !!proxyHostId;
|
||||
const activeCerts = issuedClientCerts.filter(c => !c.revoked_at);
|
||||
|
||||
const certsByCA = new Map<number, IssuedClientCertificate[]>();
|
||||
for (const cert of activeCerts) {
|
||||
const list = certsByCA.get(cert.ca_certificate_id) ?? [];
|
||||
list.push(cert);
|
||||
certsByCA.set(cert.ca_certificate_id, list);
|
||||
}
|
||||
|
||||
const loadRules = useCallback(() => {
|
||||
if (!proxyHostId) return;
|
||||
fetch(`/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((data: MtlsAccessRule[]) => { setRules(data); setRulesLoaded(true); })
|
||||
.catch(() => { setRules([]); setRulesLoaded(true); });
|
||||
}, [proxyHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && enabled) loadRules();
|
||||
}, [isEditMode, enabled, loadRules]);
|
||||
|
||||
function toggleCert(certId: number) {
|
||||
setSelectedCertIds(prev => prev.includes(certId) ? prev.filter(i => i !== certId) : [...prev, certId]);
|
||||
}
|
||||
|
||||
function toggleRole(roleId: number) {
|
||||
setSelectedRoleIds(prev => prev.includes(roleId) ? prev.filter(i => i !== roleId) : [...prev, roleId]);
|
||||
}
|
||||
|
||||
function toggleAllFromCA(caId: number) {
|
||||
const caCerts = certsByCA.get(caId) ?? [];
|
||||
const caIds = caCerts.map(c => c.id);
|
||||
const allSelected = caIds.every(id => selectedCertIds.includes(id));
|
||||
setSelectedCertIds(prev => allSelected ? prev.filter(id => !caIds.includes(id)) : [...new Set([...prev, ...caIds])]);
|
||||
}
|
||||
|
||||
async function deleteRule(ruleId: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules/${ruleId}`, { method: "DELETE" });
|
||||
if (res.ok) setRules(prev => prev.filter(r => r.id !== ruleId));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const hasTrust = selectedCertIds.length > 0 || selectedRoleIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/60 bg-amber-500/5 p-4">
|
||||
<input type="hidden" name="mtls_present" value="1" />
|
||||
<input type="hidden" name="mtls_enabled" value={enabled ? "true" : "false"} />
|
||||
{enabled && selectedIds.map(id => (
|
||||
<input key={id} type="hidden" name="mtls_ca_cert_id" value={String(id)} />
|
||||
{enabled && selectedCertIds.map(id => (
|
||||
<input key={`c${id}`} type="hidden" name="mtls_cert_id" value={String(id)} />
|
||||
))}
|
||||
{enabled && selectedRoleIds.map(id => (
|
||||
<input key={`r${id}`} type="hidden" name="mtls_role_id" value={String(id)} />
|
||||
))}
|
||||
|
||||
{/* Header */}
|
||||
@@ -41,52 +102,285 @@ export function MtlsFields({ value, caCertificates }: Props) {
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold leading-snug">Mutual TLS (mTLS)</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Require clients to present a certificate signed by a trusted CA
|
||||
Require clients to present a trusted certificate to connect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
enabled ? "max-h-[1000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
|
||||
enabled ? "max-h-[4000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<Alert className="mb-4">
|
||||
<AlertDescription>
|
||||
mTLS requires TLS to be configured on this host (certificate must be set).
|
||||
Select roles and/or individual certificates to allow.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Trusted Client CA Certificates
|
||||
</span>
|
||||
{/* ── Trusted Roles ── */}
|
||||
{mtlsRoles.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-500" />
|
||||
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Trusted Roles
|
||||
</p>
|
||||
{selectedRoleIds.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs ml-auto">{selectedRoleIds.length} selected</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border bg-background mb-4">
|
||||
{mtlsRoles.map(role => (
|
||||
<div key={role.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-muted/30 border-b last:border-b-0">
|
||||
<Checkbox
|
||||
checked={selectedRoleIds.includes(role.id)}
|
||||
onCheckedChange={() => toggleRole(role.id)}
|
||||
/>
|
||||
<label className="flex-1 min-w-0 cursor-pointer" onClick={() => toggleRole(role.id)}>
|
||||
<span className="text-sm font-medium">{role.name}</span>
|
||||
{role.description && <span className="text-xs text-muted-foreground ml-2">— {role.description}</span>}
|
||||
</label>
|
||||
<Badge variant="outline" className="text-xs shrink-0">{role.certificate_count} certs</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{caCertificates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
No CA certificates configured. Add them on the Certificates page.
|
||||
{/* ── Trusted Certificates ── */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<UserCheck className="h-4 w-4 text-amber-500" />
|
||||
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Trusted Certificates
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col mt-1">
|
||||
{caCertificates.map(ca => (
|
||||
<div key={ca.id} className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
id={`ca-cert-${ca.id}`}
|
||||
checked={selectedIds.includes(ca.id)}
|
||||
onCheckedChange={() => toggleId(ca.id)}
|
||||
/>
|
||||
<label htmlFor={`ca-cert-${ca.id}`} className="text-sm cursor-pointer">
|
||||
{ca.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{selectedCertIds.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs ml-auto">{selectedCertIds.length} selected</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeCerts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">No client certificates issued yet.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Issue certificates from a CA on the Certificates page.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from(certsByCA.entries()).map(([caId, certs]) => {
|
||||
const ca = caCertificates.find(c => c.id === caId);
|
||||
const caName = ca?.name ?? `CA #${caId}`;
|
||||
const allSelected = certs.every(c => selectedCertIds.includes(c.id));
|
||||
const someSelected = certs.some(c => selectedCertIds.includes(c.id));
|
||||
|
||||
return (
|
||||
<div key={caId} className="rounded-md border bg-background">
|
||||
<div className="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 rounded-t-md">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => toggleAllFromCA(caId)}
|
||||
className={someSelected && !allSelected ? "opacity-60" : ""}
|
||||
/>
|
||||
<label
|
||||
className="text-xs font-semibold text-muted-foreground uppercase tracking-wide cursor-pointer flex-1"
|
||||
onClick={() => toggleAllFromCA(caId)}
|
||||
>
|
||||
{caName}
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{certs.filter(c => selectedCertIds.includes(c.id)).length}/{certs.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t">
|
||||
{certs.map(cert => (
|
||||
<div key={cert.id} className="flex items-center gap-2.5 px-3 py-1.5 hover:bg-muted/30">
|
||||
<Checkbox
|
||||
checked={selectedCertIds.includes(cert.id)}
|
||||
onCheckedChange={() => toggleCert(cert.id)}
|
||||
className="ml-4"
|
||||
/>
|
||||
<label className="min-w-0 flex-1 cursor-pointer" onClick={() => toggleCert(cert.id)}>
|
||||
<span className="text-sm">{cert.common_name}</span>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
expires {new Date(cert.valid_to).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasTrust && activeCerts.length > 0 && (
|
||||
<p className="text-xs text-destructive mt-2">No roles or certificates selected — mTLS will block all connections.</p>
|
||||
)}
|
||||
|
||||
{/* ── RBAC rules ── */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-500" />
|
||||
<p className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Path-Based Access Rules
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => setAddRuleOpen(true)}>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Restrict specific paths to certain roles or certificates. Paths without rules allow any trusted cert/role above.
|
||||
</p>
|
||||
|
||||
{!rulesLoaded ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-3">Loading...</p>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">No access rules configured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">All trusted certificates/roles have equal access to every path.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{rules.map(rule => (
|
||||
<div key={rule.id} className="group flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.path_pattern}</code>
|
||||
{rule.deny_all ? (
|
||||
<Badge variant="destructive" className="text-xs gap-1"><Ban className="h-3 w-3" /> Deny</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
|
||||
{rule.allowed_role_ids.map(roleId => {
|
||||
const role = mtlsRoles.find(r => r.id === roleId);
|
||||
return <Badge key={`r-${roleId}`} variant="secondary" className="text-xs">{role?.name ?? `#${roleId}`}</Badge>;
|
||||
})}
|
||||
{rule.allowed_cert_ids.map(certId => {
|
||||
const cert = issuedClientCerts.find(c => c.id === certId);
|
||||
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.common_name ?? `#${certId}`}</Badge>;
|
||||
})}
|
||||
{rule.allowed_role_ids.length === 0 && rule.allowed_cert_ids.length === 0 && (
|
||||
<span className="text-xs text-destructive italic">No roles/certs — effectively denied</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button type="button" size="icon" variant="ghost" className="h-6 w-6" onClick={() => setEditRule(rule)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" className="h-6 w-6 text-destructive hover:text-destructive" onClick={() => deleteRule(rule.id)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addRuleOpen && (
|
||||
<RuleDialog onClose={() => setAddRuleOpen(false)} proxyHostId={proxyHostId!} roles={mtlsRoles} activeCerts={activeCerts} title="Add Access Rule" submitLabel="Add Rule" onSaved={loadRules} />
|
||||
)}
|
||||
{editRule && (
|
||||
<RuleDialog onClose={() => setEditRule(null)} proxyHostId={proxyHostId!} roles={mtlsRoles} activeCerts={activeCerts} title="Edit Access Rule" submitLabel="Save" existing={editRule} onSaved={loadRules} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLabel, existing, onSaved }: {
|
||||
onClose: () => void; proxyHostId: number; roles: MtlsRole[]; activeCerts: IssuedClientCertificate[];
|
||||
title: string; submitLabel: string; existing?: MtlsAccessRule; onSaved: () => void;
|
||||
}) {
|
||||
const [pathPattern, setPathPattern] = useState(existing?.path_pattern ?? "*");
|
||||
const [priority, setPriority] = useState(String(existing?.priority ?? 0));
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowed_role_ids ?? []);
|
||||
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowed_cert_ids ?? []);
|
||||
const [denyAll, setDenyAll] = useState(existing?.deny_all ?? false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!pathPattern.trim()) { setError("Path pattern is required"); return; }
|
||||
setSubmitting(true); setError("");
|
||||
try {
|
||||
const url = existing
|
||||
? `/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules/${existing.id}`
|
||||
: `/api/v1/proxy-hosts/${proxyHostId}/mtls-access-rules`;
|
||||
const res = await fetch(url, {
|
||||
method: existing ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path_pattern: pathPattern.trim(), priority: Number(priority) || 0, description: description || null, allowed_role_ids: selectedRoleIds, allowed_cert_ids: selectedCertIds, deny_all: denyAll }),
|
||||
});
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); setError(data.error || `Failed (${res.status})`); setSubmitting(false); return; }
|
||||
onSaved(); onClose();
|
||||
} catch { setError("Network error"); setSubmitting(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<AppDialog open onClose={onClose} title={title} submitLabel={submitLabel} onSubmit={handleSubmit} isSubmitting={submitting}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Label>Path Pattern</Label>
|
||||
<Input value={pathPattern} onChange={e => setPathPattern(e.target.value)} placeholder="*" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Use * for all paths, /admin/* for prefix match</p>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<Label>Priority</Label>
|
||||
<Input type="number" value={priority} onChange={e => setPriority(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2.5">
|
||||
<Switch checked={denyAll} onCheckedChange={setDenyAll} />
|
||||
<Label className="text-sm cursor-pointer">Deny all access to this path</Label>
|
||||
</div>
|
||||
<div className={cn(denyAll && "opacity-30 pointer-events-none", "flex flex-col gap-4")}>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Allowed Roles</p>
|
||||
{roles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No mTLS roles yet. Create roles on the Certificates page.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{roles.map(role => (
|
||||
<div key={role.id} className="flex items-center gap-2 py-1 rounded hover:bg-muted/50 px-1">
|
||||
<Checkbox checked={selectedRoleIds.includes(role.id)} onCheckedChange={() => setSelectedRoleIds(prev => prev.includes(role.id) ? prev.filter(i => i !== role.id) : [...prev, role.id])} />
|
||||
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedRoleIds(prev => prev.includes(role.id) ? prev.filter(i => i !== role.id) : [...prev, role.id])}>{role.name}</label>
|
||||
{role.description && <span className="text-xs text-muted-foreground ml-1">— {role.description}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-0.5">Allowed Specific Certificates</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">These bypass role checks for this path</p>
|
||||
{activeCerts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active client certificates.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
|
||||
{activeCerts.map(cert => (
|
||||
<div key={cert.id} className="flex items-center gap-2 py-1 rounded hover:bg-muted/50 px-1">
|
||||
<Checkbox checked={selectedCertIds.includes(cert.id)} onCheckedChange={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])} />
|
||||
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.common_name}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
/**
|
||||
* mTLS helper functions for building Caddy TLS connection policies.
|
||||
* mTLS helper functions for building Caddy TLS connection policies
|
||||
* and HTTP-layer RBAC route enforcement.
|
||||
*
|
||||
* Extracted from caddy.ts so they can be unit-tested independently.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalise a fingerprint to the format Caddy uses:
|
||||
* lowercase hex without colons.
|
||||
*
|
||||
* Node's X509Certificate.fingerprint256 returns "AB:CD:EF:..." (uppercase, colons).
|
||||
* Caddy's {http.request.tls.client.fingerprint} returns "abcdef..." (lowercase, no colons).
|
||||
*/
|
||||
export function normalizeFingerprint(fp: string): string {
|
||||
return fp.replace(/:/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal type matching MtlsAccessRule from the models layer.
|
||||
* Defined here to avoid importing from models (which pulls in db.ts).
|
||||
*/
|
||||
export type MtlsAccessRuleLike = {
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a PEM certificate to base64-encoded DER format expected by Caddy's
|
||||
* `trusted_ca_certs` and `trusted_leaf_certs` fields.
|
||||
@@ -36,7 +59,8 @@ export function buildClientAuthentication(
|
||||
mTlsDomainMap: Map<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>,
|
||||
cAsWithAnyIssuedCerts: Set<number>
|
||||
cAsWithAnyIssuedCerts: Set<number>,
|
||||
mTlsDomainLeafOverride?: Map<string, string[]>
|
||||
): Record<string, unknown> | null {
|
||||
const caCertIds = new Set<number>();
|
||||
for (const domain of domains) {
|
||||
@@ -47,27 +71,50 @@ export function buildClientAuthentication(
|
||||
}
|
||||
if (caCertIds.size === 0) return null;
|
||||
|
||||
// Check if any domain in this group uses the new cert-based model (has leaf override)
|
||||
const leafOverridePems = new Set<string>();
|
||||
let hasLeafOverride = false;
|
||||
if (mTlsDomainLeafOverride) {
|
||||
for (const domain of domains) {
|
||||
const pems = mTlsDomainLeafOverride.get(domain.toLowerCase());
|
||||
if (pems) {
|
||||
hasLeafOverride = true;
|
||||
for (const pem of pems) leafOverridePems.add(pem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCaCerts: string[] = [];
|
||||
const trustedLeafCerts: string[] = [];
|
||||
|
||||
for (const id of caCertIds) {
|
||||
const ca = caCertMap.get(id);
|
||||
if (!ca) continue;
|
||||
if (hasLeafOverride) {
|
||||
// New cert-based model: CAs were derived from selected certs.
|
||||
// Add CAs for chain validation, pin to only the explicitly selected leaf certs.
|
||||
for (const id of caCertIds) {
|
||||
const ca = caCertMap.get(id);
|
||||
if (ca) trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
}
|
||||
for (const pem of leafOverridePems) {
|
||||
trustedLeafCerts.push(pemToBase64Der(pem));
|
||||
}
|
||||
} else {
|
||||
// Legacy CA-based model
|
||||
for (const id of caCertIds) {
|
||||
const ca = caCertMap.get(id);
|
||||
if (!ca) continue;
|
||||
|
||||
if (cAsWithAnyIssuedCerts.has(id)) {
|
||||
// Managed CA: enforce revocation via leaf pinning
|
||||
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
|
||||
if (activeLeafCerts.length === 0) {
|
||||
// All certs revoked: exclude CA so chain validation fails for its certs
|
||||
continue;
|
||||
if (cAsWithAnyIssuedCerts.has(id)) {
|
||||
const activeLeafCerts = issuedClientCertMap.get(id) ?? [];
|
||||
if (activeLeafCerts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
for (const certPem of activeLeafCerts) {
|
||||
trustedLeafCerts.push(pemToBase64Der(certPem));
|
||||
}
|
||||
} else {
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
}
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
for (const certPem of activeLeafCerts) {
|
||||
trustedLeafCerts.push(pemToBase64Der(certPem));
|
||||
}
|
||||
} else {
|
||||
// Unmanaged CA: trust any cert in the chain
|
||||
trustedCaCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,3 +155,129 @@ export function groupMtlsDomainsByCaSet(
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ── mTLS RBAC HTTP-layer route enforcement ───────────────────────────
|
||||
|
||||
/**
|
||||
* For a single access rule, resolve the set of allowed fingerprints by unioning:
|
||||
* - All active cert fingerprints from certs that hold any of the allowed roles
|
||||
* - All active cert fingerprints from directly-allowed cert IDs
|
||||
*/
|
||||
export function resolveAllowedFingerprints(
|
||||
rule: MtlsAccessRuleLike,
|
||||
roleFingerprintMap: Map<number, Set<string>>,
|
||||
certFingerprintMap: Map<number, string>
|
||||
): Set<string> {
|
||||
const allowed = new Set<string>();
|
||||
|
||||
for (const roleId of rule.allowed_role_ids) {
|
||||
const fps = roleFingerprintMap.get(roleId);
|
||||
if (fps) {
|
||||
for (const fp of fps) allowed.add(fp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const certId of rule.allowed_cert_ids) {
|
||||
const fp = certFingerprintMap.get(certId);
|
||||
if (fp) allowed.add(fp);
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CEL expression that checks whether the client certificate's
|
||||
* fingerprint is in the given set of allowed fingerprints.
|
||||
*
|
||||
* Uses Caddy's `{http.request.tls.client.fingerprint}` placeholder.
|
||||
*/
|
||||
export function buildFingerprintCelExpression(fingerprints: Set<string>): string {
|
||||
const fps = Array.from(fingerprints).sort();
|
||||
const quoted = fps.map((fp) => `'${fp}'`).join(", ");
|
||||
return `{http.request.tls.client.fingerprint} in [${quoted}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a proxy host's mTLS access rules, builds subroutes that enforce
|
||||
* path-based RBAC at the HTTP layer (after TLS handshake).
|
||||
*
|
||||
* Returns null if there are no access rules (caller should use normal routing).
|
||||
*
|
||||
* The returned subroutes:
|
||||
* - For each rule (ordered by priority desc), emit a path+fingerprint match
|
||||
* route (allow) followed by a path-only route (deny 403).
|
||||
* - After all rules, a catch-all route allows any valid cert (preserving
|
||||
* backwards-compatible behavior for paths without rules).
|
||||
*/
|
||||
export function buildMtlsRbacSubroutes(
|
||||
accessRules: MtlsAccessRuleLike[],
|
||||
roleFingerprintMap: Map<number, Set<string>>,
|
||||
certFingerprintMap: Map<number, string>,
|
||||
baseHandlers: Record<string, unknown>[],
|
||||
reverseProxyHandler: Record<string, unknown>
|
||||
): Record<string, unknown>[] | null {
|
||||
if (accessRules.length === 0) return null;
|
||||
|
||||
const subroutes: Record<string, unknown>[] = [];
|
||||
|
||||
// Rules are already sorted by priority desc, path asc
|
||||
for (const rule of accessRules) {
|
||||
if (rule.deny_all) {
|
||||
// Explicit deny: any request matching this path gets 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
body: "mTLS access denied",
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowedFps = resolveAllowedFingerprints(rule, roleFingerprintMap, certFingerprintMap);
|
||||
|
||||
if (allowedFps.size === 0) {
|
||||
// Rule exists but no certs match → deny all for this path
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
body: "mTLS access denied",
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow route: path + fingerprint CEL match
|
||||
const celExpr = buildFingerprintCelExpression(allowedFps);
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern], expression: celExpr }],
|
||||
handle: [...baseHandlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
// Deny route: path matches but fingerprint didn't → 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
body: "mTLS access denied",
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Catch-all: paths without explicit rules → any valid cert gets through
|
||||
subroutes.push({
|
||||
handle: [...baseHandlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
return subroutes;
|
||||
}
|
||||
|
||||
|
||||
133
src/lib/caddy.ts
133
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<number, Set<string>>;
|
||||
certFingerprintMap: Map<number, string>;
|
||||
accessRulesByHost: Map<number, MtlsAccessRuleLike[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export function buildLocationReverseProxy(
|
||||
@@ -1106,6 +1113,12 @@ async function buildProxyRoutes(
|
||||
}
|
||||
} else {
|
||||
const locationRules = meta.location_rules ?? [];
|
||||
|
||||
// Check for mTLS RBAC access rules for this proxy host
|
||||
const hostAccessRules = options.mtlsRbac?.accessRulesByHost.get(row.id);
|
||||
const hasMtlsRbac = hostAccessRules && hostAccessRules.length > 0
|
||||
&& options.mtlsRbac?.roleFingerprintMap && options.mtlsRbac?.certFingerprintMap;
|
||||
|
||||
for (const domainGroup of domainGroups) {
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
@@ -1120,12 +1133,41 @@ async function buildProxyRoutes(
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
const route: CaddyHttpRoute = {
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [...handlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
};
|
||||
hostRoutes.push(route);
|
||||
|
||||
if (hasMtlsRbac) {
|
||||
// mTLS RBAC: wrap in subroute with path-based fingerprint enforcement
|
||||
const rbacSubroutes = buildMtlsRbacSubroutes(
|
||||
hostAccessRules,
|
||||
options.mtlsRbac!.roleFingerprintMap,
|
||||
options.mtlsRbac!.certFingerprintMap,
|
||||
handlers,
|
||||
reverseProxyHandler
|
||||
);
|
||||
if (rbacSubroutes) {
|
||||
hostRoutes.push({
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [{
|
||||
handler: "subroute",
|
||||
routes: rbacSubroutes,
|
||||
}],
|
||||
terminal: true,
|
||||
});
|
||||
} else {
|
||||
// Fallback: no subroutes generated, use normal routing
|
||||
hostRoutes.push({
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [...handlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const route: CaddyHttpRoute = {
|
||||
match: [{ host: domainGroup }],
|
||||
handle: [...handlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
};
|
||||
hostRoutes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,14 +1184,15 @@ function buildTlsConnectionPolicies(
|
||||
mTlsDomainMap: Map<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>,
|
||||
cAsWithAnyIssuedCerts: Set<number>
|
||||
cAsWithAnyIssuedCerts: Set<number>,
|
||||
mTlsDomainLeafOverride: Map<string, string[]>
|
||||
) {
|
||||
const policies: Record<string, unknown>[] = [];
|
||||
const readyCertificates = new Set<number>();
|
||||
const importedCertPems: { certificate: string; key: string }[] = [];
|
||||
|
||||
const buildAuth = (domains: string[]) =>
|
||||
buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts);
|
||||
buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts, mTlsDomainLeafOverride);
|
||||
|
||||
/**
|
||||
* Pushes one TLS policy per unique CA set found in `mTlsDomains`.
|
||||
@@ -1628,6 +1671,7 @@ async function buildCaddyDocument() {
|
||||
.from(caCertificates),
|
||||
db
|
||||
.select({
|
||||
id: issuedClientCertificates.id,
|
||||
caCertificateId: issuedClientCertificates.caCertificateId,
|
||||
certificatePem: issuedClientCertificates.certificatePem
|
||||
})
|
||||
@@ -1692,18 +1736,71 @@ async function buildCaddyDocument() {
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
// Build domain → CA cert IDs map for mTLS-enabled hosts
|
||||
// Build a lookup: issued cert ID → { id, caCertificateId, certificatePem }
|
||||
const issuedCertById = new Map(issuedClientCertRows.map(r => [r.id, r]));
|
||||
|
||||
// Resolve role IDs → cert IDs for trusted_role_ids in mTLS config
|
||||
const roleCertIdMap = await buildRoleCertIdMap();
|
||||
|
||||
// Build domain → CA cert IDs map for mTLS-enabled hosts.
|
||||
// New model (trusted_client_cert_ids + trusted_role_ids): derive CAs from selected certs and pin to those certs.
|
||||
// Old model (ca_certificate_ids): trust entire CAs as before.
|
||||
const mTlsDomainMap = new Map<string, number[]>();
|
||||
// Per-domain override: which specific leaf cert PEMs to pin (new model only)
|
||||
const mTlsDomainLeafOverride = new Map<string, string[]>();
|
||||
for (const row of proxyHostRows) {
|
||||
if (!row.enabled) continue;
|
||||
const meta = parseJson<{ mtls?: MtlsConfig }>(row.meta, {});
|
||||
if (!meta.mtls?.enabled || !meta.mtls.ca_certificate_ids?.length) continue;
|
||||
if (!meta.mtls?.enabled) continue;
|
||||
|
||||
const domains = parseJson<string[]>(row.domains, []).map(d => d.trim().toLowerCase()).filter(Boolean);
|
||||
for (const domain of domains) {
|
||||
mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids);
|
||||
if (domains.length === 0) continue;
|
||||
|
||||
// Collect all trusted cert IDs from both direct selection and roles
|
||||
const allCertIds = new Set<number>();
|
||||
if (meta.mtls.trusted_client_cert_ids) {
|
||||
for (const id of meta.mtls.trusted_client_cert_ids) allCertIds.add(id);
|
||||
}
|
||||
if (meta.mtls.trusted_role_ids) {
|
||||
for (const roleId of meta.mtls.trusted_role_ids) {
|
||||
const certIds = roleCertIdMap.get(roleId);
|
||||
if (certIds) for (const id of certIds) allCertIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (allCertIds.size > 0) {
|
||||
// New model: derive CAs from resolved cert IDs and collect leaf PEMs
|
||||
const derivedCaIds = new Set<number>();
|
||||
const leafPems: string[] = [];
|
||||
for (const certId of allCertIds) {
|
||||
const cert = issuedCertById.get(certId);
|
||||
if (cert) {
|
||||
derivedCaIds.add(cert.caCertificateId);
|
||||
leafPems.push(cert.certificatePem);
|
||||
}
|
||||
}
|
||||
if (derivedCaIds.size === 0) continue;
|
||||
const caIdArr = Array.from(derivedCaIds);
|
||||
for (const domain of domains) {
|
||||
mTlsDomainMap.set(domain, caIdArr);
|
||||
mTlsDomainLeafOverride.set(domain, leafPems);
|
||||
}
|
||||
} else if (meta.mtls.ca_certificate_ids?.length) {
|
||||
// Legacy model: trust entire CAs (backward compat)
|
||||
for (const domain of domains) {
|
||||
mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build mTLS RBAC data for HTTP-layer enforcement
|
||||
const enabledProxyHostIds = proxyHostRows.filter((r) => r.enabled).map((r) => r.id);
|
||||
const [roleFingerprintMap, certFingerprintMap, accessRulesByHost] = await Promise.all([
|
||||
buildRoleFingerprintMap(),
|
||||
buildCertFingerprintMap(),
|
||||
getAccessRulesForHosts(enabledProxyHostIds),
|
||||
]);
|
||||
|
||||
const { usage: certificateUsage, autoManagedDomains } = collectCertificateUsage(proxyHostRows, certificateMap);
|
||||
const [generalSettings, dnsSettings, upstreamDnsResolutionSettings, globalGeoBlock, globalWaf] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
@@ -1723,7 +1820,8 @@ async function buildCaddyDocument() {
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
issuedClientCertMap,
|
||||
cAsWithAnyIssuedCerts
|
||||
cAsWithAnyIssuedCerts,
|
||||
mTlsDomainLeafOverride
|
||||
);
|
||||
|
||||
const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes(
|
||||
@@ -1734,7 +1832,12 @@ async function buildCaddyDocument() {
|
||||
globalDnsSettings: dnsSettings,
|
||||
globalUpstreamDnsResolutionSettings: upstreamDnsResolutionSettings,
|
||||
globalGeoBlock,
|
||||
globalWaf
|
||||
globalWaf,
|
||||
mtlsRbac: {
|
||||
roleFingerprintMap,
|
||||
certFingerprintMap,
|
||||
accessRulesByHost,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -274,6 +274,70 @@ export const wafLogParseState = sqliteTable('waf_log_parse_state', {
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
// ── mTLS RBAC ──────────────────────────────────────────────────────────
|
||||
|
||||
export const mtlsRoles = sqliteTable(
|
||||
"mtls_roles",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("mtls_roles_name_unique").on(table.name)
|
||||
})
|
||||
);
|
||||
|
||||
export const mtlsCertificateRoles = sqliteTable(
|
||||
"mtls_certificate_roles",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
issuedClientCertificateId: integer("issued_client_certificate_id")
|
||||
.references(() => issuedClientCertificates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
mtlsRoleId: integer("mtls_role_id")
|
||||
.references(() => mtlsRoles.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
certRoleUnique: uniqueIndex("mtls_cert_role_unique").on(
|
||||
table.issuedClientCertificateId,
|
||||
table.mtlsRoleId
|
||||
),
|
||||
roleIdx: index("mtls_certificate_roles_role_idx").on(table.mtlsRoleId)
|
||||
})
|
||||
);
|
||||
|
||||
export const mtlsAccessRules = sqliteTable(
|
||||
"mtls_access_rules",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
proxyHostId: integer("proxy_host_id")
|
||||
.references(() => proxyHosts.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
pathPattern: text("path_pattern").notNull(),
|
||||
allowedRoleIds: text("allowed_role_ids").notNull().default("[]"),
|
||||
allowedCertIds: text("allowed_cert_ids").notNull().default("[]"),
|
||||
denyAll: integer("deny_all", { mode: "boolean" }).notNull().default(false),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
proxyHostIdx: index("mtls_access_rules_proxy_host_idx").on(table.proxyHostId),
|
||||
hostPathUnique: uniqueIndex("mtls_access_rules_host_path_unique").on(
|
||||
table.proxyHostId,
|
||||
table.pathPattern
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
|
||||
194
src/lib/models/mtls-access-rules.ts
Normal file
194
src/lib/models/mtls-access-rules.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { mtlsAccessRules } from "../db/schema";
|
||||
import { asc, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type MtlsAccessRule = {
|
||||
id: number;
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
priority: number;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MtlsAccessRuleInput = {
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids?: number[];
|
||||
allowed_cert_ids?: number[];
|
||||
deny_all?: boolean;
|
||||
priority?: number;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type RuleRow = typeof mtlsAccessRules.$inferSelect;
|
||||
|
||||
function parseJsonIds(raw: string): number[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.filter((n: unknown) => typeof n === "number" && Number.isFinite(n));
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function toMtlsAccessRule(row: RuleRow): MtlsAccessRule {
|
||||
return {
|
||||
id: row.id,
|
||||
proxy_host_id: row.proxyHostId,
|
||||
path_pattern: row.pathPattern,
|
||||
allowed_role_ids: parseJsonIds(row.allowedRoleIds),
|
||||
allowed_cert_ids: parseJsonIds(row.allowedCertIds),
|
||||
deny_all: row.denyAll,
|
||||
priority: row.priority,
|
||||
description: row.description,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listMtlsAccessRules(proxyHostId: number): Promise<MtlsAccessRule[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mtlsAccessRules)
|
||||
.where(eq(mtlsAccessRules.proxyHostId, proxyHostId))
|
||||
.orderBy(desc(mtlsAccessRules.priority), asc(mtlsAccessRules.pathPattern));
|
||||
return rows.map(toMtlsAccessRule);
|
||||
}
|
||||
|
||||
export async function getMtlsAccessRule(id: number): Promise<MtlsAccessRule | null> {
|
||||
const row = await db.query.mtlsAccessRules.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
return row ? toMtlsAccessRule(row) : null;
|
||||
}
|
||||
|
||||
export async function createMtlsAccessRule(
|
||||
input: MtlsAccessRuleInput,
|
||||
actorUserId: number
|
||||
): Promise<MtlsAccessRule> {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(mtlsAccessRules)
|
||||
.values({
|
||||
proxyHostId: input.proxy_host_id,
|
||||
pathPattern: input.path_pattern.trim(),
|
||||
allowedRoleIds: JSON.stringify(input.allowed_role_ids ?? []),
|
||||
allowedCertIds: JSON.stringify(input.allowed_cert_ids ?? []),
|
||||
denyAll: input.deny_all ?? false,
|
||||
priority: input.priority ?? 0,
|
||||
description: input.description ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) throw new Error("Failed to create mTLS access rule");
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: record.id,
|
||||
summary: `Created mTLS access rule for path ${input.path_pattern} on proxy host ${input.proxy_host_id}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return toMtlsAccessRule(record);
|
||||
}
|
||||
|
||||
export async function updateMtlsAccessRule(
|
||||
id: number,
|
||||
input: Partial<Omit<MtlsAccessRuleInput, "proxy_host_id">>,
|
||||
actorUserId: number
|
||||
): Promise<MtlsAccessRule> {
|
||||
const existing = await db.query.mtlsAccessRules.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS access rule not found");
|
||||
|
||||
const now = nowIso();
|
||||
const updates: Partial<typeof mtlsAccessRules.$inferInsert> = { updatedAt: now };
|
||||
|
||||
if (input.path_pattern !== undefined) updates.pathPattern = input.path_pattern.trim();
|
||||
if (input.allowed_role_ids !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowed_role_ids);
|
||||
if (input.allowed_cert_ids !== undefined) updates.allowedCertIds = JSON.stringify(input.allowed_cert_ids);
|
||||
if (input.deny_all !== undefined) updates.denyAll = input.deny_all;
|
||||
if (input.priority !== undefined) updates.priority = input.priority;
|
||||
if (input.description !== undefined) updates.description = input.description ?? null;
|
||||
|
||||
await db.update(mtlsAccessRules).set(updates).where(eq(mtlsAccessRules.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: id,
|
||||
summary: `Updated mTLS access rule for path ${input.path_pattern ?? existing.pathPattern}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return (await getMtlsAccessRule(id))!;
|
||||
}
|
||||
|
||||
export async function deleteMtlsAccessRule(
|
||||
id: number,
|
||||
actorUserId: number
|
||||
): Promise<void> {
|
||||
const existing = await db.query.mtlsAccessRules.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS access rule not found");
|
||||
|
||||
await db.delete(mtlsAccessRules).where(eq(mtlsAccessRules.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: id,
|
||||
summary: `Deleted mTLS access rule for path ${existing.pathPattern}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-query access rules for multiple proxy hosts at once.
|
||||
* Used during Caddy config generation.
|
||||
*/
|
||||
export async function getAccessRulesForHosts(
|
||||
proxyHostIds: number[]
|
||||
): Promise<Map<number, MtlsAccessRule[]>> {
|
||||
if (proxyHostIds.length === 0) return new Map();
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mtlsAccessRules)
|
||||
.where(inArray(mtlsAccessRules.proxyHostId, proxyHostIds))
|
||||
.orderBy(desc(mtlsAccessRules.priority), asc(mtlsAccessRules.pathPattern));
|
||||
|
||||
const map = new Map<number, MtlsAccessRule[]>();
|
||||
for (const row of rows) {
|
||||
const parsed = toMtlsAccessRule(row);
|
||||
let bucket = map.get(parsed.proxy_host_id);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
map.set(parsed.proxy_host_id, bucket);
|
||||
}
|
||||
bucket.push(parsed);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
345
src/lib/models/mtls-roles.ts
Normal file
345
src/lib/models/mtls-roles.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import {
|
||||
mtlsRoles,
|
||||
mtlsCertificateRoles,
|
||||
issuedClientCertificates,
|
||||
} from "../db/schema";
|
||||
import { asc, eq, inArray, count, and, isNull } from "drizzle-orm";
|
||||
import { normalizeFingerprint } from "../caddy-mtls";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type MtlsRole = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
certificate_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MtlsRoleInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type MtlsRoleWithCertificates = MtlsRole & {
|
||||
certificate_ids: number[];
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type RoleRow = typeof mtlsRoles.$inferSelect;
|
||||
|
||||
async function countCertsForRole(roleId: number): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(eq(mtlsCertificateRoles.mtlsRoleId, roleId));
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
function toMtlsRole(row: RoleRow, certCount: number): MtlsRole {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
certificate_count: certCount,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listMtlsRoles(): Promise<MtlsRole[]> {
|
||||
const rows = await db.query.mtlsRoles.findMany({
|
||||
orderBy: (table) => asc(table.name),
|
||||
});
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const roleIds = rows.map((r) => r.id);
|
||||
const counts = await db
|
||||
.select({
|
||||
roleId: mtlsCertificateRoles.mtlsRoleId,
|
||||
cnt: count(),
|
||||
})
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(inArray(mtlsCertificateRoles.mtlsRoleId, roleIds))
|
||||
.groupBy(mtlsCertificateRoles.mtlsRoleId);
|
||||
|
||||
const countMap = new Map(counts.map((c) => [c.roleId, c.cnt]));
|
||||
return rows.map((r) => toMtlsRole(r, countMap.get(r.id) ?? 0));
|
||||
}
|
||||
|
||||
export async function getMtlsRole(id: number): Promise<MtlsRoleWithCertificates | null> {
|
||||
const row = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!row) return null;
|
||||
|
||||
const assignments = await db
|
||||
.select({ certId: mtlsCertificateRoles.issuedClientCertificateId })
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(eq(mtlsCertificateRoles.mtlsRoleId, id));
|
||||
|
||||
return {
|
||||
...toMtlsRole(row, assignments.length),
|
||||
certificate_ids: assignments.map((a) => a.certId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMtlsRole(
|
||||
input: MtlsRoleInput,
|
||||
actorUserId: number
|
||||
): Promise<MtlsRole> {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(mtlsRoles)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
description: input.description ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) throw new Error("Failed to create mTLS role");
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "mtls_role",
|
||||
entityId: record.id,
|
||||
summary: `Created mTLS role ${input.name}`,
|
||||
});
|
||||
|
||||
return toMtlsRole(record, 0);
|
||||
}
|
||||
|
||||
export async function updateMtlsRole(
|
||||
id: number,
|
||||
input: Partial<MtlsRoleInput>,
|
||||
actorUserId: number
|
||||
): Promise<MtlsRole> {
|
||||
const existing = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS role not found");
|
||||
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(mtlsRoles)
|
||||
.set({
|
||||
name: input.name?.trim() ?? existing.name,
|
||||
description: input.description !== undefined ? (input.description ?? null) : existing.description,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(mtlsRoles.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "mtls_role",
|
||||
entityId: id,
|
||||
summary: `Updated mTLS role ${input.name?.trim() ?? existing.name}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
const certCount = await countCertsForRole(id);
|
||||
const updated = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
return toMtlsRole(updated!, certCount);
|
||||
}
|
||||
|
||||
export async function deleteMtlsRole(id: number, actorUserId: number): Promise<void> {
|
||||
const existing = await db.query.mtlsRoles.findFirst({
|
||||
where: (table, { eq: cmpEq }) => cmpEq(table.id, id),
|
||||
});
|
||||
if (!existing) throw new Error("mTLS role not found");
|
||||
|
||||
await db.delete(mtlsRoles).where(eq(mtlsRoles.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "mtls_role",
|
||||
entityId: id,
|
||||
summary: `Deleted mTLS role ${existing.name}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
// ── Certificate ↔ Role assignments ───────────────────────────────────
|
||||
|
||||
export async function assignRoleToCertificate(
|
||||
roleId: number,
|
||||
certId: number,
|
||||
actorUserId: number
|
||||
): Promise<void> {
|
||||
const role = await db.query.mtlsRoles.findFirst({
|
||||
where: (t, { eq: cmpEq }) => cmpEq(t.id, roleId),
|
||||
});
|
||||
if (!role) throw new Error("mTLS role not found");
|
||||
|
||||
const cert = await db.query.issuedClientCertificates.findFirst({
|
||||
where: (t, { eq: cmpEq }) => cmpEq(t.id, certId),
|
||||
});
|
||||
if (!cert) throw new Error("Issued client certificate not found");
|
||||
|
||||
const now = nowIso();
|
||||
await db
|
||||
.insert(mtlsCertificateRoles)
|
||||
.values({
|
||||
issuedClientCertificateId: certId,
|
||||
mtlsRoleId: roleId,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "assign",
|
||||
entityType: "mtls_certificate_role",
|
||||
entityId: roleId,
|
||||
summary: `Assigned cert ${cert.commonName} to role ${role.name}`,
|
||||
data: { roleId, certId },
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
export async function removeRoleFromCertificate(
|
||||
roleId: number,
|
||||
certId: number,
|
||||
actorUserId: number
|
||||
): Promise<void> {
|
||||
const role = await db.query.mtlsRoles.findFirst({
|
||||
where: (t, { eq: cmpEq }) => cmpEq(t.id, roleId),
|
||||
});
|
||||
if (!role) throw new Error("mTLS role not found");
|
||||
|
||||
await db
|
||||
.delete(mtlsCertificateRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(mtlsCertificateRoles.mtlsRoleId, roleId),
|
||||
eq(mtlsCertificateRoles.issuedClientCertificateId, certId)
|
||||
)
|
||||
);
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "unassign",
|
||||
entityType: "mtls_certificate_role",
|
||||
entityId: roleId,
|
||||
summary: `Removed cert from role ${role.name}`,
|
||||
data: { roleId, certId },
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
|
||||
export async function getCertificateRoles(certId: number): Promise<MtlsRole[]> {
|
||||
const assignments = await db
|
||||
.select({ roleId: mtlsCertificateRoles.mtlsRoleId })
|
||||
.from(mtlsCertificateRoles)
|
||||
.where(eq(mtlsCertificateRoles.issuedClientCertificateId, certId));
|
||||
|
||||
if (assignments.length === 0) return [];
|
||||
|
||||
const roleIds = assignments.map((a) => a.roleId);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mtlsRoles)
|
||||
.where(inArray(mtlsRoles.id, roleIds))
|
||||
.orderBy(asc(mtlsRoles.name));
|
||||
|
||||
return rows.map((r) => toMtlsRole(r, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of roleId → Set<normalizedFingerprint> for all active (non-revoked) certs.
|
||||
* Used during Caddy config generation.
|
||||
*/
|
||||
export async function buildRoleFingerprintMap(): Promise<Map<number, Set<string>>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
roleId: mtlsCertificateRoles.mtlsRoleId,
|
||||
fingerprint: issuedClientCertificates.fingerprintSha256,
|
||||
})
|
||||
.from(mtlsCertificateRoles)
|
||||
.innerJoin(
|
||||
issuedClientCertificates,
|
||||
eq(mtlsCertificateRoles.issuedClientCertificateId, issuedClientCertificates.id)
|
||||
)
|
||||
.where(isNull(issuedClientCertificates.revokedAt));
|
||||
|
||||
const map = new Map<number, Set<string>>();
|
||||
for (const row of rows) {
|
||||
let set = map.get(row.roleId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(row.roleId, set);
|
||||
}
|
||||
set.add(normalizeFingerprint(row.fingerprint));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of certId → normalizedFingerprint for all active (non-revoked) certs.
|
||||
* Used during Caddy config generation for direct cert overrides.
|
||||
*/
|
||||
export async function buildCertFingerprintMap(): Promise<Map<number, string>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issuedClientCertificates.id,
|
||||
fingerprint: issuedClientCertificates.fingerprintSha256,
|
||||
})
|
||||
.from(issuedClientCertificates)
|
||||
.where(isNull(issuedClientCertificates.revokedAt));
|
||||
|
||||
const map = new Map<number, string>();
|
||||
for (const row of rows) {
|
||||
map.set(row.id, normalizeFingerprint(row.fingerprint));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of roleId → Set<certId> for all active (non-revoked) certs.
|
||||
* Used during Caddy config generation to resolve trusted_role_ids → cert IDs.
|
||||
*/
|
||||
export async function buildRoleCertIdMap(): Promise<Map<number, Set<number>>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
roleId: mtlsCertificateRoles.mtlsRoleId,
|
||||
certId: mtlsCertificateRoles.issuedClientCertificateId,
|
||||
})
|
||||
.from(mtlsCertificateRoles)
|
||||
.innerJoin(
|
||||
issuedClientCertificates,
|
||||
eq(mtlsCertificateRoles.issuedClientCertificateId, issuedClientCertificates.id)
|
||||
)
|
||||
.where(isNull(issuedClientCertificates.revokedAt));
|
||||
|
||||
const map = new Map<number, Set<number>>();
|
||||
for (const row of rows) {
|
||||
let set = map.get(row.roleId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(row.roleId, set);
|
||||
}
|
||||
set.add(row.certId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// normalizeFingerprint is imported from caddy-mtls.ts (the canonical location)
|
||||
// and re-exported for convenience.
|
||||
export { normalizeFingerprint } from "../caddy-mtls";
|
||||
@@ -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 = {
|
||||
|
||||
300
tests/integration/mtls-access-rules-model.test.ts
Normal file
300
tests/integration/mtls-access-rules-model.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Integration tests for src/lib/models/mtls-access-rules.ts
|
||||
* Tests all CRUD operations and the bulk query function.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createTestDb, type TestDb } from '../helpers/db';
|
||||
import {
|
||||
mtlsAccessRules,
|
||||
proxyHosts,
|
||||
users,
|
||||
} from '../../src/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
vi.mock('../../src/lib/db', async () => ({
|
||||
get default() { return db; },
|
||||
nowIso: () => new Date().toISOString(),
|
||||
toIso: (v: string | null) => v,
|
||||
}));
|
||||
vi.mock('../../src/lib/caddy', () => ({ applyCaddyConfig: vi.fn() }));
|
||||
vi.mock('../../src/lib/audit', () => ({ logAuditEvent: vi.fn() }));
|
||||
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createTestDb();
|
||||
vi.clearAllMocks();
|
||||
const now = new Date().toISOString();
|
||||
const [user] = await db.insert(users).values({
|
||||
email: 'admin@test', name: 'Admin', role: 'admin',
|
||||
provider: 'credentials', subject: 'admin@test', status: 'active',
|
||||
createdAt: now, updatedAt: now,
|
||||
}).returning();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
function nowIso() { return new Date().toISOString(); }
|
||||
|
||||
async function insertHost(name = 'test-host') {
|
||||
const now = nowIso();
|
||||
const [host] = await db.insert(proxyHosts).values({
|
||||
name, domains: '["test.example.com"]', upstreams: '["http://localhost:8080"]',
|
||||
createdAt: now, updatedAt: now,
|
||||
}).returning();
|
||||
return host;
|
||||
}
|
||||
|
||||
const {
|
||||
listMtlsAccessRules,
|
||||
getMtlsAccessRule,
|
||||
createMtlsAccessRule,
|
||||
updateMtlsAccessRule,
|
||||
deleteMtlsAccessRule,
|
||||
getAccessRulesForHosts,
|
||||
} = await import('../../src/lib/models/mtls-access-rules');
|
||||
|
||||
describe('mtls-access-rules CRUD', () => {
|
||||
it('createMtlsAccessRule creates a rule', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: '/admin/*',
|
||||
allowed_role_ids: [1, 2],
|
||||
allowed_cert_ids: [10],
|
||||
priority: 5,
|
||||
description: 'admin only',
|
||||
}, userId);
|
||||
|
||||
expect(rule.proxy_host_id).toBe(host.id);
|
||||
expect(rule.path_pattern).toBe('/admin/*');
|
||||
expect(rule.allowed_role_ids).toEqual([1, 2]);
|
||||
expect(rule.allowed_cert_ids).toEqual([10]);
|
||||
expect(rule.priority).toBe(5);
|
||||
expect(rule.description).toBe('admin only');
|
||||
expect(rule.deny_all).toBe(false);
|
||||
});
|
||||
|
||||
it('createMtlsAccessRule trims path_pattern', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: ' /api/* ',
|
||||
}, userId);
|
||||
expect(rule.path_pattern).toBe('/api/*');
|
||||
});
|
||||
|
||||
it('createMtlsAccessRule defaults arrays to empty', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: '*',
|
||||
}, userId);
|
||||
expect(rule.allowed_role_ids).toEqual([]);
|
||||
expect(rule.allowed_cert_ids).toEqual([]);
|
||||
expect(rule.deny_all).toBe(false);
|
||||
expect(rule.priority).toBe(0);
|
||||
});
|
||||
|
||||
it('createMtlsAccessRule with deny_all', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: '/blocked/*',
|
||||
deny_all: true,
|
||||
}, userId);
|
||||
expect(rule.deny_all).toBe(true);
|
||||
});
|
||||
|
||||
it('listMtlsAccessRules returns rules ordered by priority desc then path asc', async () => {
|
||||
const host = await insertHost();
|
||||
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/b', priority: 1 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/a', priority: 10 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/c', priority: 1 }, userId);
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules).toHaveLength(3);
|
||||
expect(rules[0].path_pattern).toBe('/a'); // priority 10 (highest)
|
||||
expect(rules[1].path_pattern).toBe('/b'); // priority 1, path /b
|
||||
expect(rules[2].path_pattern).toBe('/c'); // priority 1, path /c
|
||||
});
|
||||
|
||||
it('listMtlsAccessRules returns empty array for host with no rules', async () => {
|
||||
const host = await insertHost();
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('listMtlsAccessRules only returns rules for the specified host', async () => {
|
||||
const host1 = await insertHost('h1');
|
||||
const host2 = await insertHost('h2');
|
||||
await createMtlsAccessRule({ proxy_host_id: host1.id, path_pattern: '/h1' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: host2.id, path_pattern: '/h2' }, userId);
|
||||
|
||||
const rules = await listMtlsAccessRules(host1.id);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].path_pattern).toBe('/h1');
|
||||
});
|
||||
|
||||
it('getMtlsAccessRule returns a single rule', async () => {
|
||||
const host = await insertHost();
|
||||
const created = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/test',
|
||||
}, userId);
|
||||
|
||||
const fetched = await getMtlsAccessRule(created.id);
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched!.id).toBe(created.id);
|
||||
expect(fetched!.path_pattern).toBe('/test');
|
||||
});
|
||||
|
||||
it('getMtlsAccessRule returns null for non-existent rule', async () => {
|
||||
expect(await getMtlsAccessRule(999)).toBeNull();
|
||||
});
|
||||
|
||||
it('updateMtlsAccessRule updates fields', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/old', priority: 0,
|
||||
}, userId);
|
||||
|
||||
const updated = await updateMtlsAccessRule(rule.id, {
|
||||
path_pattern: '/new',
|
||||
priority: 99,
|
||||
allowed_role_ids: [5],
|
||||
deny_all: true,
|
||||
description: 'updated',
|
||||
}, userId);
|
||||
|
||||
expect(updated.path_pattern).toBe('/new');
|
||||
expect(updated.priority).toBe(99);
|
||||
expect(updated.allowed_role_ids).toEqual([5]);
|
||||
expect(updated.deny_all).toBe(true);
|
||||
expect(updated.description).toBe('updated');
|
||||
});
|
||||
|
||||
it('updateMtlsAccessRule partial update leaves other fields unchanged', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/test',
|
||||
allowed_role_ids: [1], priority: 5, description: 'original',
|
||||
}, userId);
|
||||
|
||||
const updated = await updateMtlsAccessRule(rule.id, { priority: 10 }, userId);
|
||||
expect(updated.path_pattern).toBe('/test');
|
||||
expect(updated.allowed_role_ids).toEqual([1]);
|
||||
expect(updated.description).toBe('original');
|
||||
expect(updated.priority).toBe(10);
|
||||
});
|
||||
|
||||
it('updateMtlsAccessRule throws for non-existent rule', async () => {
|
||||
await expect(updateMtlsAccessRule(999, { priority: 1 }, 1)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('deleteMtlsAccessRule removes the rule', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/test',
|
||||
}, userId);
|
||||
|
||||
await deleteMtlsAccessRule(rule.id, 1);
|
||||
expect(await getMtlsAccessRule(rule.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteMtlsAccessRule throws for non-existent rule', async () => {
|
||||
await expect(deleteMtlsAccessRule(999, 1)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessRulesForHosts (bulk query)', () => {
|
||||
it('returns empty map for empty host list', async () => {
|
||||
const map = await getAccessRulesForHosts([]);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty map when no rules exist', async () => {
|
||||
const host = await insertHost();
|
||||
const map = await getAccessRulesForHosts([host.id]);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('groups rules by proxy host ID', async () => {
|
||||
const h1 = await insertHost('h1');
|
||||
const h2 = await insertHost('h2');
|
||||
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/b' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/c' }, userId);
|
||||
|
||||
const map = await getAccessRulesForHosts([h1.id, h2.id]);
|
||||
expect(map.get(h1.id)).toHaveLength(2);
|
||||
expect(map.get(h2.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes hosts not in the query list', async () => {
|
||||
const h1 = await insertHost('h1');
|
||||
const h2 = await insertHost('h2');
|
||||
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/b' }, userId);
|
||||
|
||||
const map = await getAccessRulesForHosts([h1.id]);
|
||||
expect(map.has(h1.id)).toBe(true);
|
||||
expect(map.has(h2.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('rules within a host are ordered by priority desc, path asc', async () => {
|
||||
const h = await insertHost();
|
||||
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/z', priority: 10 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/a', priority: 1 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/m', priority: 10 }, userId);
|
||||
|
||||
const map = await getAccessRulesForHosts([h.id]);
|
||||
const rules = map.get(h.id)!;
|
||||
expect(rules[0].path_pattern).toBe('/m'); // priority 10, path /m
|
||||
expect(rules[1].path_pattern).toBe('/z'); // priority 10, path /z
|
||||
expect(rules[2].path_pattern).toBe('/a'); // priority 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON parsing edge cases in access rules', () => {
|
||||
it('handles malformed allowed_role_ids JSON gracefully', async () => {
|
||||
const host = await insertHost();
|
||||
const now = nowIso();
|
||||
// Insert directly with bad JSON
|
||||
await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id, pathPattern: '/test',
|
||||
allowedRoleIds: 'not-json', allowedCertIds: '[]',
|
||||
createdAt: now, updatedAt: now,
|
||||
});
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules[0].allowed_role_ids).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters non-numeric values from JSON arrays', async () => {
|
||||
const host = await insertHost();
|
||||
const now = nowIso();
|
||||
await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id, pathPattern: '/test',
|
||||
allowedRoleIds: '[1, "hello", null, 3]', allowedCertIds: '[]',
|
||||
createdAt: now, updatedAt: now,
|
||||
});
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules[0].allowed_role_ids).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('handles non-array JSON', async () => {
|
||||
const host = await insertHost();
|
||||
const now = nowIso();
|
||||
await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id, pathPattern: '/test',
|
||||
allowedRoleIds: '{"foo": 1}', allowedCertIds: '"string"',
|
||||
createdAt: now, updatedAt: now,
|
||||
});
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules[0].allowed_role_ids).toEqual([]);
|
||||
expect(rules[0].allowed_cert_ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
428
tests/integration/mtls-rbac.test.ts
Normal file
428
tests/integration/mtls-rbac.test.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb, type TestDb } from '../helpers/db';
|
||||
import {
|
||||
mtlsRoles,
|
||||
mtlsCertificateRoles,
|
||||
mtlsAccessRules,
|
||||
issuedClientCertificates,
|
||||
caCertificates,
|
||||
proxyHosts,
|
||||
} from '@/src/lib/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function insertCaCert(name = 'Test CA') {
|
||||
const now = nowIso();
|
||||
const [ca] = await db.insert(caCertificates).values({
|
||||
name,
|
||||
certificatePem: '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return ca;
|
||||
}
|
||||
|
||||
async function insertClientCert(caCertId: number, cn = 'test-client', fingerprint = 'AABB') {
|
||||
const now = nowIso();
|
||||
const [cert] = await db.insert(issuedClientCertificates).values({
|
||||
caCertificateId: caCertId,
|
||||
commonName: cn,
|
||||
serialNumber: Date.now().toString(16),
|
||||
fingerprintSha256: fingerprint,
|
||||
certificatePem: '-----BEGIN CERTIFICATE-----\nCLIENT\n-----END CERTIFICATE-----',
|
||||
validFrom: now,
|
||||
validTo: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return cert;
|
||||
}
|
||||
|
||||
async function insertRole(name = 'admin') {
|
||||
const now = nowIso();
|
||||
const [role] = await db.insert(mtlsRoles).values({
|
||||
name,
|
||||
description: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return role;
|
||||
}
|
||||
|
||||
async function insertProxyHost(name = 'test-host') {
|
||||
const now = nowIso();
|
||||
const [host] = await db.insert(proxyHosts).values({
|
||||
name,
|
||||
domains: '["test.example.com"]',
|
||||
upstreams: '["http://localhost:8080"]',
|
||||
meta: JSON.stringify({ mtls: { enabled: true, ca_certificate_ids: [1] } }),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
return host;
|
||||
}
|
||||
|
||||
// ── mtlsRoles ────────────────────────────────────────────────────────
|
||||
|
||||
describe('mtls_roles table', () => {
|
||||
it('creates a role with unique name', async () => {
|
||||
const role = await insertRole('admin');
|
||||
expect(role.name).toBe('admin');
|
||||
expect(role.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('enforces unique name constraint', async () => {
|
||||
await insertRole('admin');
|
||||
await expect(insertRole('admin')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('supports description field', async () => {
|
||||
const now = nowIso();
|
||||
const [role] = await db.insert(mtlsRoles).values({
|
||||
name: 'viewer',
|
||||
description: 'Read-only access',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
expect(role.description).toBe('Read-only access');
|
||||
});
|
||||
});
|
||||
|
||||
// ── mtlsCertificateRoles ─────────────────────────────────────────────
|
||||
|
||||
describe('mtls_certificate_roles table', () => {
|
||||
it('assigns a cert to a role', async () => {
|
||||
const ca = await insertCaCert();
|
||||
const cert = await insertClientCert(ca.id);
|
||||
const role = await insertRole();
|
||||
|
||||
const now = nowIso();
|
||||
const [assignment] = await db.insert(mtlsCertificateRoles).values({
|
||||
issuedClientCertificateId: cert.id,
|
||||
mtlsRoleId: role.id,
|
||||
createdAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(assignment.issuedClientCertificateId).toBe(cert.id);
|
||||
expect(assignment.mtlsRoleId).toBe(role.id);
|
||||
});
|
||||
|
||||
it('enforces unique constraint on (cert, role) pair', async () => {
|
||||
const ca = await insertCaCert();
|
||||
const cert = await insertClientCert(ca.id);
|
||||
const role = await insertRole();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsCertificateRoles).values({
|
||||
issuedClientCertificateId: cert.id,
|
||||
mtlsRoleId: role.id,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
db.insert(mtlsCertificateRoles).values({
|
||||
issuedClientCertificateId: cert.id,
|
||||
mtlsRoleId: role.id,
|
||||
createdAt: now,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('cascades on role deletion', async () => {
|
||||
const ca = await insertCaCert();
|
||||
const cert = await insertClientCert(ca.id);
|
||||
const role = await insertRole();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsCertificateRoles).values({
|
||||
issuedClientCertificateId: cert.id,
|
||||
mtlsRoleId: role.id,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await db.delete(mtlsRoles).where(eq(mtlsRoles.id, role.id));
|
||||
|
||||
const remaining = await db.select().from(mtlsCertificateRoles);
|
||||
expect(remaining.length).toBe(0);
|
||||
});
|
||||
|
||||
it('cascades on cert deletion', async () => {
|
||||
const ca = await insertCaCert();
|
||||
const cert = await insertClientCert(ca.id);
|
||||
const role = await insertRole();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsCertificateRoles).values({
|
||||
issuedClientCertificateId: cert.id,
|
||||
mtlsRoleId: role.id,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await db.delete(issuedClientCertificates).where(eq(issuedClientCertificates.id, cert.id));
|
||||
|
||||
const remaining = await db.select().from(mtlsCertificateRoles);
|
||||
expect(remaining.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── mtlsAccessRules ──────────────────────────────────────────────────
|
||||
|
||||
describe('mtls_access_rules table', () => {
|
||||
it('creates an access rule for a proxy host', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
const [rule] = await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/admin/*',
|
||||
allowedRoleIds: JSON.stringify([1, 2]),
|
||||
allowedCertIds: JSON.stringify([]),
|
||||
denyAll: false,
|
||||
priority: 10,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(rule.pathPattern).toBe('/admin/*');
|
||||
expect(rule.priority).toBe(10);
|
||||
expect(JSON.parse(rule.allowedRoleIds)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('enforces unique (proxyHostId, pathPattern)', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/admin/*',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/admin/*',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows same path on different hosts', async () => {
|
||||
const host1 = await insertProxyHost('host-1');
|
||||
const host2 = await insertProxyHost('host-2');
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host1.id,
|
||||
pathPattern: '/admin/*',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const [rule2] = await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host2.id,
|
||||
pathPattern: '/admin/*',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(rule2.proxyHostId).toBe(host2.id);
|
||||
});
|
||||
|
||||
it('cascades on proxy host deletion', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/admin/*',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await db.delete(proxyHosts).where(eq(proxyHosts.id, host.id));
|
||||
|
||||
const remaining = await db.select().from(mtlsAccessRules);
|
||||
expect(remaining.length).toBe(0);
|
||||
});
|
||||
|
||||
it('stores deny_all flag correctly', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
|
||||
const [rule] = await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/blocked/*',
|
||||
denyAll: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(rule.denyAll).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults allowed_role_ids and allowed_cert_ids to "[]"', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
const [rule] = await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/test',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(rule.allowedRoleIds).toBe('[]');
|
||||
expect(rule.allowedCertIds).toBe('[]');
|
||||
});
|
||||
|
||||
it('defaults deny_all to false and priority to 0', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
const [rule] = await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/test',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(rule.denyAll).toBe(false);
|
||||
expect(rule.priority).toBe(0);
|
||||
});
|
||||
|
||||
it('stores JSON arrays with numbers in allowed_role_ids', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
const [rule] = await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/test',
|
||||
allowedRoleIds: JSON.stringify([1, 2, 3]),
|
||||
allowedCertIds: JSON.stringify([10, 20]),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(JSON.parse(rule.allowedRoleIds)).toEqual([1, 2, 3]);
|
||||
expect(JSON.parse(rule.allowedCertIds)).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('supports description field', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
const [rule] = await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/test',
|
||||
description: 'Only for admins',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
expect(rule.description).toBe('Only for admins');
|
||||
});
|
||||
|
||||
it('supports multiple rules with different priorities on same host', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsAccessRules).values({ proxyHostId: host.id, pathPattern: '/a', priority: 1, createdAt: now, updatedAt: now });
|
||||
await db.insert(mtlsAccessRules).values({ proxyHostId: host.id, pathPattern: '/b', priority: 100, createdAt: now, updatedAt: now });
|
||||
await db.insert(mtlsAccessRules).values({ proxyHostId: host.id, pathPattern: '/c', priority: 50, createdAt: now, updatedAt: now });
|
||||
|
||||
const rows = await db.select().from(mtlsAccessRules);
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Additional schema relationship tests ─────────────────────────────
|
||||
|
||||
describe('cross-table relationships', () => {
|
||||
it('cascades CA deletion through issued certs to certificate_roles', async () => {
|
||||
const ca = await insertCaCert();
|
||||
const cert = await insertClientCert(ca.id);
|
||||
const role = await insertRole();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsCertificateRoles).values({
|
||||
issuedClientCertificateId: cert.id,
|
||||
mtlsRoleId: role.id,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Delete the CA — should cascade: CA → issued certs → cert_roles
|
||||
await db.delete(caCertificates).where(eq(caCertificates.id, ca.id));
|
||||
|
||||
const remainingCerts = await db.select().from(issuedClientCertificates);
|
||||
expect(remainingCerts).toHaveLength(0);
|
||||
|
||||
const remainingAssignments = await db.select().from(mtlsCertificateRoles);
|
||||
expect(remainingAssignments).toHaveLength(0);
|
||||
|
||||
// The role itself should still exist
|
||||
const remainingRoles = await db.select().from(mtlsRoles);
|
||||
expect(remainingRoles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows a cert to be assigned to multiple roles simultaneously', async () => {
|
||||
const ca = await insertCaCert();
|
||||
const cert = await insertClientCert(ca.id);
|
||||
const r1 = await insertRole('role-a');
|
||||
const r2 = await insertRole('role-b');
|
||||
const r3 = await insertRole('role-c');
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert.id, mtlsRoleId: r1.id, createdAt: now });
|
||||
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert.id, mtlsRoleId: r2.id, createdAt: now });
|
||||
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert.id, mtlsRoleId: r3.id, createdAt: now });
|
||||
|
||||
const assignments = await db.select().from(mtlsCertificateRoles);
|
||||
expect(assignments).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('allows multiple certs to be assigned to the same role', async () => {
|
||||
const ca = await insertCaCert();
|
||||
const cert1 = await insertClientCert(ca.id, 'alice', 'AA');
|
||||
const cert2 = await insertClientCert(ca.id, 'bob', 'BB');
|
||||
const cert3 = await insertClientCert(ca.id, 'charlie', 'CC');
|
||||
const role = await insertRole();
|
||||
const now = nowIso();
|
||||
|
||||
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert1.id, mtlsRoleId: role.id, createdAt: now });
|
||||
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert2.id, mtlsRoleId: role.id, createdAt: now });
|
||||
await db.insert(mtlsCertificateRoles).values({ issuedClientCertificateId: cert3.id, mtlsRoleId: role.id, createdAt: now });
|
||||
|
||||
const assignments = await db.select().from(mtlsCertificateRoles).where(eq(mtlsCertificateRoles.mtlsRoleId, role.id));
|
||||
expect(assignments).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('role deletion does not affect proxy host access rules referencing the role', async () => {
|
||||
const host = await insertProxyHost();
|
||||
const role = await insertRole();
|
||||
const now = nowIso();
|
||||
|
||||
// Create an access rule that references this role
|
||||
await db.insert(mtlsAccessRules).values({
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/test',
|
||||
allowedRoleIds: JSON.stringify([role.id]),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Delete the role — the access rule should still exist (JSON array, no FK)
|
||||
await db.delete(mtlsRoles).where(eq(mtlsRoles.id, role.id));
|
||||
|
||||
const rules = await db.select().from(mtlsAccessRules);
|
||||
expect(rules).toHaveLength(1);
|
||||
// The role ID is still in the JSON, but the role no longer exists
|
||||
expect(JSON.parse(rules[0].allowedRoleIds)).toEqual([role.id]);
|
||||
});
|
||||
});
|
||||
348
tests/integration/mtls-roles-model.test.ts
Normal file
348
tests/integration/mtls-roles-model.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Integration tests for src/lib/models/mtls-roles.ts
|
||||
* Tests all CRUD operations and the fingerprint/cert-id map builders
|
||||
* using a real in-memory SQLite database.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createTestDb, type TestDb } from '../helpers/db';
|
||||
import {
|
||||
mtlsRoles,
|
||||
mtlsCertificateRoles,
|
||||
issuedClientCertificates,
|
||||
caCertificates,
|
||||
users,
|
||||
} from '../../src/lib/db/schema';
|
||||
import { eq, isNull } from 'drizzle-orm';
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
// Mock the modules that mtls-roles.ts imports
|
||||
vi.mock('../../src/lib/db', async () => {
|
||||
// This gets re-evaluated per test via beforeEach
|
||||
return {
|
||||
get default() { return db; },
|
||||
nowIso: () => new Date().toISOString(),
|
||||
toIso: (v: string | null) => v,
|
||||
};
|
||||
});
|
||||
vi.mock('../../src/lib/caddy', () => ({ applyCaddyConfig: vi.fn() }));
|
||||
vi.mock('../../src/lib/audit', () => ({ logAuditEvent: vi.fn() }));
|
||||
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createTestDb();
|
||||
vi.clearAllMocks();
|
||||
// Seed a user to satisfy FK constraints on createdBy
|
||||
const now = new Date().toISOString();
|
||||
const [user] = await db.insert(users).values({
|
||||
email: 'admin@test', name: 'Admin', role: 'admin',
|
||||
provider: 'credentials', subject: 'admin@test', status: 'active',
|
||||
createdAt: now, updatedAt: now,
|
||||
}).returning();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
function nowIso() { return new Date().toISOString(); }
|
||||
|
||||
async function seedCaAndCerts() {
|
||||
const now = nowIso();
|
||||
const [ca] = await db.insert(caCertificates).values({
|
||||
name: 'Test CA',
|
||||
certificatePem: '-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----',
|
||||
createdAt: now, updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
const [cert1] = await db.insert(issuedClientCertificates).values({
|
||||
caCertificateId: ca.id, commonName: 'alice', serialNumber: '001',
|
||||
fingerprintSha256: 'AA:BB:CC:DD', certificatePem: '-----BEGIN CERTIFICATE-----\nALICE\n-----END CERTIFICATE-----',
|
||||
validFrom: now, validTo: now, createdAt: now, updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
const [cert2] = await db.insert(issuedClientCertificates).values({
|
||||
caCertificateId: ca.id, commonName: 'bob', serialNumber: '002',
|
||||
fingerprintSha256: 'EE:FF:00:11', certificatePem: '-----BEGIN CERTIFICATE-----\nBOB\n-----END CERTIFICATE-----',
|
||||
validFrom: now, validTo: now, createdAt: now, updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
const [revokedCert] = await db.insert(issuedClientCertificates).values({
|
||||
caCertificateId: ca.id, commonName: 'revoked-user', serialNumber: '003',
|
||||
fingerprintSha256: '99:88:77:66', certificatePem: '-----BEGIN CERTIFICATE-----\nREVOKED\n-----END CERTIFICATE-----',
|
||||
validFrom: now, validTo: now, revokedAt: now, createdAt: now, updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return { ca, cert1, cert2, revokedCert };
|
||||
}
|
||||
|
||||
// Dynamically import after mocks are set up
|
||||
const {
|
||||
listMtlsRoles,
|
||||
getMtlsRole,
|
||||
createMtlsRole,
|
||||
updateMtlsRole,
|
||||
deleteMtlsRole,
|
||||
assignRoleToCertificate,
|
||||
removeRoleFromCertificate,
|
||||
getCertificateRoles,
|
||||
buildRoleFingerprintMap,
|
||||
buildCertFingerprintMap,
|
||||
buildRoleCertIdMap,
|
||||
} = await import('../../src/lib/models/mtls-roles');
|
||||
|
||||
describe('mtls-roles model CRUD', () => {
|
||||
it('createMtlsRole creates a role and returns it', async () => {
|
||||
const role = await createMtlsRole({ name: 'admin', description: 'Admin role' }, userId);
|
||||
expect(role.name).toBe('admin');
|
||||
expect(role.description).toBe('Admin role');
|
||||
expect(role.certificate_count).toBe(0);
|
||||
expect(role.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('createMtlsRole trims whitespace', async () => {
|
||||
const role = await createMtlsRole({ name: ' padded ' }, userId);
|
||||
expect(role.name).toBe('padded');
|
||||
});
|
||||
|
||||
it('listMtlsRoles returns all roles sorted by name', async () => {
|
||||
await createMtlsRole({ name: 'zebra' }, userId);
|
||||
await createMtlsRole({ name: 'alpha' }, userId);
|
||||
const roles = await listMtlsRoles();
|
||||
expect(roles.length).toBe(2);
|
||||
expect(roles[0].name).toBe('alpha');
|
||||
expect(roles[1].name).toBe('zebra');
|
||||
});
|
||||
|
||||
it('listMtlsRoles includes certificate counts', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
|
||||
const roles = await listMtlsRoles();
|
||||
expect(roles[0].certificate_count).toBe(1);
|
||||
});
|
||||
|
||||
it('listMtlsRoles returns empty array when no roles', async () => {
|
||||
const roles = await listMtlsRoles();
|
||||
expect(roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('getMtlsRole returns role with certificate_ids', async () => {
|
||||
const { cert1, cert2 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(role.id, cert2.id, 1);
|
||||
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched!.certificate_ids).toHaveLength(2);
|
||||
expect(fetched!.certificate_ids).toContain(cert1.id);
|
||||
expect(fetched!.certificate_ids).toContain(cert2.id);
|
||||
});
|
||||
|
||||
it('getMtlsRole returns null for non-existent role', async () => {
|
||||
const result = await getMtlsRole(999);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('updateMtlsRole updates name and description', async () => {
|
||||
const role = await createMtlsRole({ name: 'old', description: 'old desc' }, userId);
|
||||
const updated = await updateMtlsRole(role.id, { name: 'new', description: 'new desc' }, userId);
|
||||
expect(updated.name).toBe('new');
|
||||
expect(updated.description).toBe('new desc');
|
||||
});
|
||||
|
||||
it('updateMtlsRole throws for non-existent role', async () => {
|
||||
await expect(updateMtlsRole(999, { name: 'x' }, 1)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('updateMtlsRole can set description to null', async () => {
|
||||
const role = await createMtlsRole({ name: 'test', description: 'has desc' }, userId);
|
||||
const updated = await updateMtlsRole(role.id, { description: null }, userId);
|
||||
expect(updated.description).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteMtlsRole removes the role', async () => {
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await deleteMtlsRole(role.id, 1);
|
||||
const roles = await listMtlsRoles();
|
||||
expect(roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('deleteMtlsRole throws for non-existent role', async () => {
|
||||
await expect(deleteMtlsRole(999, 1)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mtls-roles certificate assignments', () => {
|
||||
it('assignRoleToCertificate creates assignment', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched!.certificate_ids).toContain(cert1.id);
|
||||
});
|
||||
|
||||
it('assignRoleToCertificate throws for non-existent role', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
await expect(assignRoleToCertificate(999, cert1.id, 1)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('assignRoleToCertificate throws for non-existent cert', async () => {
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await expect(assignRoleToCertificate(role.id, 999, 1)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('assignRoleToCertificate throws on duplicate assignment', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
await expect(assignRoleToCertificate(role.id, cert1.id, 1)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('removeRoleFromCertificate removes assignment', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
await removeRoleFromCertificate(role.id, cert1.id, 1);
|
||||
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched!.certificate_ids).toEqual([]);
|
||||
});
|
||||
|
||||
it('removeRoleFromCertificate throws for non-existent role', async () => {
|
||||
await expect(removeRoleFromCertificate(999, 1, 1)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('getCertificateRoles returns roles for a cert', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
const role1 = await createMtlsRole({ name: 'admin' }, userId);
|
||||
const role2 = await createMtlsRole({ name: 'viewer' }, userId);
|
||||
await assignRoleToCertificate(role1.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(role2.id, cert1.id, 1);
|
||||
|
||||
const roles = await getCertificateRoles(cert1.id);
|
||||
expect(roles).toHaveLength(2);
|
||||
expect(roles.map(r => r.name).sort()).toEqual(['admin', 'viewer']);
|
||||
});
|
||||
|
||||
it('getCertificateRoles returns empty array for cert with no roles', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
const roles = await getCertificateRoles(cert1.id);
|
||||
expect(roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('a cert can be in multiple roles', async () => {
|
||||
const { cert1 } = await seedCaAndCerts();
|
||||
const r1 = await createMtlsRole({ name: 'r1' }, userId);
|
||||
const r2 = await createMtlsRole({ name: 'r2' }, userId);
|
||||
const r3 = await createMtlsRole({ name: 'r3' }, userId);
|
||||
await assignRoleToCertificate(r1.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(r2.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(r3.id, cert1.id, 1);
|
||||
const roles = await getCertificateRoles(cert1.id);
|
||||
expect(roles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('a role can have multiple certs', async () => {
|
||||
const { cert1, cert2 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(role.id, cert2.id, 1);
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched!.certificate_ids).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRoleFingerprintMap', () => {
|
||||
it('returns empty map when no roles exist', async () => {
|
||||
const map = await buildRoleFingerprintMap();
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps role IDs to normalized fingerprints of active certs', async () => {
|
||||
const { cert1, cert2 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(role.id, cert2.id, 1);
|
||||
|
||||
const map = await buildRoleFingerprintMap();
|
||||
expect(map.has(role.id)).toBe(true);
|
||||
const fps = map.get(role.id)!;
|
||||
expect(fps.size).toBe(2);
|
||||
// Fingerprints are normalized: colons stripped, lowercased
|
||||
expect(fps.has('aabbccdd')).toBe(true);
|
||||
expect(fps.has('eeff0011')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes revoked certs from fingerprint map', async () => {
|
||||
const { revokedCert } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, revokedCert.id, 1);
|
||||
|
||||
const map = await buildRoleFingerprintMap();
|
||||
// Role exists but has no active certs
|
||||
expect(map.has(role.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles multiple roles with overlapping certs', async () => {
|
||||
const { cert1, cert2 } = await seedCaAndCerts();
|
||||
const r1 = await createMtlsRole({ name: 'r1' }, userId);
|
||||
const r2 = await createMtlsRole({ name: 'r2' }, userId);
|
||||
await assignRoleToCertificate(r1.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(r2.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(r2.id, cert2.id, 1);
|
||||
|
||||
const map = await buildRoleFingerprintMap();
|
||||
expect(map.get(r1.id)!.size).toBe(1);
|
||||
expect(map.get(r2.id)!.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCertFingerprintMap', () => {
|
||||
it('returns empty map when no certs exist', async () => {
|
||||
const map = await buildCertFingerprintMap();
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps cert IDs to normalized fingerprints', async () => {
|
||||
const { cert1, cert2 } = await seedCaAndCerts();
|
||||
const map = await buildCertFingerprintMap();
|
||||
expect(map.get(cert1.id)).toBe('aabbccdd');
|
||||
expect(map.get(cert2.id)).toBe('eeff0011');
|
||||
});
|
||||
|
||||
it('excludes revoked certs', async () => {
|
||||
const { revokedCert } = await seedCaAndCerts();
|
||||
const map = await buildCertFingerprintMap();
|
||||
expect(map.has(revokedCert.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRoleCertIdMap', () => {
|
||||
it('returns empty map when no roles exist', async () => {
|
||||
const map = await buildRoleCertIdMap();
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('maps role IDs to cert IDs of active certs', async () => {
|
||||
const { cert1, cert2 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(role.id, cert2.id, 1);
|
||||
|
||||
const map = await buildRoleCertIdMap();
|
||||
expect(map.has(role.id)).toBe(true);
|
||||
expect(map.get(role.id)!.has(cert1.id)).toBe(true);
|
||||
expect(map.get(role.id)!.has(cert2.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes revoked certs from role cert ID map', async () => {
|
||||
const { revokedCert } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, revokedCert.id, 1);
|
||||
|
||||
const map = await buildRoleCertIdMap();
|
||||
expect(map.has(role.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
186
tests/unit/caddy-mtls-leaf-override.test.ts
Normal file
186
tests/unit/caddy-mtls-leaf-override.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Unit tests for the new cert-based mTLS model (leaf override / trusted_client_cert_ids).
|
||||
*
|
||||
* Tests buildClientAuthentication with the mTlsDomainLeafOverride parameter
|
||||
* to ensure the new "trust user X" model works correctly alongside the legacy CA model.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildClientAuthentication, pemToBase64Der } from '../../src/lib/caddy-mtls';
|
||||
|
||||
function makeCaPem(label: string): string {
|
||||
return `-----BEGIN CERTIFICATE-----\n${label}\n-----END CERTIFICATE-----`;
|
||||
}
|
||||
|
||||
function makeCaCertMap(...entries: [number, string][]) {
|
||||
return new Map(entries.map(([id, label]) => [id, { id, certificatePem: makeCaPem(label) }]));
|
||||
}
|
||||
|
||||
describe('buildClientAuthentication with leaf override (new cert-based model)', () => {
|
||||
it('uses leaf override PEMs when mTlsDomainLeafOverride is provided', () => {
|
||||
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
|
||||
const caCertMap = makeCaCertMap([1, 'CA_A']);
|
||||
const leafOverride = new Map([['app.example.com', [makeCaPem('USER_CERT_1'), makeCaPem('USER_CERT_2')]]]);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['app.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
new Map(),
|
||||
new Set(),
|
||||
leafOverride
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.mode).toBe('require_and_verify');
|
||||
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
|
||||
expect(result!.trusted_leaf_certs).toEqual(['USER_CERT_1', 'USER_CERT_2']);
|
||||
});
|
||||
|
||||
it('ignores the legacy managed/unmanaged CA logic when leaf override is present', () => {
|
||||
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
|
||||
const caCertMap = makeCaCertMap([1, 'CA_A']);
|
||||
// CA 1 is managed with active certs, but leaf override should take precedence
|
||||
const issuedClientCertMap = new Map([[1, [makeCaPem('OTHER_CERT')]]]);
|
||||
const cAsWithAnyIssuedCerts = new Set([1]);
|
||||
const leafOverride = new Map([['app.example.com', [makeCaPem('SPECIFIC_USER')]]]);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['app.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
issuedClientCertMap,
|
||||
cAsWithAnyIssuedCerts,
|
||||
leafOverride
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Should only have the override leaf, not OTHER_CERT from the managed CA
|
||||
expect(result!.trusted_leaf_certs).toEqual(['SPECIFIC_USER']);
|
||||
expect(result!.trusted_leaf_certs).not.toContain('OTHER_CERT');
|
||||
});
|
||||
|
||||
it('falls back to legacy CA logic when no leaf override exists for domain', () => {
|
||||
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
|
||||
const caCertMap = makeCaCertMap([1, 'CA_A']);
|
||||
// No leaf override for this domain
|
||||
const leafOverride = new Map([['other.example.com', [makeCaPem('OTHER')]]]);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['app.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
new Map(),
|
||||
new Set(),
|
||||
leafOverride
|
||||
);
|
||||
|
||||
// Falls back to unmanaged CA: no leaf certs
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
|
||||
expect(result!.trusted_leaf_certs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes CAs from multiple domains in leaf override', () => {
|
||||
const mTlsDomainMap = new Map([
|
||||
['app.example.com', [1, 2]],
|
||||
]);
|
||||
const caCertMap = makeCaCertMap([1, 'CA_A'], [2, 'CA_B']);
|
||||
const leafOverride = new Map([['app.example.com', [makeCaPem('USER_1')]]]);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['app.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
new Map(),
|
||||
new Set(),
|
||||
leafOverride
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.trusted_ca_certs).toContain('CA_A');
|
||||
expect(result!.trusted_ca_certs).toContain('CA_B');
|
||||
expect(result!.trusted_leaf_certs).toEqual(['USER_1']);
|
||||
});
|
||||
|
||||
it('deduplicates leaf PEMs from multiple domains in same group', () => {
|
||||
const sharedPem = makeCaPem('SHARED_CERT');
|
||||
const mTlsDomainMap = new Map([
|
||||
['a.example.com', [1]],
|
||||
['b.example.com', [1]],
|
||||
]);
|
||||
const caCertMap = makeCaCertMap([1, 'CA_A']);
|
||||
const leafOverride = new Map([
|
||||
['a.example.com', [sharedPem]],
|
||||
['b.example.com', [sharedPem]],
|
||||
]);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['a.example.com', 'b.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
new Map(),
|
||||
new Set(),
|
||||
leafOverride
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// The Set-based dedup in the function should handle this
|
||||
// Actually looking at the code, it uses a Set for PEMs
|
||||
const leafCerts = result!.trusted_leaf_certs as string[];
|
||||
// pemToBase64Der('SHARED_CERT') appears once since we used a Set
|
||||
expect(leafCerts.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null when leaf override has PEMs but no CA exists in caCertMap', () => {
|
||||
const mTlsDomainMap = new Map([['app.example.com', [99]]]);
|
||||
const caCertMap = new Map<number, { id: number; certificatePem: string }>(); // CA 99 not found
|
||||
const leafOverride = new Map([['app.example.com', [makeCaPem('USER')]]]);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['app.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
new Map(),
|
||||
new Set(),
|
||||
leafOverride
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty leaf override map (same as no override)', () => {
|
||||
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
|
||||
const caCertMap = makeCaCertMap([1, 'CA_A']);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['app.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
new Map(),
|
||||
new Set(),
|
||||
new Map()
|
||||
);
|
||||
|
||||
// No override → unmanaged CA logic
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
|
||||
expect(result!.trusted_leaf_certs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles undefined leaf override (backward compat)', () => {
|
||||
const mTlsDomainMap = new Map([['app.example.com', [1]]]);
|
||||
const caCertMap = makeCaCertMap([1, 'CA_A']);
|
||||
|
||||
const result = buildClientAuthentication(
|
||||
['app.example.com'],
|
||||
mTlsDomainMap,
|
||||
caCertMap,
|
||||
new Map(),
|
||||
new Set(),
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.trusted_ca_certs).toEqual(['CA_A']);
|
||||
});
|
||||
});
|
||||
369
tests/unit/caddy-mtls-rbac.test.ts
Normal file
369
tests/unit/caddy-mtls-rbac.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Unit tests for mTLS RBAC functions in src/lib/caddy-mtls.ts
|
||||
*
|
||||
* Covers:
|
||||
* - resolveAllowedFingerprints: union of role + cert fingerprints
|
||||
* - buildFingerprintCelExpression: CEL expression generation
|
||||
* - buildMtlsRbacSubroutes: full subroute generation with path rules
|
||||
* - normalizeFingerprint: colon stripping + lowercase
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveAllowedFingerprints,
|
||||
buildFingerprintCelExpression,
|
||||
buildMtlsRbacSubroutes,
|
||||
normalizeFingerprint,
|
||||
type MtlsAccessRuleLike,
|
||||
} from "../../src/lib/caddy-mtls";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeRule(overrides: Partial<MtlsAccessRuleLike> = {}): MtlsAccessRuleLike {
|
||||
return {
|
||||
path_pattern: "/admin/*",
|
||||
allowed_role_ids: [],
|
||||
allowed_cert_ids: [],
|
||||
deny_all: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── normalizeFingerprint ─────────────────────────────────────────────
|
||||
|
||||
describe("normalizeFingerprint", () => {
|
||||
it("strips colons and lowercases", () => {
|
||||
expect(normalizeFingerprint("AB:CD:EF:12")).toBe("abcdef12");
|
||||
});
|
||||
|
||||
it("handles already-normalized input", () => {
|
||||
expect(normalizeFingerprint("abcdef12")).toBe("abcdef12");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(normalizeFingerprint("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveAllowedFingerprints ───────────────────────────────────────
|
||||
|
||||
describe("resolveAllowedFingerprints", () => {
|
||||
it("resolves fingerprints from roles", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([
|
||||
[1, new Set(["fp_a", "fp_b"])],
|
||||
[2, new Set(["fp_c"])],
|
||||
]);
|
||||
const certFpMap = new Map<number, string>();
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [1, 2] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result).toEqual(new Set(["fp_a", "fp_b", "fp_c"]));
|
||||
});
|
||||
|
||||
it("resolves fingerprints from direct cert IDs", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>();
|
||||
const certFpMap = new Map<number, string>([
|
||||
[10, "fp_x"],
|
||||
[20, "fp_y"],
|
||||
]);
|
||||
|
||||
const rule = makeRule({ allowed_cert_ids: [10, 20] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result).toEqual(new Set(["fp_x", "fp_y"]));
|
||||
});
|
||||
|
||||
it("unions both roles and certs", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([
|
||||
[1, new Set(["fp_a"])],
|
||||
]);
|
||||
const certFpMap = new Map<number, string>([[10, "fp_b"]]);
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result).toEqual(new Set(["fp_a", "fp_b"]));
|
||||
});
|
||||
|
||||
it("deduplicates when a cert is in a role AND directly allowed", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([
|
||||
[1, new Set(["fp_a"])],
|
||||
]);
|
||||
const certFpMap = new Map<number, string>([[10, "fp_a"]]);
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("fp_a")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty set for unknown role/cert IDs", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>();
|
||||
const certFpMap = new Map<number, string>();
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [999], allowed_cert_ids: [999] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildFingerprintCelExpression ────────────────────────────────────
|
||||
|
||||
describe("buildFingerprintCelExpression", () => {
|
||||
it("builds CEL expression with sorted fingerprints", () => {
|
||||
const fps = new Set(["fp_b", "fp_a"]);
|
||||
const expr = buildFingerprintCelExpression(fps);
|
||||
expect(expr).toBe("{http.request.tls.client.fingerprint} in ['fp_a', 'fp_b']");
|
||||
});
|
||||
|
||||
it("handles single fingerprint", () => {
|
||||
const fps = new Set(["abc123"]);
|
||||
const expr = buildFingerprintCelExpression(fps);
|
||||
expect(expr).toBe("{http.request.tls.client.fingerprint} in ['abc123']");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildMtlsRbacSubroutes ──────────────────────────────────────────
|
||||
|
||||
describe("buildMtlsRbacSubroutes", () => {
|
||||
const baseHandlers = [{ handler: "headers" }];
|
||||
const reverseProxy = { handler: "reverse_proxy" };
|
||||
|
||||
it("returns null for empty rules", () => {
|
||||
const result = buildMtlsRbacSubroutes(
|
||||
[],
|
||||
new Map(),
|
||||
new Map(),
|
||||
baseHandlers,
|
||||
reverseProxy
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("generates allow + deny routes for a role-based rule", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([
|
||||
[1, new Set(["fp_admin"])],
|
||||
]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Should have 3 routes: allow /admin/*, deny /admin/*, catch-all
|
||||
expect(result!.length).toBe(3);
|
||||
|
||||
// Allow route has expression matcher
|
||||
const allowRoute = result![0] as Record<string, unknown>;
|
||||
const match = (allowRoute.match as Record<string, unknown>[])[0];
|
||||
expect(match.path).toEqual(["/admin/*"]);
|
||||
expect(match.expression).toContain("fp_admin");
|
||||
expect(allowRoute.terminal).toBe(true);
|
||||
|
||||
// Deny route returns 403
|
||||
const denyRoute = result![1] as Record<string, unknown>;
|
||||
const denyMatch = (denyRoute.match as Record<string, unknown>[])[0];
|
||||
expect(denyMatch.path).toEqual(["/admin/*"]);
|
||||
const denyHandler = (denyRoute.handle as Record<string, unknown>[])[0];
|
||||
expect(denyHandler.status_code).toBe("403");
|
||||
|
||||
// Catch-all has no match
|
||||
const catchAll = result![2] as Record<string, unknown>;
|
||||
expect(catchAll.match).toBeUndefined();
|
||||
expect(catchAll.terminal).toBe(true);
|
||||
});
|
||||
|
||||
it("generates 403 for deny_all rule", () => {
|
||||
const rules = [makeRule({ deny_all: true })];
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// deny route + catch-all = 2
|
||||
expect(result!.length).toBe(2);
|
||||
|
||||
const denyRoute = result![0] as Record<string, unknown>;
|
||||
const handler = (denyRoute.handle as Record<string, unknown>[])[0];
|
||||
expect(handler.status_code).toBe("403");
|
||||
});
|
||||
|
||||
it("generates 403 when rule has no matching fingerprints", () => {
|
||||
const rules = [makeRule({ allowed_role_ids: [999] })]; // role doesn't exist
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// deny route + catch-all = 2
|
||||
expect(result!.length).toBe(2);
|
||||
|
||||
const denyRoute = result![0] as Record<string, unknown>;
|
||||
const handler = (denyRoute.handle as Record<string, unknown>[])[0];
|
||||
expect(handler.status_code).toBe("403");
|
||||
});
|
||||
|
||||
it("handles multiple rules with different paths", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([
|
||||
[1, new Set(["fp_admin"])],
|
||||
[2, new Set(["fp_api"])],
|
||||
]);
|
||||
const rules = [
|
||||
makeRule({ path_pattern: "/admin/*", allowed_role_ids: [1] }),
|
||||
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1, 2] }),
|
||||
];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// 2 rules × 2 routes each + 1 catch-all = 5
|
||||
expect(result!.length).toBe(5);
|
||||
});
|
||||
|
||||
it("uses direct cert fingerprints as overrides", () => {
|
||||
const certFpMap = new Map<number, string>([[42, "fp_special"]]);
|
||||
const rules = [makeRule({ allowed_cert_ids: [42] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), certFpMap, baseHandlers, reverseProxy);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const allowRoute = result![0] as Record<string, unknown>;
|
||||
const match = (allowRoute.match as Record<string, unknown>[])[0];
|
||||
expect(match.expression).toContain("fp_special");
|
||||
});
|
||||
|
||||
it("catch-all route includes base handlers + reverse proxy", () => {
|
||||
const rules = [makeRule({ deny_all: true })];
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
const catchAll = result![result!.length - 1] as Record<string, unknown>;
|
||||
const handlers = catchAll.handle as Record<string, unknown>[];
|
||||
expect(handlers).toHaveLength(2); // baseHandlers[0] + reverseProxy
|
||||
expect(handlers[0]).toEqual({ handler: "headers" });
|
||||
expect(handlers[1]).toEqual({ handler: "reverse_proxy" });
|
||||
});
|
||||
|
||||
it("allow route includes base handlers + reverse proxy", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1] })];
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
const allowRoute = result![0] as Record<string, unknown>;
|
||||
const handlers = allowRoute.handle as Record<string, unknown>[];
|
||||
expect(handlers).toHaveLength(2);
|
||||
expect(handlers[1]).toEqual({ handler: "reverse_proxy" });
|
||||
});
|
||||
|
||||
it("deny route body is 'mTLS access denied'", () => {
|
||||
const rules = [makeRule({ deny_all: true })];
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
const denyHandler = (result![0] as any).handle[0];
|
||||
expect(denyHandler.body).toBe("mTLS access denied");
|
||||
});
|
||||
|
||||
it("handles mixed deny_all and role-based rules", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
|
||||
const rules = [
|
||||
makeRule({ path_pattern: "/secret/*", deny_all: true }),
|
||||
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1] }),
|
||||
];
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
// /secret/* deny + /api/* allow + /api/* deny + catch-all = 4
|
||||
expect(result!.length).toBe(4);
|
||||
|
||||
// First route: deny /secret/*
|
||||
expect((result![0] as any).match[0].path).toEqual(["/secret/*"]);
|
||||
expect((result![0] as any).handle[0].status_code).toBe("403");
|
||||
|
||||
// Second route: allow /api/*
|
||||
expect((result![1] as any).match[0].path).toEqual(["/api/*"]);
|
||||
expect((result![1] as any).match[0].expression).toContain("fp");
|
||||
});
|
||||
|
||||
it("handles rule with both roles and certs combined", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp_role"])]]);
|
||||
const certFpMap = new Map<number, string>([[42, "fp_cert"]]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1], allowed_cert_ids: [42] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, certFpMap, baseHandlers, reverseProxy);
|
||||
const match = (result![0] as any).match[0];
|
||||
expect(match.expression).toContain("fp_role");
|
||||
expect(match.expression).toContain("fp_cert");
|
||||
});
|
||||
|
||||
it("preserves base handlers order in generated routes", () => {
|
||||
const multiHandlers = [{ handler: "waf" }, { handler: "headers" }, { handler: "auth" }];
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), multiHandlers, reverseProxy);
|
||||
const allowHandlers = (result![0] as any).handle;
|
||||
expect(allowHandlers[0]).toEqual({ handler: "waf" });
|
||||
expect(allowHandlers[1]).toEqual({ handler: "headers" });
|
||||
expect(allowHandlers[2]).toEqual({ handler: "auth" });
|
||||
expect(allowHandlers[3]).toEqual({ handler: "reverse_proxy" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── normalizeFingerprint edge cases ──────────────────────────────────
|
||||
|
||||
describe("normalizeFingerprint edge cases", () => {
|
||||
it("handles full SHA-256 fingerprint with colons", () => {
|
||||
const fp = "AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89";
|
||||
expect(normalizeFingerprint(fp)).toBe("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
||||
});
|
||||
|
||||
it("handles mixed case without colons", () => {
|
||||
expect(normalizeFingerprint("AbCdEf")).toBe("abcdef");
|
||||
});
|
||||
|
||||
it("handles fingerprint with only colons", () => {
|
||||
expect(normalizeFingerprint(":::")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildFingerprintCelExpression edge cases ─────────────────────────
|
||||
|
||||
describe("buildFingerprintCelExpression edge cases", () => {
|
||||
it("handles empty fingerprint set", () => {
|
||||
const expr = buildFingerprintCelExpression(new Set());
|
||||
expect(expr).toBe("{http.request.tls.client.fingerprint} in []");
|
||||
});
|
||||
|
||||
it("handles many fingerprints", () => {
|
||||
const fps = new Set(Array.from({ length: 50 }, (_, i) => `fp_${String(i).padStart(3, "0")}`));
|
||||
const expr = buildFingerprintCelExpression(fps);
|
||||
expect(expr).toContain("fp_000");
|
||||
expect(expr).toContain("fp_049");
|
||||
// Verify sorted order
|
||||
const idx0 = expr.indexOf("fp_000");
|
||||
const idx49 = expr.indexOf("fp_049");
|
||||
expect(idx0).toBeLessThan(idx49);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveAllowedFingerprints edge cases ────────────────────────────
|
||||
|
||||
describe("resolveAllowedFingerprints edge cases", () => {
|
||||
it("handles empty arrays in rule", () => {
|
||||
const rule = makeRule({ allowed_role_ids: [], allowed_cert_ids: [] });
|
||||
const result = resolveAllowedFingerprints(rule, new Map(), new Map());
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles role with empty fingerprint set", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set()]]);
|
||||
const rule = makeRule({ allowed_role_ids: [1] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("merges fingerprints from multiple roles correctly", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([
|
||||
[1, new Set(["a", "b"])],
|
||||
[2, new Set(["b", "c"])],
|
||||
[3, new Set(["c", "d"])],
|
||||
]);
|
||||
const rule = makeRule({ allowed_role_ids: [1, 2, 3] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
|
||||
expect(result).toEqual(new Set(["a", "b", "c", "d"]));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user