Add mtls to blueprint, policy, and oss getTraefikConfig

This commit is contained in:
Owen
2026-10-20 10:02:44 -04:00
parent 6aaaae3819
commit f4140fd6f6
34 changed files with 1607 additions and 516 deletions
@@ -14,12 +14,11 @@ import {
type SelectedRole
} from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
import { ResourceMtlsCertificatesCredenza } from "@app/components/ResourceMtlsCertificatesCredenza";
import { Button } from "@app/components/ui/button";
import { Form, FormField } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceMtls } from "@app/hooks/useResourceMtls";
import { usePolicyMtls } from "@app/hooks/usePolicyMtls";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { resourceQueries } from "@app/lib/queries";
@@ -33,13 +32,7 @@ import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { toUnicode } from "punycode";
import {
useContext,
useEffect,
useMemo,
useRef,
useState
} from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { createPolicySchema } from ".";
import {
@@ -50,6 +43,7 @@ import {
} from "./PolicyAuthMethodCredenzas";
import { PolicyAuthOtherMethodsSection } from "./PolicyAuthOtherMethodsSection";
import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection";
import { PolicyMtlsCertificatesCredenza } from "./PolicyMtlsCertificatesCredenza";
import type { PolicyAuthMethodId } from "./policy-auth-method-id";
import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice";
import z from "zod";
@@ -259,9 +253,7 @@ export function PolicyAuthStackSectionEdit({
const [editingMethod, setEditingMethod] =
useState<PolicyAuthMethodId | null>(null);
const mtls = useResourceMtls();
// mTLS is resource-level, so it stays editable when the policy is shared.
const mtlsReadonly = readonly;
const mtls = usePolicyMtls();
const canSaveMtls =
mtls.isDirty && !(mtls.enabled && !mtls.hasCertificates);
@@ -513,8 +505,7 @@ export function PolicyAuthStackSectionEdit({
}),
api.post(`/resource/${resourceId}/users`, {
userIds: currentResourceUserIds
}),
...(canSaveMtls ? [mtls.save()] : [])
})
]);
toast({
@@ -725,7 +716,7 @@ export function PolicyAuthStackSectionEdit({
certificateCount:
mtls.certificates.length,
sslEnabled: mtls.sslEnabled,
disabled: mtlsReadonly,
disabled: authReadonly,
onConfigure: () =>
openMethodEditor("mtls"),
onToggle: (active) =>
@@ -824,7 +815,7 @@ export function PolicyAuthStackSectionEdit({
/>
{mtls.supported && (
<ResourceMtlsCertificatesCredenza
<PolicyMtlsCertificatesCredenza
open={editingMethod === "mtls"}
onOpenChange={(open) => {
if (open) return;
@@ -834,8 +825,14 @@ export function PolicyAuthStackSectionEdit({
mtls.setEnabled(false);
}
}}
resourcePolicyId={
policy.resourcePolicyId
}
certificates={mtls.certificates}
isLoading={mtls.isLoadingCertificates}
onMtlsDisabled={() =>
updatePolicy({ mtlsEnabled: false })
}
/>
)}
</>
@@ -16,15 +16,14 @@ import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { Textarea } from "@app/components/ui/textarea";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import { orgQueries } from "@app/lib/queries";
import type {
CreateResourceMtlsCertificateResponse,
DeleteResourceMtlsCertificateResponse,
ListResourceMtlsCertificatesResponse
} from "@server/routers/resource";
CreateResourcePolicyMtlsCertificateResponse,
DeleteResourcePolicyMtlsCertificateResponse,
ListResourcePolicyMtlsCertificatesResponse
} from "@server/routers/policy";
import { useQueryClient } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { FileKey2, Trash2 } from "lucide-react";
@@ -34,22 +33,26 @@ import { useRef, useState } from "react";
const DAY_MS = 24 * 60 * 60 * 1000;
const EXPIRES_SOON_MS = 30 * DAY_MS;
type ResourceMtlsCertificatesCredenzaProps = {
type PolicyMtlsCertificatesCredenzaProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
certificates: ListResourceMtlsCertificatesResponse["certificates"];
resourcePolicyId: number;
certificates: ListResourcePolicyMtlsCertificatesResponse["certificates"];
isLoading: boolean;
// called when removing the last CA certificate switched mTLS off
onMtlsDisabled: () => void;
};
export function ResourceMtlsCertificatesCredenza({
export function PolicyMtlsCertificatesCredenza({
open,
onOpenChange,
resourcePolicyId,
certificates,
isLoading
}: ResourceMtlsCertificatesCredenzaProps) {
isLoading,
onMtlsDisabled
}: PolicyMtlsCertificatesCredenzaProps) {
const t = useTranslations();
const { env } = useEnvContext();
const { resource, updateResource } = useResourceContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
@@ -61,9 +64,7 @@ export function ResourceMtlsCertificatesCredenza({
async function invalidateCertificates() {
await queryClient.invalidateQueries(
resourceQueries.resourceMtlsCertificates({
resourceId: resource.resourceId
})
orgQueries.resourcePolicyMtlsCertificates({ resourcePolicyId })
);
}
@@ -82,13 +83,12 @@ export function ResourceMtlsCertificatesCredenza({
if (!pem.trim()) return;
setAdding(true);
try {
await api.put<AxiosResponse<CreateResourceMtlsCertificateResponse>>(
`/resource/${resource.resourceId}/mtls/certificate`,
{
certificate: pem,
...(name.trim() ? { name: name.trim() } : {})
}
);
await api.put<
AxiosResponse<CreateResourcePolicyMtlsCertificateResponse>
>(`/resource-policy/${resourcePolicyId}/mtls/certificate`, {
certificate: pem,
...(name.trim() ? { name: name.trim() } : {})
});
setPem("");
setName("");
await invalidateCertificates();
@@ -114,14 +114,14 @@ export function ResourceMtlsCertificatesCredenza({
setRemovingId(mtlsCertificateId);
try {
const res = await api.delete<
AxiosResponse<DeleteResourceMtlsCertificateResponse>
AxiosResponse<DeleteResourcePolicyMtlsCertificateResponse>
>(
`/resource/${resource.resourceId}/mtls/certificate/${mtlsCertificateId}`
`/resource-policy/${resourcePolicyId}/mtls/certificate/${mtlsCertificateId}`
);
await invalidateCertificates();
if (res.data.data.mtlsDisabled) {
updateResource({ mtlsEnabled: false });
onMtlsDisabled();
toast({
title: t("mtlsCertificateRemoved"),
description: t("mtlsLastCertificateRemoved")
@@ -1,56 +1,58 @@
import ResourceContext from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import type { UpdateResourceResponse } from "@server/routers/resource";
import { orgQueries } from "@app/lib/queries";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useContext, useEffect, useState } from "react";
// mTLS is a resource-level setting, so it is only available when editing
// authentication in the context of an HTTP resource.
export function useResourceMtls() {
// mTLS is set on the policy being edited, like the other auth methods. When
// editing in the context of a resource it only applies to HTTP resources.
export function usePolicyMtls() {
const { policy, updatePolicy } = useResourcePolicyContext();
const resourceContext = useContext(ResourceContext);
const { env } = useEnvContext();
const api = createApiClient({ env });
const resource = resourceContext?.resource;
const supported =
!!resource &&
!env.flags.disableEnterpriseFeatures &&
resource.mode === "http";
(!resource || resource.mode === "http");
const { data: certificates = [], isLoading: isLoadingCertificates } =
useQuery({
...resourceQueries.resourceMtlsCertificates({
resourceId: resource?.resourceId ?? 0
...orgQueries.resourcePolicyMtlsCertificates({
resourcePolicyId: policy.resourcePolicyId
}),
enabled: supported
});
const [enabled, setEnabled] = useState(Boolean(resource?.mtlsEnabled));
const [enabled, setEnabled] = useState(policy.mtlsEnabled);
// The server switches mTLS off when the last CA is removed.
useEffect(() => {
setEnabled(Boolean(resource?.mtlsEnabled));
}, [resource?.mtlsEnabled]);
setEnabled(policy.mtlsEnabled);
}, [policy.mtlsEnabled]);
const hasCertificates = certificates.length > 0;
const isDirty = supported && enabled !== Boolean(resource?.mtlsEnabled);
const isDirty = supported && enabled !== policy.mtlsEnabled;
async function save(): Promise<AxiosResponse | void> {
if (!supported || !isDirty) return;
const res = await api.post<AxiosResponse<UpdateResourceResponse>>(
`/resource/${resource!.resourceId}`,
const res = await api.put(
`/resource-policy/${policy.resourcePolicyId}`,
{ mtlsEnabled: enabled }
);
resourceContext!.updateResource({ mtlsEnabled: enabled });
updatePolicy({ mtlsEnabled: enabled });
return res;
}
return {
supported,
sslEnabled: Boolean(resource?.ssl),
// A policy page has no single resource; there it applies to the
// policy's resources that have SSL enabled.
sslEnabled: resource ? Boolean(resource.ssl) : true,
enabled,
setEnabled,
certificates,
+25 -13
View File
@@ -55,13 +55,15 @@ import type {
ListLauncherSitesResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type {
GetResourcePolicyResponse,
ListResourcePolicyMtlsCertificatesResponse
} from "@server/routers/policy";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type {
GetResourcePoliciesResponse,
GetResourceWhitelistResponse,
ListResourceAiModelsResponse,
ListResourceMtlsCertificatesResponse,
ListResourceNamesResponse,
ListResourceRolesResponse,
ListResourceRulesResponse,
@@ -927,6 +929,27 @@ export const orgQueries = {
return res.data.data;
}
}),
resourcePolicyMtlsCertificates: ({
resourcePolicyId
}: {
resourcePolicyId: number;
}) =>
queryOptions({
queryKey: [
"RESOURCE_POLICY",
resourcePolicyId,
"MTLS_CERTIFICATES"
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListResourcePolicyMtlsCertificatesResponse>
>(`/resource-policy/${resourcePolicyId}/mtls/certificates`, {
signal
});
return res.data.data.certificates;
}
})
};
@@ -1657,17 +1680,6 @@ export const resourceQueries = {
return res.data.data.whitelist;
}
}),
resourceMtlsCertificates: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "MTLS_CERTIFICATES"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListResourceMtlsCertificatesResponse>
>(`/resource/${resourceId}/mtls/certificates`, { signal });
return res.data.data.certificates;
}
}),
policies: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "POLICIES"] as const,