mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 12:09:04 +02:00
Put the mtls on the policy creation screen
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user