Put the mtls on the policy creation screen

This commit is contained in:
Owen
2026-10-20 10:21:10 -04:00
parent f4140fd6f6
commit de62f9c6c9
6 changed files with 317 additions and 36 deletions
+4
View File
@@ -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",
@@ -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<string>();
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;
});
@@ -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
@@ -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<PolicyFormValues, any, any>;
orgId: string;
@@ -39,6 +45,7 @@ export function PolicyAuthStackSectionCreate({
emailEnabled
}: PolicyAuthStackSectionCreateProps) {
const t = useTranslations();
const { env } = useEnvContext();
const [editingMethod, setEditingMethod] =
useState<PolicyAuthMethodId | null>(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
}
/>
</SettingsSectionForm>
@@ -229,6 +327,34 @@ export function PolicyAuthStackSectionCreate({
parentForm.setValue("headerAuth", value);
}}
/>
{mtlsSupported && (
<MtlsCertificatesCredenza
open={editingMethod === "mtls"}
onOpenChange={(open) => {
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}
/>
)}
</SettingsSectionBody>
</SettingsSection>
);
@@ -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<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
async function invalidateCertificates() {
await queryClient.invalidateQueries(
orgQueries.resourcePolicyMtlsCertificates({ resourcePolicyId })
);
}
async function onFileSelected(e: React.ChangeEvent<HTMLInputElement>) {
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<CreateResourcePolicyMtlsCertificateResponse>
>(`/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<DeleteResourcePolicyMtlsCertificateResponse>
@@ -138,6 +119,72 @@ export function PolicyMtlsCertificatesCredenza({
t("mtlsCertificateErrorRemoveDescription")
)
});
}
}
return (
<MtlsCertificatesCredenza
{...props}
onAdd={onAdd}
onRemove={onRemove}
/>
);
}
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<boolean>;
onRemove: (mtlsCertificateId: number) => Promise<void>;
};
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<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
async function onFileSelected(e: React.ChangeEvent<HTMLInputElement>) {
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);
}
+11
View File
@@ -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(