Merge pull request #4711 from Infisical/feat/add-dynamic-secret-kube-auth-vault-migration

feat: add dynamic secret kube role vault migration
This commit is contained in:
Sheen
2025-10-29 01:03:43 +08:00
committed by GitHub
12 changed files with 748 additions and 37 deletions

View File

@@ -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",

View File

@@ -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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
): Promise<THCVaultKubernetesRole[]> => {
// 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<string, string>;
extra_labels?: Record<string, string>;
};
}>(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"
});
}
};

View File

@@ -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<string, string>;
extra_labels?: Record<string, string>;
config: THCVaultKubernetesSecretsConfig;
mountPath: string;
};

View File

@@ -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
};
};

View File

@@ -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.
<Warning>
**Organization Admin Access Required:** All in-platform migration features
(importing secrets, Kubernetes configurations, and policies from Vault) are
only accessible to organization admins.
</Warning>
### Step 1: Set Up Your Vault Connection
<Steps>
@@ -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"]
}
```
</Accordion>
@@ -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.
</Note>
#### 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
<Note>
Sensitive values like cluster tokens cannot be retrieved from Vault and must
be manually provided in the form after loading the configuration.
</Note>
#### 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

View File

@@ -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
});
};

View File

@@ -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<string, string>;
extra_labels?: Record<string, string>;
config: {
kubernetes_host: string;
kubernetes_ca_cert?: string;
};
};

View File

@@ -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 = ({
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<span className="text-mineshaft-200">Load values from HashiCorp Vault</span>
</div>
<Button
variant="outline_bg"
size="xs"
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
<Tooltip
content={
!isOrgAdmin
? "Only organization admins can import configurations from HashiCorp Vault"
: undefined
}
onClick={() => handleImportPopUpToggle("importFromVault", true)}
>
Load from Vault
</Button>
<Button
variant="outline_bg"
size="xs"
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
}
onClick={() => handleImportPopUpToggle("importFromVault", true)}
isDisabled={!isOrgAdmin}
>
Load from Vault
</Button>
</Tooltip>
</div>
)}
<div className="flex w-full items-center gap-2">

View File

@@ -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) => (
<Button
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
<Tooltip
content={
!isOrgAdmin
? "Only organization admins can import secrets from HashiCorp Vault"
: undefined
}
onClick={() => {
handlePopUpOpen("importFromVault");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add from HashiCorp Vault
</Button>
<Button
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
}
onClick={() => {
handlePopUpOpen("importFromVault");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed || !isOrgAdmin}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add from HashiCorp Vault
</Button>
</Tooltip>
)}
</ProjectPermissionCan>
)}

View File

@@ -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<TForm>({
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 (
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
{hasVaultConnection && (
<div className="mb-4 flex items-center justify-between rounded-md border border-primary-400/30 bg-primary/10 px-3 py-2.5">
<div className="flex items-center gap-2 text-sm">
<FontAwesomeIcon icon={faInfoCircle} className="text-primary" />
<span className="text-mineshaft-200">Load values from HashiCorp Vault</span>
</div>
<Tooltip
content={
!isOrgAdmin
? "Only organization admins can import configurations from HashiCorp Vault"
: undefined
}
>
<Button
variant="outline_bg"
size="xs"
type="button"
onClick={() => setIsVaultImportModalOpen(true)}
isDisabled={!isOrgAdmin}
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
}
>
Load from Vault
</Button>
</Tooltip>
</div>
)}
<div className="flex items-center space-x-2">
<div className="grow">
<Controller
@@ -286,8 +399,8 @@ export const KubernetesInputForm = ({
</div>
</div>
<div>
<div className="mt-4 mb-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<div className="mt-4 mb-4 border-b border-mineshaft-500 pb-2 pl-1">
<h3 className="font-medium text-mineshaft-200">Configuration</h3>
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
@@ -654,6 +767,11 @@ export const KubernetesInputForm = ({
Cancel
</Button>
</div>
<VaultKubernetesImportModal
isOpen={isVaultImportModalOpen}
onOpenChange={setIsVaultImportModalOpen}
onImport={handleVaultImport}
/>
</form>
);
};

View File

@@ -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<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<VaultKubernetesRole | null>(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 (
<>
<div className="mb-4 rounded-md bg-primary/10 p-3 text-sm text-mineshaft-200">
<div className="flex items-start gap-2">
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<div className="space-y-1.5 text-xs leading-relaxed">
<p>
Select a Kubernetes secrets engine role from Vault to pre-fill the form with its
configuration including cluster URL, CA certificate, TTL settings, etc.
</p>
</div>
</div>
</div>
<FormControl
label="Namespace"
className="mb-4"
tooltipText="Select the Vault namespace containing the Kubernetes secrets engine."
>
<>
<FilterableSelect
value={namespaces?.find((ns) => 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"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available Kubernetes secrets engines
</p>
</>
</FormControl>
<FormControl
label="Kubernetes Secrets Engine"
className="mb-4"
tooltipText="Select the Kubernetes secrets engine mount to fetch available roles."
>
<>
<FilterableSelect
value={kubernetesMounts?.find((mount) => 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"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a Kubernetes secrets engine mount to list available roles
</p>
</>
</FormControl>
<FormControl label="Kubernetes Role" className="mb-6">
<>
<FilterableSelect
value={selectedRole}
onChange={(value) => {
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"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a Kubernetes role from the selected mount to load its configuration
</p>
</>
</FormControl>
<div className="mt-8 flex space-x-4">
<Button
onClick={handleImport}
isDisabled={!selectedRole || isLoadingMounts || isLoadingRoles}
>
Load Configuration
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const VaultKubernetesImportModal = ({ isOpen, onOpenChange, onImport }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
bodyClassName="overflow-visible"
title="Load from HashiCorp Vault"
subTitle="Select a Kubernetes secrets engine role to load its configuration."
className="max-w-2xl"
>
<Content onClose={() => onOpenChange(false)} onImport={onImport} />
</ModalContent>
</Modal>
);
};