diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index 2dbac642f..f0737e01d 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -332,6 +332,35 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider } }); + server.route({ + method: "GET", + url: "/vault/auth-mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + authType: z.string().optional() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultAuthMounts({ + actor: req.permission, + namespace: req.query.namespace, + authType: req.query.authType + }); + + return { mounts }; + } + }); + server.route({ method: "POST", url: "/vault/import-secrets", @@ -372,7 +401,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider }, schema: { querystring: z.object({ - namespace: z.string() + namespace: z.string(), + mountPath: z.string() }), response: { 200: z.object({ @@ -384,7 +414,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider handler: async (req) => { const secretPaths = await server.services.migration.getVaultSecretPaths({ actor: req.permission, - namespace: req.query.namespace + namespace: req.query.namespace, + mountPath: req.query.mountPath }); return { secretPaths }; @@ -399,7 +430,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider }, schema: { querystring: z.object({ - namespace: z.string() + namespace: z.string(), + mountPath: z.string() }), response: { @@ -437,7 +469,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider handler: async (req) => { const roles = await server.services.migration.getVaultKubernetesAuthRoles({ actor: req.permission, - namespace: req.query.namespace + namespace: req.query.namespace, + mountPath: req.query.mountPath }); return { roles }; 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 c17686501..38f97700c 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 @@ -455,7 +455,8 @@ export const listHCVaultMounts = async ( export const listHCVaultSecretPaths = async ( namespace: string, connection: THCVaultConnection, - gatewayService: Pick + gatewayService: Pick, + filterMountPath?: string ) => { const instanceUrl = await getHCVaultInstanceUrl(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService); @@ -532,7 +533,13 @@ export const listHCVaultSecretPaths = async ( const mounts = await listHCVaultMounts(connection, gatewayService, namespace); // Filter for KV mounts (kv, kv-v1, kv-v2) - const kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv")); + let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv")); + + // If filterMountPath is provided, filter to only that mount + if (filterMountPath) { + const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash + kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath); + } // Create concurrency limiter to avoid overwhelming the Vault instance const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); @@ -648,7 +655,7 @@ export const getHCVaultSecretsForPath = async ( export const getHCVaultAuthMounts = async ( namespace: string, - authType: HCVaultAuthType, + authType: HCVaultAuthType | undefined, connection: THCVaultConnection, gatewayService: Pick ): Promise => { @@ -668,7 +675,8 @@ export const getHCVaultAuthMounts = async ( const authMounts: THCVaultAuthMount[] = []; Object.entries(data.data).forEach(([path, authMethod]) => { - if (authMethod.type === authType) { + // If authType is specified, filter by it; otherwise, include all + if (!authType || authMethod.type === authType) { authMounts.push({ path, type: authMethod.type, @@ -680,16 +688,17 @@ export const getHCVaultAuthMounts = async ( return authMounts; } catch (error: unknown) { - logger.error(error, `Unable to list HC Vault ${authType} auth mounts`); + const authTypeStr = authType || "all"; + logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`); if (error instanceof AxiosError) { throw new BadRequestError({ - message: `Failed to list ${authType} auth mounts: ${error.message || "Unknown error"}` + message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}` }); } throw new BadRequestError({ - message: `Unable to list ${authType} auth mounts from HashiCorp Vault` + message: `Unable to list ${authTypeStr} auth mounts from HashiCorp Vault` }); } }; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index da475a012..638d57511 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -444,7 +444,15 @@ export const externalMigrationServiceFactory = ({ return mounts; }; - const getVaultSecretPaths = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const getVaultSecretPaths = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { const { hasRole } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -482,7 +490,7 @@ export const externalMigrationServiceFactory = ({ credentials } as THCVaultConnection; - const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService); + const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath); return secretPaths; }; @@ -617,7 +625,66 @@ export const externalMigrationServiceFactory = ({ return deletedConfig; }; - const getVaultKubernetesAuthRoles = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const getVaultAuthMounts = async ({ + actor, + namespace, + authType + }: { + actor: OrgServiceActor; + namespace: string; + authType?: 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 view vault auth mounts" }); + } + + 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; + + const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService); + + return authMounts; + }; + + const getVaultKubernetesAuthRoles = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { const { hasRole } = await permissionService.getOrgPermission( actor.type, actor.id, @@ -655,18 +722,10 @@ export const externalMigrationServiceFactory = ({ credentials } as THCVaultConnection; - // Get all Kubernetes auth mounts for this namespace - const authMounts = await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService); + // Get roles for the specified mount path only + const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService); - // For each mount, get all roles with their configuration - const allRolesPromises = authMounts.map(async (mount) => { - const roles = await getHCVaultKubernetesAuthRoles(namespace, mount.path, connection, gatewayService); - return roles; - }); - - const rolesPerMount = await Promise.all(allRolesPromises); - - return rolesPerMount.flat(); + return roles; }; return { @@ -680,6 +739,7 @@ export const externalMigrationServiceFactory = ({ getVaultNamespaces, getVaultPolicies, getVaultMounts, + getVaultAuthMounts, getVaultSecretPaths, importVaultSecrets, getVaultKubernetesAuthRoles diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png index bb4c96d75..230393be6 100644 Binary files a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png index e93c7fff5..55185db37 100644 Binary files a/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png differ diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index 8c982e3e0..e4ce6824d 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -17,8 +17,21 @@ export const externalMigrationQueryKeys = { vaultNamespaces: () => ["vault-namespaces"], vaultPolicies: (namespace?: string) => ["vault-policies", namespace], vaultMounts: (namespace?: string) => ["vault-mounts", namespace], - vaultSecretPaths: (namespace?: string) => ["vault-secret-paths", namespace], - vaultKubernetesAuthRoles: (namespace?: string) => ["vault-kubernetes-auth-roles", namespace] + vaultAuthMounts: (namespace?: string, authType?: string) => [ + "vault-auth-mounts", + namespace, + authType + ], + vaultSecretPaths: (namespace?: string, mountPath?: string) => [ + "vault-secret-paths", + namespace, + mountPath + ], + vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [ + "vault-kubernetes-auth-roles", + namespace, + mountPath + ] }; export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => { @@ -91,38 +104,71 @@ export const useGetVaultMounts = (enabled = true, namespace?: string) => { }); }; -export const useGetVaultSecretPaths = (enabled = true, namespace?: string) => { +export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => { return useQuery({ - queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace), + queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath), queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + const { data } = await apiRequest.get<{ secretPaths: string[]; }>("/api/v3/external-migration/vault/secret-paths", { params: { - namespace + namespace, + mountPath } }); return data.secretPaths; }, + enabled: enabled && !!namespace && !!mountPath + }); +}; + +export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType), + queryFn: async () => { + const { data } = await apiRequest.get<{ + mounts: Array<{ path: string; type: string }>; + }>("/api/v3/external-migration/vault/auth-mounts", { + params: { + namespace, + ...(authType && { authType }) + } + }); + + return data.mounts; + }, enabled }); }; -export const useGetVaultKubernetesAuthRoles = (enabled = true, namespace?: string) => { +export const useGetVaultKubernetesAuthRoles = ( + enabled = true, + namespace?: string, + mountPath?: string +) => { return useQuery({ - queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace), + queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath), queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + const { data } = await apiRequest.get<{ roles: VaultKubernetesAuthRole[]; }>("/api/v3/external-migration/vault/auth-roles/kubernetes", { params: { - namespace + namespace, + mountPath } }); return data.roles; }, - enabled + enabled: enabled && !!namespace && !!mountPath }); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx index 3c3fd6df1..7491b51d6 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx @@ -10,6 +10,7 @@ import { ModalContent } from "@app/components/v2"; import { + useGetVaultAuthMounts, useGetVaultKubernetesAuthRoles, useGetVaultNamespaces } from "@app/hooks/api/migration/queries"; @@ -28,21 +29,39 @@ type ContentProps = { 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: authMounts, isLoading: isLoadingMounts } = useGetVaultAuthMounts( + shouldFetchMounts, + selectedNamespace ?? undefined, + "kubernetes" + ); const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles( shouldFetchRoles, - selectedNamespace ?? undefined + selectedNamespace ?? undefined, + selectedMountPath ?? undefined ); + // Enable fetching mounts when namespace is selected useEffect(() => { if (selectedNamespace) { - setShouldFetchRoles(true); + 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 handleImportAndApply = () => { if (!selectedRole) { createNotification({ @@ -70,6 +89,7 @@ const Content = ({ onClose, onImport }: ContentProps) => { if (value && !Array.isArray(value)) { const namespace = value as { id: string; name: string }; setSelectedNamespace(namespace.name); + setSelectedMountPath(null); setSelectedRole(null); } }} @@ -81,7 +101,42 @@ const Content = ({ onClose, onImport }: ContentProps) => { className="w-full" />

- Select the Vault namespace to fetch available Kubernetes auth roles + Select the Vault namespace to fetch available auth mounts +

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

+ Choose a Kubernetes auth engine to filter available roles

@@ -99,9 +154,13 @@ const Content = ({ onClose, onImport }: ContentProps) => { }} options={roles || []} getOptionValue={(option) => option.name} - getOptionLabel={(option) => `${option.name} (${option.mountPath})`} - isDisabled={isLoadingRoles || !roles?.length} - placeholder="Select a Kubernetes role to load..." + getOptionLabel={(option) => option.name} + isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath} + placeholder={ + !selectedMountPath + ? "Select an auth engine first..." + : "Select a Kubernetes role to load..." + } isClearable className="w-full" /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/VaultSecretImportModal.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/VaultSecretImportModal.tsx index d9294500d..820a21f4d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/VaultSecretImportModal.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/VaultSecretImportModal.tsx @@ -34,6 +34,7 @@ type ContentProps = { const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) => { const [selectedNamespace, setSelectedNamespace] = useState(null); + const [selectedMountPath, setSelectedMountPath] = useState(null); const [selectedPath, setSelectedPath] = useState(null); const [shouldFetchPaths, setShouldFetchPaths] = useState(false); const [shouldFetchMounts, setShouldFetchMounts] = useState(false); @@ -41,21 +42,33 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) = const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); const { data: secretPaths, isLoading: isLoadingPaths } = useGetVaultSecretPaths( shouldFetchPaths, - selectedNamespace ?? undefined + selectedNamespace ?? undefined, + selectedMountPath ?? undefined ); const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts( shouldFetchMounts, selectedNamespace ?? undefined ); - // Enable fetching paths and mounts when namespace is selected + // Filter to only show KV mounts + const kvMounts = mounts?.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv")); + + // Enable fetching mounts when namespace is selected useEffect(() => { if (selectedNamespace) { - setShouldFetchPaths(true); setShouldFetchMounts(true); } }, [selectedNamespace]); + // Enable fetching paths when both namespace and mount path are selected + useEffect(() => { + if (selectedNamespace && selectedMountPath) { + setShouldFetchPaths(true); + } else { + setShouldFetchPaths(false); + } + }, [selectedNamespace, selectedMountPath]); + const handleImport = () => { if (!selectedPath) { createNotification({ type: "error", text: "Please select a Vault secret path to import" }); @@ -111,6 +124,7 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) = if (value && !Array.isArray(value)) { const namespace = value as { id: string; name: string }; setSelectedNamespace(namespace.name); + setSelectedMountPath(null); setSelectedPath(null); } }} @@ -122,7 +136,35 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) = className="w-full" />

- Select the Vault namespace to fetch available secret paths + Select the Vault namespace to fetch available mounts +

+ + + + + <> + 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 + setSelectedPath(null); + } + }} + options={kvMounts || []} + getOptionValue={(option) => option.path} + getOptionLabel={(option) => option.path.replace(/\/$/, "")} + isDisabled={isLoadingMounts || !kvMounts?.length} + placeholder="Select secrets engine..." + className="w-full" + /> +

+ Choose a KV secrets engine to filter available secret paths

@@ -141,13 +183,17 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) = options={(secretPaths || []).map((path) => ({ path }))} getOptionValue={(option) => option.path} getOptionLabel={(option) => option.path} - isDisabled={isLoadingPaths || !secretPaths?.length} - placeholder="Select a Vault path to import..." + isDisabled={isLoadingPaths || !secretPaths?.length || !selectedMountPath} + placeholder={ + !selectedMountPath + ? "Select a mount path first..." + : "Select a Vault path to import..." + } isClearable className="w-full" />

- Choose a secret path from your Vault namespace to import into Infisical + Choose a secret path from the selected mount to import into Infisical