diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index f0737e01d..367490c82 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -393,6 +393,56 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider } }); + server.route({ + method: "GET", + url: "/vault/kubernetes-roles", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + response: { + 200: z.object({ + roles: z.array( + z.object({ + name: z.string(), + mountPath: z.string(), + allowed_kubernetes_namespaces: z.array(z.string()).nullish(), + allowed_kubernetes_namespace_selector: z.string().nullish(), + token_max_ttl: z.number().nullish(), + token_default_ttl: z.number().nullish(), + token_default_audiences: z.array(z.string()).nullish(), + service_account_name: z.string().nullish(), + kubernetes_role_name: z.string().nullish(), + kubernetes_role_type: z.string().nullish(), + generated_role_rules: z.string().nullish(), + name_template: z.string().nullish(), + extra_annotations: z.record(z.string()).nullish(), + extra_labels: z.record(z.string()).nullish(), + config: z.object({ + kubernetes_host: z.string(), + kubernetes_ca_cert: z.string().nullish() + }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const roles = await server.services.migration.getVaultKubernetesRoles({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { roles }; + } + }); + server.route({ method: "GET", url: "/vault/secret-paths", diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 425c9c4d2..5eb9202fd 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -21,6 +21,8 @@ import { THCVaultKubernetesAuthConfig, THCVaultKubernetesAuthRole, THCVaultKubernetesAuthRoleWithConfig, + THCVaultKubernetesRole, + THCVaultKubernetesSecretsConfig, THCVaultMount, THCVaultMountResponse } from "./hc-vault-connection-types"; @@ -816,3 +818,122 @@ export const getHCVaultKubernetesAuthRoles = async ( }); } }; + +export const getHCVaultKubernetesRoles = async ( + namespace: string, + mountPath: string, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + // Remove trailing slash from mount path + const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath; + + try { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + // 1. Get the Kubernetes secrets engine configuration for this mount + const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesSecretsConfig }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/${cleanMountPath}/config`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const kubernetesConfig = configResponse.data; + + // 2. List all roles in this mount + let roleNames: string[] = []; + try { + const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/${cleanMountPath}/roles?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + roleNames = roleListResponse.data.keys || []; + } catch (error) { + // Vault returns 404 when no roles are configured yet + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as { response?: { status?: number } }; + if (axiosError.response?.status === 404) { + return []; + } + } + + throw error; + } + + if (!roleNames || roleNames.length === 0) { + return []; + } + + // 3. Fetch details for each role with concurrency control + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const roleDetailsPromises = roleNames.map((roleName) => + limiter(async () => { + const { data: roleResponse } = await requestWithHCVaultGateway<{ + data: { + allowed_kubernetes_namespaces?: string[]; + allowed_kubernetes_namespace_selector?: string; + token_max_ttl?: number; + token_default_ttl?: number; + token_default_audiences?: string[]; + service_account_name?: string; + kubernetes_role_name?: string; + kubernetes_role_type?: string; + generated_role_rules?: string; + name_template?: string; + extra_annotations?: Record; + extra_labels?: Record; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${cleanMountPath}/roles/${roleName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + // 4. Merge the role with the config + return { + ...roleResponse.data, + name: roleName, + config: kubernetesConfig, + mountPath: cleanMountPath + } as THCVaultKubernetesRole; + }) + ); + + const roles = await Promise.all(roleDetailsPromises); + + return roles; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault Kubernetes secrets engine roles"); + + if (error instanceof AxiosError) { + const errorMessage = + (error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error"; + throw new BadRequestError({ + message: `Failed to list Kubernetes secrets engine roles: ${errorMessage}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Kubernetes secrets engine roles from HashiCorp Vault" + }); + } +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index d25dbc9e6..2b1956c84 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -95,3 +95,26 @@ export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole & config: THCVaultKubernetesAuthConfig; mountPath: string; }; + +export type THCVaultKubernetesSecretsConfig = { + kubernetes_host: string; + kubernetes_ca_cert?: string; +}; + +export type THCVaultKubernetesRole = { + name: string; + allowed_kubernetes_namespaces?: string[]; + allowed_kubernetes_namespace_selector?: string; + token_max_ttl?: number; + token_default_ttl?: number; + token_default_audiences?: string[]; + service_account_name?: string; + kubernetes_role_name?: string; + kubernetes_role_type?: string; + generated_role_rules?: string; + name_template?: string; + extra_annotations?: Record; + extra_labels?: Record; + config: THCVaultKubernetesSecretsConfig; + mountPath: string; +}; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index bc2a24c07..55d4a9868 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -19,6 +19,7 @@ import { convertVaultValueToString, getHCVaultAuthMounts, getHCVaultKubernetesAuthRoles, + getHCVaultKubernetesRoles, getHCVaultSecretsForPath, HCVaultAuthType, listHCVaultMounts, @@ -762,6 +763,55 @@ export const externalMigrationServiceFactory = ({ return roles; }; + const getVaultKubernetesRoles = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can get Kubernetes roles" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + return getHCVaultKubernetesRoles(namespace, mountPath, connection, gatewayService); + }; + return { importEnvKeyData, importVaultData, @@ -776,6 +826,7 @@ export const externalMigrationServiceFactory = ({ getVaultAuthMounts, getVaultSecretPaths, importVaultSecrets, - getVaultKubernetesAuthRoles + getVaultKubernetesAuthRoles, + getVaultKubernetesRoles }; }; diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index e267cc5e9..c58e1ea6b 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -26,6 +26,12 @@ Infisical provides two approaches for migrating from HashiCorp Vault. This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical. + + **Organization Admin Access Required:** All in-platform migration features + (importing secrets, Kubernetes configurations, and policies from Vault) are + only accessible to organization admins. + + ### Step 1: Set Up Your Vault Connection @@ -84,6 +90,19 @@ This migration approach lets you set up a connection to your Vault instance once path "auth/+/role/*" { capabilities = ["read"] } + + # Kubernetes secrets engine - for reading secrets engine configuration and roles + path "+/config" { + capabilities = ["read"] + } + + path "+/roles" { + capabilities = ["list"] + } + + path "+/roles/*" { + capabilities = ["read"] + } ``` @@ -160,6 +179,34 @@ The authentication settings (service accounts, TTL, policies, etc.) will be auto must be manually provided in the form after importing the configuration. +#### Import Kubernetes Dynamic Secret Configurations + +When creating a Kubernetes dynamic secret, you can import the configuration from a Vault Kubernetes secrets engine role: + +1. Navigate to your project and select an environment +2. Click **"+ Add Secret"** dropdown and choose **"Dynamic Secret"** +3. Select **Kubernetes** as the provider +4. Click **"Load from Vault"** at the top of the form + + ![Load Kubernetes Dynamic Secret from Vault](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png) + +5. Select your Vault namespace, Kubernetes secrets engine mount, and role +6. Click **"Load Configuration"** + +The form will be automatically populated with the role's configuration including: + +- Cluster URL and CA certificate +- Credential type (Static or Dynamic) +- Service account name or Kubernetes role settings +- Allowed namespaces +- Token TTL values +- Token audiences + + + Sensitive values like cluster tokens cannot be retrieved from Vault and must + be manually provided in the form after loading the configuration. + + #### Import and Translate Access Control Policies When configuring project role-based access control, you can import Vault HCL policies and automatically translate them to Infisical permissions. diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png new file mode 100644 index 000000000..7cb345637 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png differ diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index e4ce6824d..198bf887b 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -5,7 +5,8 @@ import { apiRequest } from "@app/config/request"; import { ExternalMigrationProviders, TVaultExternalMigrationConfig, - VaultKubernetesAuthRole + VaultKubernetesAuthRole, + VaultKubernetesRole } from "./types"; export const externalMigrationQueryKeys = { @@ -31,6 +32,11 @@ export const externalMigrationQueryKeys = { "vault-kubernetes-auth-roles", namespace, mountPath + ], + vaultKubernetesRoles: (namespace?: string, mountPath?: string) => [ + "vault-kubernetes-roles", + namespace, + mountPath ] }; @@ -172,3 +178,30 @@ export const useGetVaultKubernetesAuthRoles = ( enabled: enabled && !!namespace && !!mountPath }); }; + +export const useGetVaultKubernetesRoles = ( + enabled = true, + namespace?: string, + mountPath?: string +) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultKubernetesRoles(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + roles: VaultKubernetesRole[]; + }>("/api/v3/external-migration/vault/kubernetes-roles", { + params: { + namespace, + mountPath + } + }); + + return data.roles; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts index f4303ea26..4c101548c 100644 --- a/frontend/src/hooks/api/migration/types.ts +++ b/frontend/src/hooks/api/migration/types.ts @@ -49,3 +49,24 @@ export type VaultKubernetesAuthRole = { disable_local_ca_jwt?: boolean; }; }; + +export type VaultKubernetesRole = { + name: string; + mountPath: string; + allowed_kubernetes_namespaces?: string[]; + allowed_kubernetes_namespace_selector?: string; + token_max_ttl?: number; + token_default_ttl?: number; + token_default_audiences?: string[]; + service_account_name?: string; + kubernetes_role_name?: string; + kubernetes_role_type?: string; + generated_role_rules?: string; + name_template?: string; + extra_annotations?: Record; + extra_labels?: Record; + config: { + kubernetes_host: string; + kubernetes_ca_cert?: string; + }; +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index 3cfe1c355..9b8624990 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -22,11 +22,12 @@ import { TextArea, Tooltip } from "@app/components/v2"; -import { useOrganization, useSubscription } from "@app/context"; +import { useOrganization, useOrgPermission, useSubscription } from "@app/context"; import { OrgGatewayPermissionActions, OrgPermissionSubjects } from "@app/context/OrgPermissionContext/types"; +import { OrgMembershipRole } from "@app/helpers/roles"; import { gatewaysQueryKeys, useAddIdentityKubernetesAuth, @@ -129,6 +130,8 @@ export const IdentityKubernetesAuthForm = ({ ] as const); const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); + const { hasOrgRole } = useOrgPermission(); + const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin); const { control, @@ -409,20 +412,29 @@ export const IdentityKubernetesAuthForm = ({ Load values from HashiCorp Vault - + + )}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index 1ec486bfe..f534e2ac3 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -55,6 +55,7 @@ import { ProjectPermissionActions, ProjectPermissionDynamicSecretActions, ProjectPermissionSub, + useOrgPermission, useProject, useProjectPermission, useSubscription @@ -64,6 +65,7 @@ import { ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; +import { OrgMembershipRole } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; import { useCreateFolder, @@ -203,6 +205,8 @@ export const ActionBar = ({ const { permission } = useProjectPermission(); const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); + const { hasOrgRole } = useOrgPermission(); + const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin); const handleFolderCreate = async (folderName: string, description: string | null) => { try { @@ -1121,25 +1125,33 @@ export const ActionBar = ({ })} > {(isAllowed) => ( - + + )} )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx index 4165805b1..d15bcc9b8 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/KubernetesInputForm.tsx @@ -1,5 +1,6 @@ +import { useState } from "react"; import { Controller, FieldValues, useFieldArray, useForm } from "react-hook-form"; -import { faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faInfoCircle, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; @@ -21,16 +22,22 @@ import { TextArea, Tooltip } from "@app/components/v2"; +import { useOrgPermission } from "@app/context"; import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext"; import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types"; +import { OrgMembershipRole } from "@app/helpers/roles"; import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; import { DynamicSecretProviders, KubernetesDynamicSecretCredentialType } from "@app/hooks/api/dynamicSecret/types"; +import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration/queries"; +import { VaultKubernetesRole } from "@app/hooks/api/migration/types"; import { ProjectEnv } from "@app/hooks/api/types"; import { slugSchema } from "@app/lib/schemas"; +import { VaultKubernetesImportModal } from "./VaultKubernetesImportModal"; + enum RoleType { ClusterRole = "cluster-role", Role = "role" @@ -162,11 +169,14 @@ export const KubernetesInputForm = ({ environments, isSingleEnvironmentMode }: Props) => { + const [isVaultImportModalOpen, setIsVaultImportModalOpen] = useState(false); + const { control, formState: { isSubmitting }, handleSubmit, - watch + watch, + setValue } = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -186,18 +196,88 @@ export const KubernetesInputForm = ({ } }); - const { fields, append, remove } = useFieldArray({ + const { fields, append, remove, replace } = useFieldArray({ control, name: "provider.audiences" }); const createDynamicSecret = useCreateDynamicSecret(); const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); + const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); + const { hasOrgRole } = useOrgPermission(); + const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin); const sslEnabled = watch("provider.sslEnabled"); const credentialType = watch("provider.credentialType"); const authMethod = watch("provider.authMethod"); + const handleVaultImport = (role: VaultKubernetesRole) => { + try { + setValue("name", role.name); + + setValue("provider.url", role.config.kubernetes_host); + + // Set CA certificate if available + if (role.config.kubernetes_ca_cert) { + setValue("provider.ca", role.config.kubernetes_ca_cert); + setValue("provider.sslEnabled", true); + } + + // Determine credential type based on role configuration + if (role.service_account_name) { + // Static credential type + setValue("provider.credentialType", KubernetesDynamicSecretCredentialType.Static); + setValue("provider.serviceAccountName", role.service_account_name); + + // Set namespace (single namespace for static) + if (role.allowed_kubernetes_namespaces && role.allowed_kubernetes_namespaces.length > 0) { + setValue("provider.namespace", role.allowed_kubernetes_namespaces[0]); + } + } else if (role.kubernetes_role_name) { + // Dynamic credential type + setValue("provider.credentialType", KubernetesDynamicSecretCredentialType.Dynamic); + setValue("provider.role", role.kubernetes_role_name); + + // Set role type + const roleType = + role.kubernetes_role_type === "ClusterRole" ? RoleType.ClusterRole : RoleType.Role; + setValue("provider.roleType", roleType); + + // Set allowed namespaces (comma-separated for dynamic) + if (role.allowed_kubernetes_namespaces && role.allowed_kubernetes_namespaces.length > 0) { + setValue("provider.namespace", role.allowed_kubernetes_namespaces.join(", ")); + } + } + + // Set TTLs if available + if (role.token_default_ttl) { + const defaultTTL = `${role.token_default_ttl}s`; + setValue("defaultTTL", defaultTTL); + } + + if (role.token_max_ttl) { + const maxTTL = `${role.token_max_ttl}s`; + setValue("maxTTL", maxTTL); + } + + // Set audiences if available + if (role.token_default_audiences && role.token_default_audiences.length > 0) { + replace(role.token_default_audiences); + } + + createNotification({ + type: "info", + text: "Configuration loaded successfully from HashiCorp Vault" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to load configuration from HashiCorp Vault" + }); + } + }; + const handleCreateDynamicSecret = async (formData: TForm) => { const { provider, usernameTemplate, ...rest } = formData; // wait till previous request is finished @@ -235,6 +315,39 @@ export const KubernetesInputForm = ({ return (
+ {hasVaultConnection && ( +
+
+ + Load values from HashiCorp Vault +
+ + + +
+ )} +
-
- Configuration +
+

Configuration

@@ -654,6 +767,11 @@ export const KubernetesInputForm = ({ Cancel
+ ); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VaultKubernetesImportModal.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VaultKubernetesImportModal.tsx new file mode 100644 index 000000000..3e9412ef9 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VaultKubernetesImportModal.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "react"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Modal, + ModalClose, + ModalContent +} from "@app/components/v2"; +import { + useGetVaultKubernetesRoles, + useGetVaultMounts, + useGetVaultNamespaces +} from "@app/hooks/api/migration/queries"; +import { VaultKubernetesRole } from "@app/hooks/api/migration/types"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onImport: (role: VaultKubernetesRole) => void; +}; + +type ContentProps = { + onClose: () => void; + onImport: (role: VaultKubernetesRole) => void; +}; + +const Content = ({ onClose, onImport }: ContentProps) => { + const [selectedNamespace, setSelectedNamespace] = useState(null); + const [selectedMountPath, setSelectedMountPath] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + const [shouldFetchRoles, setShouldFetchRoles] = useState(false); + const [shouldFetchMounts, setShouldFetchMounts] = useState(false); + + const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); + const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesRoles( + shouldFetchRoles, + selectedNamespace ?? undefined, + selectedMountPath ?? undefined + ); + const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts( + shouldFetchMounts, + selectedNamespace ?? undefined + ); + + // Filter to only show Kubernetes mounts + const kubernetesMounts = mounts?.filter((mount) => mount.type === "kubernetes"); + + // Enable fetching mounts when namespace is selected + useEffect(() => { + if (selectedNamespace) { + setShouldFetchMounts(true); + } + }, [selectedNamespace]); + + // Enable fetching roles when both namespace and mount path are selected + useEffect(() => { + if (selectedNamespace && selectedMountPath) { + setShouldFetchRoles(true); + } else { + setShouldFetchRoles(false); + } + }, [selectedNamespace, selectedMountPath]); + + const handleImport = () => { + if (!selectedRole) { + createNotification({ + type: "error", + text: "Please select a Vault Kubernetes role to load" + }); + return; + } + + if (!selectedNamespace) { + createNotification({ type: "error", text: "Please select a namespace" }); + return; + } + + if (!mounts || mounts.length === 0) { + createNotification({ + type: "error", + text: "No Vault mounts found. Please ensure you have Kubernetes secrets engine configured." + }); + return; + } + + onImport(selectedRole); + onClose(); + }; + + return ( + <> +
+
+ +
+

+ Select a Kubernetes secrets engine role from Vault to pre-fill the form with its + configuration including cluster URL, CA certificate, TTL settings, etc. +

+
+
+
+ + + <> + ns.name === selectedNamespace)} + onChange={(value) => { + if (value && !Array.isArray(value)) { + const namespace = value as { id: string; name: string }; + setSelectedNamespace(namespace.name); + setSelectedMountPath(null); + setSelectedRole(null); + } + }} + options={namespaces || []} + getOptionValue={(option) => option.name} + getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)} + isDisabled={isLoadingNamespaces} + placeholder="Select namespace..." + className="w-full" + /> +

+ Select the Vault namespace to fetch available Kubernetes secrets engines +

+ +
+ + + <> + mount.path === selectedMountPath)} + onChange={(value) => { + if (value && !Array.isArray(value)) { + const mount = value as { path: string; type: string; version: string | null }; + setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash + setSelectedRole(null); + } + }} + options={kubernetesMounts || []} + getOptionValue={(option) => option.path} + getOptionLabel={(option) => option.path.replace(/\/$/, "")} + isDisabled={isLoadingMounts || !kubernetesMounts?.length} + placeholder="Select Kubernetes secrets engine..." + className="w-full" + /> +

+ Choose a Kubernetes secrets engine mount to list available roles +

+ +
+ + + <> + { + if (value && !Array.isArray(value)) { + setSelectedRole(value as VaultKubernetesRole); + } else { + setSelectedRole(null); + } + }} + options={roles || []} + getOptionValue={(option) => option.name} + getOptionLabel={(option) => option.name} + isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath} + placeholder={ + !selectedMountPath ? "Select a mount path first..." : "Select a role to load..." + } + isClearable + className="w-full" + /> +

+ Choose a Kubernetes role from the selected mount to load its configuration +

+ +
+ +
+ + + + +
+ + ); +}; + +export const VaultKubernetesImportModal = ({ isOpen, onOpenChange, onImport }: Props) => { + return ( + + + onOpenChange(false)} onImport={onImport} /> + + + ); +};