feat: add dynamic secret kube auth vault migration

This commit is contained in:
Sheen Capadngan
2025-10-22 03:08:22 +08:00
parent 80aa473bca
commit a9ddd6eb89
12 changed files with 747 additions and 36 deletions
@@ -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({ server.route({
method: "GET", method: "GET",
url: "/vault/secret-paths", url: "/vault/secret-paths",
@@ -21,6 +21,8 @@ import {
THCVaultKubernetesAuthConfig, THCVaultKubernetesAuthConfig,
THCVaultKubernetesAuthRole, THCVaultKubernetesAuthRole,
THCVaultKubernetesAuthRoleWithConfig, THCVaultKubernetesAuthRoleWithConfig,
THCVaultKubernetesRole,
THCVaultKubernetesSecretsConfig,
THCVaultMount, THCVaultMount,
THCVaultMountResponse THCVaultMountResponse
} from "./hc-vault-connection-types"; } from "./hc-vault-connection-types";
@@ -799,3 +801,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"
});
}
};
@@ -95,3 +95,26 @@ export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole &
config: THCVaultKubernetesAuthConfig; config: THCVaultKubernetesAuthConfig;
mountPath: string; 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;
};
@@ -18,6 +18,7 @@ import { TAppConnectionServiceFactory } from "../app-connection/app-connection-s
import { import {
getHCVaultAuthMounts, getHCVaultAuthMounts,
getHCVaultKubernetesAuthRoles, getHCVaultKubernetesAuthRoles,
getHCVaultKubernetesRoles,
getHCVaultSecretsForPath, getHCVaultSecretsForPath,
HCVaultAuthType, HCVaultAuthType,
listHCVaultMounts, listHCVaultMounts,
@@ -747,6 +748,55 @@ export const externalMigrationServiceFactory = ({
return roles; 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 { return {
importEnvKeyData, importEnvKeyData,
importVaultData, importVaultData,
@@ -761,6 +811,7 @@ export const externalMigrationServiceFactory = ({
getVaultAuthMounts, getVaultAuthMounts,
getVaultSecretPaths, getVaultSecretPaths,
importVaultSecrets, importVaultSecrets,
getVaultKubernetesAuthRoles getVaultKubernetesAuthRoles,
getVaultKubernetesRoles
}; };
}; };
@@ -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. 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 ### Step 1: Set Up Your Vault Connection
<Steps> <Steps>
@@ -84,6 +90,19 @@ This migration approach lets you set up a connection to your Vault instance once
path "auth/+/role/*" { path "auth/+/role/*" {
capabilities = ["read"] 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> </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. must be manually provided in the form after importing the configuration.
</Note> </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 #### 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. 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

+34 -1
View File
@@ -5,7 +5,8 @@ import { apiRequest } from "@app/config/request";
import { import {
ExternalMigrationProviders, ExternalMigrationProviders,
TVaultExternalMigrationConfig, TVaultExternalMigrationConfig,
VaultKubernetesAuthRole VaultKubernetesAuthRole,
VaultKubernetesRole
} from "./types"; } from "./types";
export const externalMigrationQueryKeys = { export const externalMigrationQueryKeys = {
@@ -31,6 +32,11 @@ export const externalMigrationQueryKeys = {
"vault-kubernetes-auth-roles", "vault-kubernetes-auth-roles",
namespace, namespace,
mountPath mountPath
],
vaultKubernetesRoles: (namespace?: string, mountPath?: string) => [
"vault-kubernetes-roles",
namespace,
mountPath
] ]
}; };
@@ -172,3 +178,30 @@ export const useGetVaultKubernetesAuthRoles = (
enabled: enabled && !!namespace && !!mountPath 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
});
};
+21
View File
@@ -49,3 +49,24 @@ export type VaultKubernetesAuthRole = {
disable_local_ca_jwt?: boolean; 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;
};
};
@@ -22,11 +22,12 @@ import {
TextArea, TextArea,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context"; import { useOrganization, useOrgPermission, useSubscription } from "@app/context";
import { import {
OrgGatewayPermissionActions, OrgGatewayPermissionActions,
OrgPermissionSubjects OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types"; } from "@app/context/OrgPermissionContext/types";
import { OrgMembershipRole } from "@app/helpers/roles";
import { import {
gatewaysQueryKeys, gatewaysQueryKeys,
useAddIdentityKubernetesAuth, useAddIdentityKubernetesAuth,
@@ -129,6 +130,8 @@ export const IdentityKubernetesAuthForm = ({
] as const); ] as const);
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const { hasOrgRole } = useOrgPermission();
const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin);
const { const {
control, control,
@@ -409,20 +412,29 @@ export const IdentityKubernetesAuthForm = ({
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" /> <FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<span className="text-mineshaft-200">Load values from HashiCorp Vault</span> <span className="text-mineshaft-200">Load values from HashiCorp Vault</span>
</div> </div>
<Button <Tooltip
variant="outline_bg" content={
size="xs" !isOrgAdmin
leftIcon={ ? "Only organization admins can import configurations from HashiCorp Vault"
<img : undefined
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
} }
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>
)} )}
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
@@ -55,6 +55,7 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
ProjectPermissionSub, ProjectPermissionSub,
useOrgPermission,
useProject, useProject,
useProjectPermission, useProjectPermission,
useSubscription useSubscription
@@ -64,6 +65,7 @@ import {
ProjectPermissionSecretActions, ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions ProjectPermissionSecretRotationActions
} from "@app/context/ProjectPermissionContext/types"; } from "@app/context/ProjectPermissionContext/types";
import { OrgMembershipRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
useCreateFolder, useCreateFolder,
@@ -203,6 +205,8 @@ export const ActionBar = ({
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const { hasOrgRole } = useOrgPermission();
const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin);
const handleFolderCreate = async (folderName: string, description: string | null) => { const handleFolderCreate = async (folderName: string, description: string | null) => {
try { try {
@@ -1114,25 +1118,33 @@ export const ActionBar = ({
})} })}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Tooltip
leftIcon={ content={
<img !isOrgAdmin
src="/images/integrations/Vault.png" ? "Only organization admins can import secrets from HashiCorp Vault"
alt="HashiCorp Vault" : undefined
className="h-4 w-4"
/>
} }
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> </ProjectPermissionCan>
)} )}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Controller, FieldValues, useFieldArray, useForm } from "react-hook-form"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -21,16 +22,22 @@ import {
TextArea, TextArea,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrgPermission } from "@app/context";
import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext"; import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext";
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types"; import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
import { OrgMembershipRole } from "@app/helpers/roles";
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { import {
DynamicSecretProviders, DynamicSecretProviders,
KubernetesDynamicSecretCredentialType KubernetesDynamicSecretCredentialType
} from "@app/hooks/api/dynamicSecret/types"; } 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 { ProjectEnv } from "@app/hooks/api/types";
import { slugSchema } from "@app/lib/schemas"; import { slugSchema } from "@app/lib/schemas";
import { VaultKubernetesImportModal } from "./VaultKubernetesImportModal";
enum RoleType { enum RoleType {
ClusterRole = "cluster-role", ClusterRole = "cluster-role",
Role = "role" Role = "role"
@@ -162,11 +169,14 @@ export const KubernetesInputForm = ({
environments, environments,
isSingleEnvironmentMode isSingleEnvironmentMode
}: Props) => { }: Props) => {
const [isVaultImportModalOpen, setIsVaultImportModalOpen] = useState(false);
const { const {
control, control,
formState: { isSubmitting }, formState: { isSubmitting },
handleSubmit, handleSubmit,
watch watch,
setValue
} = useForm<TForm>({ } = useForm<TForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -193,11 +203,81 @@ export const KubernetesInputForm = ({
const createDynamicSecret = useCreateDynamicSecret(); const createDynamicSecret = useCreateDynamicSecret();
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); 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 sslEnabled = watch("provider.sslEnabled");
const credentialType = watch("provider.credentialType"); const credentialType = watch("provider.credentialType");
const authMethod = watch("provider.authMethod"); 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) {
setValue("provider.audiences", 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 handleCreateDynamicSecret = async (formData: TForm) => {
const { provider, usernameTemplate, ...rest } = formData; const { provider, usernameTemplate, ...rest } = formData;
// wait till previous request is finished // wait till previous request is finished
@@ -235,6 +315,39 @@ export const KubernetesInputForm = ({
return ( return (
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off"> <form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div> <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="flex items-center space-x-2">
<div className="grow"> <div className="grow">
<Controller <Controller
@@ -286,8 +399,8 @@ export const KubernetesInputForm = ({
</div> </div>
</div> </div>
<div> <div>
<div className="mt-4 mb-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200"> <div className="mt-4 mb-4 border-b border-mineshaft-500 pb-2 pl-1">
Configuration <h3 className="font-medium text-mineshaft-200">Configuration</h3>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -654,6 +767,11 @@ export const KubernetesInputForm = ({
Cancel Cancel
</Button> </Button>
</div> </div>
<VaultKubernetesImportModal
isOpen={isVaultImportModalOpen}
onOpenChange={setIsVaultImportModalOpen}
onImport={handleVaultImport}
/>
</form> </form>
); );
}; };
@@ -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>
);
};