From de62f9c6c95b7200ad0d8cc234311dda83de9482 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 20 Oct 2026 10:21:10 -0400 Subject: [PATCH] Put the mtls on the policy creation screen --- messages/en-US.json | 4 + .../routers/policy/createResourcePolicy.ts | 87 +++++++++++- .../resource-policy/CreatePolicyForm.tsx | 14 +- .../PolicyAuthStackSectionCreate.tsx | 126 ++++++++++++++++++ .../PolicyMtlsCertificatesCredenza.tsx | 111 ++++++++++----- src/components/resource-policy/index.ts | 11 ++ 6 files changed, 317 insertions(+), 36 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index c87efd724..cc0ff07f9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3961,6 +3961,10 @@ "mtlsCertificateErrorRemoveDescription": "The certificate could not be removed.", "mtlsLastCertificateRemoved": "mTLS was turned off because the last CA certificate was removed.", "mtlsNoCertificates": "No CA certificates added yet", + "mtlsPendingCertificate": "CA certificate {number}", + "mtlsCertificateInvalidPem": "No PEM-encoded certificate found. Expected a block starting with -----BEGIN CERTIFICATE-----.", + "mtlsCertificatePrivateKey": "This looks like a private key, not a certificate. Upload the CA's public certificate only.", + "mtlsCertificateDuplicate": "This CA certificate has already been added.", "mtlsRemoveCertificate": "Remove CA certificate", "mtlsExpired": "Expired", "mtlsExpiresSoon": "Expires soon", diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 8131603dd..5f0794f2f 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -30,6 +30,11 @@ import { } from "@server/db"; import { getUniqueResourcePolicyName } from "@server/db/names"; import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; +import { addMtlsCertificates } from "@server/lib/mtls"; +import { + MAX_MTLS_CERTIFICATES_PER_RESOURCE, + parseCaCertificates +} from "@server/lib/mtlsCertificate"; import response from "@server/lib/response"; import { getResourceRuleValueValidationError, @@ -112,7 +117,27 @@ const createResourcePolicyBodySchema = z.strictObject({ .default([]), // rules applyRules: z.boolean().default(false), - rules: z.array(ruleSchema).optional().default([]) + rules: z.array(ruleSchema).optional().default([]), + // mTLS + mtlsEnabled: z + .boolean() + .optional() + .default(false) + .describe( + "Require clients to present a certificate signed by one of mtlsCaCertificates. Only takes effect on HTTP resources with SSL enabled." + ), + mtlsCaCertificates: z + .array( + z.strictObject({ + certificate: z.string().min(1).max(100_000), + name: z.string().min(1).max(255).optional() + }) + ) + .optional() + .default([]) + .describe( + "PEM-encoded trusted CA certificates for mTLS. Each certificate may be a bundle; the name is only kept for a single certificate." + ) }); registry.registerPath({ @@ -197,9 +222,52 @@ export async function createResourcePolicy( pincode, headerAuth, emails, - rules + rules, + mtlsEnabled, + mtlsCaCertificates } = parsedBody.data; + // Validate the CA certificates up front so bad input is a 400 + const mtlsFingerprints = new Set(); + for (const { certificate } of mtlsCaCertificates) { + try { + for (const cert of parseCaCertificates(certificate)) { + if (mtlsFingerprints.has(cert.fingerprint)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "The same CA certificate was provided more than once" + ) + ); + } + mtlsFingerprints.add(cert.fingerprint); + } + } catch (e) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + e instanceof Error ? e.message : "Invalid certificate" + ) + ); + } + } + if (mtlsFingerprints.size > MAX_MTLS_CERTIFICATES_PER_RESOURCE) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `At most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates can be added` + ) + ); + } + if (mtlsEnabled && mtlsFingerprints.size === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Add at least one trusted CA certificate before enabling mTLS." + ) + ); + } + // Check if Identity provider in `skipToIdpId` exists if (skipToIdpId) { const providerExists = await idpExistsForOrg(skipToIdpId, orgId); @@ -274,7 +342,8 @@ export async function createResourcePolicy( sso, idpId: skipToIdpId, applyRules, - emailWhitelistEnabled + emailWhitelistEnabled, + mtlsEnabled }) .returning(); @@ -369,6 +438,18 @@ export async function createResourcePolicy( ); } + for (const { certificate, name } of mtlsCaCertificates) { + await addMtlsCertificates( + { + type: "policy", + resourcePolicyId: newPolicy.resourcePolicyId + }, + certificate, + name, + trx + ); + } + return newPolicy; }); diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index a065601af..4e3965f22 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -93,7 +93,9 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { rules: [], password: null, headerAuth: null, - pincode: null + pincode: null, + mtlsEnabled: false, + mtlsCertificates: [] } }); @@ -122,6 +124,16 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { // email OTP emailWhitelistEnabled: payload.emailWhitelistEnabled, emails: payload.emails.map((email) => email.text), + // mTLS + mtlsEnabled: + payload.mtlsEnabled && + payload.mtlsCertificates.length > 0, + mtlsCaCertificates: payload.mtlsEnabled + ? payload.mtlsCertificates.map((c) => ({ + certificate: c.certificate, + ...(c.name ? { name: c.name } : {}) + })) + : [], // rules applyRules: payload.applyRules, rules: payload.rules diff --git a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx index 6e4084e4f..fb5ec9dfa 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx @@ -11,6 +11,8 @@ import { import { RolesSelector } from "@app/components/roles-selector"; import { UsersSelector } from "@app/components/users-selector"; import { FormField } from "@app/components/ui/form"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { type UseFormReturn, useWatch } from "react-hook-form"; @@ -23,8 +25,12 @@ import { } from "./PolicyAuthMethodCredenzas"; import { PolicyAuthOtherMethodsSection } from "./PolicyAuthOtherMethodsSection"; import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection"; +import { MtlsCertificatesCredenza } from "./PolicyMtlsCertificatesCredenza"; import type { PolicyAuthMethodId } from "./policy-auth-method-id"; +const PEM_CERTIFICATE_REGEX = + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + export type PolicyAuthStackSectionCreateProps = { form: UseFormReturn; orgId: string; @@ -39,6 +45,7 @@ export function PolicyAuthStackSectionCreate({ emailEnabled }: PolicyAuthStackSectionCreateProps) { const t = useTranslations(); + const { env } = useEnvContext(); const [editingMethod, setEditingMethod] = useState(null); @@ -62,13 +69,76 @@ export function PolicyAuthStackSectionCreate({ }); const emails = useWatch({ control: parentForm.control, name: "emails" }) ?? []; + const mtlsEnabled = useWatch({ + control: parentForm.control, + name: "mtlsEnabled" + }); + const mtlsCertificates = + useWatch({ control: parentForm.control, name: "mtlsCertificates" }) ?? + []; const passcodeActive = Boolean(password); const pinActive = Boolean(pincode); const headerAuthActive = Boolean(headerAuth); + const mtlsSupported = !env.flags.disableEnterpriseFeatures; const closeCredenza = () => setEditingMethod(null); + // The policy doesn't exist yet, so certificates are held in the form and + // sent with the create request, which parses and validates them. + async function addMtlsCertificates(pem: string, name: string | null) { + const showError = (description: string) => { + toast({ + variant: "destructive", + title: t("mtlsCertificateErrorAdd"), + description + }); + return false; + }; + + if (/PRIVATE KEY/.test(pem)) { + return showError(t("mtlsCertificatePrivateKey")); + } + const blocks = (pem.match(PEM_CERTIFICATE_REGEX) ?? []).map( + (block) => block.trim() + "\n" + ); + if (blocks.length === 0) { + return showError(t("mtlsCertificateInvalidPem")); + } + + const existing = new Set(mtlsCertificates.map((c) => c.certificate)); + const newBlocks = [...new Set(blocks)].filter( + (block) => !existing.has(block) + ); + if (newBlocks.length === 0) { + return showError(t("mtlsCertificateDuplicate")); + } + + let nextId = + Math.max(0, ...mtlsCertificates.map((c) => c.mtlsCertificateId)) + + 1; + parentForm.setValue("mtlsCertificates", [ + ...mtlsCertificates, + ...newBlocks.map((certificate) => ({ + mtlsCertificateId: nextId++, + // a label only makes sense for a single certificate + name: newBlocks.length === 1 ? name : null, + certificate + })) + ]); + return true; + } + + async function removeMtlsCertificate(mtlsCertificateId: number) { + const remaining = mtlsCertificates.filter( + (c) => c.mtlsCertificateId !== mtlsCertificateId + ); + parentForm.setValue("mtlsCertificates", remaining); + if (remaining.length === 0) { + parentForm.setValue("mtlsEnabled", false); + } + } + const handleToggle = ( method: PolicyAuthMethodId, active: boolean, @@ -177,6 +247,34 @@ export function PolicyAuthStackSectionCreate({ parentForm.setValue("headerAuth", null) ) } + mtls={ + mtlsSupported + ? { + active: Boolean(mtlsEnabled), + certificateCount: mtlsCertificates.length, + // applies to the policy's resources + // that have SSL enabled + sslEnabled: true, + onConfigure: () => + setEditingMethod("mtls"), + onToggle: (active) => + handleToggle( + "mtls", + active, + () => + parentForm.setValue( + "mtlsEnabled", + false + ), + () => + parentForm.setValue( + "mtlsEnabled", + true + ) + ) + } + : undefined + } /> @@ -229,6 +327,34 @@ export function PolicyAuthStackSectionCreate({ parentForm.setValue("headerAuth", value); }} /> + + {mtlsSupported && ( + { + if (open) return; + closeCredenza(); + // mTLS can't be enabled without a trusted CA. + if (mtlsCertificates.length === 0) { + parentForm.setValue("mtlsEnabled", false); + } + }} + certificates={mtlsCertificates.map((c, index) => ({ + mtlsCertificateId: c.mtlsCertificateId, + name: + c.name ?? + t("mtlsPendingCertificate", { + number: index + 1 + }), + subject: null, + issuer: null, + notAfter: null + }))} + isLoading={false} + onAdd={addMtlsCertificates} + onRemove={removeMtlsCertificate} + /> + )} ); diff --git a/src/components/resource-policy/PolicyMtlsCertificatesCredenza.tsx b/src/components/resource-policy/PolicyMtlsCertificatesCredenza.tsx index 5dc0189cc..656bf05b0 100644 --- a/src/components/resource-policy/PolicyMtlsCertificatesCredenza.tsx +++ b/src/components/resource-policy/PolicyMtlsCertificatesCredenza.tsx @@ -33,6 +33,11 @@ import { useRef, useState } from "react"; const DAY_MS = 24 * 60 * 60 * 1000; const EXPIRES_SOON_MS = 30 * DAY_MS; +export type MtlsCertificateListItem = Pick< + ListResourcePolicyMtlsCertificatesResponse["certificates"][number], + "mtlsCertificateId" | "name" | "subject" | "issuer" | "notAfter" +>; + type PolicyMtlsCertificatesCredenzaProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -43,59 +48,37 @@ type PolicyMtlsCertificatesCredenzaProps = { onMtlsDisabled: () => void; }; +/** Manages the trusted CAs of an existing policy through the API. */ export function PolicyMtlsCertificatesCredenza({ - open, - onOpenChange, resourcePolicyId, - certificates, - isLoading, - onMtlsDisabled + onMtlsDisabled, + ...props }: PolicyMtlsCertificatesCredenzaProps) { const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const queryClient = useQueryClient(); - const [name, setName] = useState(""); - const [pem, setPem] = useState(""); - const [adding, setAdding] = useState(false); - const [removingId, setRemovingId] = useState(null); - const fileInputRef = useRef(null); - async function invalidateCertificates() { await queryClient.invalidateQueries( orgQueries.resourcePolicyMtlsCertificates({ resourcePolicyId }) ); } - async function onFileSelected(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file) return; - const text = await file.text(); - setPem((prev) => (prev.trim() ? `${prev.trim()}\n${text}` : text)); - if (!name && certificates.length === 0) { - setName(file.name.replace(/\.(pem|crt|cer|ca-bundle)$/i, "")); - } - } - - async function onAddCertificate() { - if (!pem.trim()) return; - setAdding(true); + async function onAdd(pem: string, name: string | null) { try { await api.put< AxiosResponse >(`/resource-policy/${resourcePolicyId}/mtls/certificate`, { certificate: pem, - ...(name.trim() ? { name: name.trim() } : {}) + ...(name ? { name } : {}) }); - setPem(""); - setName(""); await invalidateCertificates(); toast({ title: t("mtlsCertificateAdded"), description: t("mtlsCertificateAddedDescription") }); + return true; } catch (e) { toast({ variant: "destructive", @@ -105,13 +88,11 @@ export function PolicyMtlsCertificatesCredenza({ t("mtlsCertificateErrorAddDescription") ) }); - } finally { - setAdding(false); + return false; } } - async function onRemoveCertificate(mtlsCertificateId: number) { - setRemovingId(mtlsCertificateId); + async function onRemove(mtlsCertificateId: number) { try { const res = await api.delete< AxiosResponse @@ -138,6 +119,72 @@ export function PolicyMtlsCertificatesCredenza({ t("mtlsCertificateErrorRemoveDescription") ) }); + } + } + + return ( + + ); +} + +type MtlsCertificatesCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + certificates: MtlsCertificateListItem[]; + isLoading: boolean; + // resolves true when the certificate was added and the inputs can reset + onAdd: (pem: string, name: string | null) => Promise; + onRemove: (mtlsCertificateId: number) => Promise; +}; + +export function MtlsCertificatesCredenza({ + open, + onOpenChange, + certificates, + isLoading, + onAdd, + onRemove +}: MtlsCertificatesCredenzaProps) { + const t = useTranslations(); + + const [name, setName] = useState(""); + const [pem, setPem] = useState(""); + const [adding, setAdding] = useState(false); + const [removingId, setRemovingId] = useState(null); + const fileInputRef = useRef(null); + + async function onFileSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + const text = await file.text(); + setPem((prev) => (prev.trim() ? `${prev.trim()}\n${text}` : text)); + if (!name && certificates.length === 0) { + setName(file.name.replace(/\.(pem|crt|cer|ca-bundle)$/i, "")); + } + } + + async function onAddCertificate() { + if (!pem.trim()) return; + setAdding(true); + try { + if (await onAdd(pem, name.trim() || null)) { + setPem(""); + setName(""); + } + } finally { + setAdding(false); + } + } + + async function onRemoveCertificate(mtlsCertificateId: number) { + setRemovingId(mtlsCertificateId); + try { + await onRemove(mtlsCertificateId); } finally { setRemovingId(null); } diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts index 18591491a..541fe0449 100644 --- a/src/components/resource-policy/index.ts +++ b/src/components/resource-policy/index.ts @@ -32,6 +32,17 @@ export const createPolicySchema = z.object({ }) .nullable() .default(null), + mtlsEnabled: z.boolean().default(false), + // PEM blocks held locally until the policy is created + mtlsCertificates: z + .array( + z.object({ + mtlsCertificateId: z.number(), + name: z.string().nullable(), + certificate: z.string() + }) + ) + .default([]), applyRules: z.boolean().default(false), rules: z .array(