feat: add in-platform migrator for kubernetes auth

This commit is contained in:
Sheen Capadngan
2025-10-15 00:56:04 +08:00
parent dffc204ec2
commit 43498573c5
9 changed files with 700 additions and 39 deletions

View File

@@ -215,7 +215,7 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
},
schema: {
querystring: z.object({
namespace: z.string().optional()
namespace: z.string()
}),
response: {
200: z.object({
@@ -301,7 +301,7 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
},
schema: {
querystring: z.object({
namespace: z.string().optional()
namespace: z.string()
}),
response: {
200: z.object({
@@ -319,4 +319,57 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
return { secretPaths };
}
});
server.route({
method: "GET",
url: "/vault/auth-roles/kubernetes",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string()
}),
response: {
200: z.object({
roles: z.array(
z.object({
name: z.string(),
mountPath: z.string(),
bound_service_account_names: z.array(z.string()),
bound_service_account_namespaces: z.array(z.string()),
token_ttl: z.number().optional(),
token_max_ttl: z.number().optional(),
token_policies: z.array(z.string()).optional(),
token_bound_cidrs: z.array(z.string()).optional(),
token_explicit_max_ttl: z.number().optional(),
token_no_default_policy: z.boolean().optional(),
token_num_uses: z.number().optional(),
token_period: z.number().optional(),
token_type: z.string().optional(),
audience: z.string().optional(),
alias_name_source: z.string().optional(),
config: z.object({
kubernetes_host: z.string(),
kubernetes_ca_cert: z.string().optional(),
issuer: z.string().optional(),
disable_iss_validation: z.boolean().optional(),
disable_local_ca_jwt: z.boolean().optional()
})
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const roles = await server.services.migration.getVaultKubernetesAuthRoles({
actor: req.permission,
namespace: req.query.namespace
});
return { roles };
}
});
};

View File

@@ -2,3 +2,7 @@ export enum HCVaultConnectionMethod {
AccessToken = "access-token",
AppRole = "app-role"
}
export enum HCVaultAuthType {
Kubernetes = "kubernetes"
}

View File

@@ -12,10 +12,15 @@ import { logger } from "@app/lib/logger";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
import { HCVaultAuthType, HCVaultConnectionMethod } from "./hc-vault-connection-enums";
import {
THCVaultAuthMount,
THCVaultAuthMountResponse,
THCVaultConnection,
THCVaultConnectionConfig,
THCVaultKubernetesAuthConfig,
THCVaultKubernetesAuthRole,
THCVaultKubernetesAuthRoleWithConfig,
THCVaultMount,
THCVaultMountResponse
} from "./hc-vault-connection-types";
@@ -187,21 +192,19 @@ export const validateHCVaultConnectionCredentials = async (
};
export const listHCVaultPolicies = async (
namespace: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
namespace?: string
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
if (namespace && connection.credentials.namespace) {
if (connection.credentials.namespace && connection.credentials.namespace !== namespace) {
throw new BadRequestError({
message: "Namespace cannot be specified when namespace is already set in the connection credentials"
message: "Specified namespace does not match the namespace in the connection credentials"
});
}
const targetNamespace = namespace || connection.credentials.namespace;
try {
const { data: listData } = await requestWithHCVaultGateway<{
policies: string[];
@@ -210,7 +213,7 @@ export const listHCVaultPolicies = async (
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
"X-Vault-Namespace": namespace
}
});
@@ -227,7 +230,7 @@ export const listHCVaultPolicies = async (
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
"X-Vault-Namespace": namespace
}
});
@@ -365,21 +368,19 @@ export const listHCVaultMounts = async (
};
export const listHCVaultSecretPaths = async (
namespace: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
namespace?: string
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
if (namespace && connection.credentials.namespace) {
if (connection.credentials.namespace && connection.credentials.namespace !== namespace) {
throw new BadRequestError({
message: "Namespace cannot be specified when namespace is already set in the connection credentials"
message: "Specified namespace does not match the namespace in the connection credentials"
});
}
const targetNamespace = namespace || connection.credentials.namespace;
const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise<string[] | null> => {
try {
let path: string;
@@ -400,7 +401,7 @@ export const listHCVaultSecretPaths = async (
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
"X-Vault-Namespace": namespace
}
});
@@ -470,10 +471,10 @@ export const listHCVaultSecretPaths = async (
};
export const getHCVaultSecretsForPath = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
namespace: string,
secretPath: string
secretPath: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
@@ -484,8 +485,6 @@ export const getHCVaultSecretsForPath = async (
});
}
const targetNamespace = namespace || connection.credentials.namespace;
try {
// Extract mount and path from the secretPath
// secretPath format: {mount}/{path}
@@ -529,7 +528,7 @@ export const getHCVaultSecretsForPath = async (
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
"X-Vault-Namespace": namespace
}
});
@@ -547,7 +546,7 @@ export const getHCVaultSecretsForPath = async (
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
"X-Vault-Namespace": namespace
}
});
@@ -570,3 +569,156 @@ export const getHCVaultSecretsForPath = async (
});
}
};
export const getHCVaultAuthMounts = async (
namespace: string,
authType: HCVaultAuthType,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
): Promise<THCVaultAuthMount[]> => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
if (connection.credentials.namespace && connection.credentials.namespace !== namespace) {
throw new BadRequestError({
message: "Specified namespace does not match the namespace in the connection credentials"
});
}
try {
const { data } = await requestWithHCVaultGateway<THCVaultAuthMountResponse>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/auth`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
});
const authMounts: THCVaultAuthMount[] = [];
Object.entries(data.data).forEach(([path, authMethod]) => {
if (authMethod.type === authType) {
authMounts.push({
path,
type: authMethod.type,
description: authMethod.description,
accessor: authMethod.accessor
});
}
});
return authMounts;
} catch (error: unknown) {
logger.error(error, `Unable to list HC Vault ${authType} auth mounts`);
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list ${authType} auth mounts: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: `Unable to list ${authType} auth mounts from HashiCorp Vault`
});
}
};
export const getHCVaultKubernetesAuthRoles = async (
namespace: string,
mountPath: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
): Promise<THCVaultKubernetesAuthRoleWithConfig[]> => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
if (connection.credentials.namespace && connection.credentials.namespace !== namespace) {
throw new BadRequestError({
message: "Specified namespace does not match the namespace in the connection credentials"
});
}
// Remove trailing slash from mount path
const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath;
try {
// 1. Get the Kubernetes auth configuration for this mount
const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthConfig }>(
connection,
gatewayService,
{
url: `${instanceUrl}/v1/auth/${cleanMountPath}/config`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
}
);
const kubernetesConfig = configResponse.data;
// 2. List all roles in this mount
const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>(
connection,
gatewayService,
{
url: `${instanceUrl}/v1/auth/${cleanMountPath}/role`,
method: "LIST",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
}
);
const roleNames = roleListResponse.data.keys;
if (!roleNames || roleNames.length === 0) {
return [];
}
// 3. Fetch details for each role
const roleDetailsPromises = roleNames.map(async (roleName) => {
const { data: roleResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthRole }>(
connection,
gatewayService,
{
url: `${instanceUrl}/v1/auth/${cleanMountPath}/role/${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 THCVaultKubernetesAuthRoleWithConfig;
});
const roles = await Promise.all(roleDetailsPromises);
return roles;
} catch (error: unknown) {
logger.error(error, "Unable to list HC Vault Kubernetes auth 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 auth roles: ${errorMessage}`
});
}
throw new BadRequestError({
message: "Unable to list Kubernetes auth roles from HashiCorp Vault"
});
}
};

View File

@@ -39,3 +39,59 @@ export type THCVaultMount = {
type: string;
version?: string | null;
};
export type THCVaultAuthMountResponse = {
data: {
[key: string]: {
type: string;
description: string;
accessor: string;
config: {
default_lease_ttl: number;
max_lease_ttl: number;
force_no_cache: boolean;
};
local: boolean;
seal_wrap: boolean;
external_entropy_access: boolean;
options: Record<string, string> | null;
};
};
};
export type THCVaultAuthMount = {
path: string;
type: string;
description: string;
accessor: string;
};
export type THCVaultKubernetesAuthConfig = {
kubernetes_host: string;
kubernetes_ca_cert?: string;
issuer?: string;
disable_iss_validation?: boolean;
disable_local_ca_jwt?: boolean;
};
export type THCVaultKubernetesAuthRole = {
name: string;
bound_service_account_names: string[];
bound_service_account_namespaces: string[];
token_ttl?: number;
token_max_ttl?: number;
token_policies?: string[];
token_bound_cidrs?: string[];
token_explicit_max_ttl?: number;
token_no_default_policy?: boolean;
token_num_uses?: number;
token_period?: number;
token_type?: string;
audience?: string;
alias_name_source?: string;
};
export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole & {
config: THCVaultKubernetesAuthConfig;
mountPath: string;
};

View File

@@ -15,7 +15,10 @@ import { AppConnection } from "../app-connection/app-connection-enums";
import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns";
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
import {
getHCVaultAuthMounts,
getHCVaultKubernetesAuthRoles,
getHCVaultSecretsForPath,
HCVaultAuthType,
listHCVaultMounts,
listHCVaultNamespaces,
listHCVaultPolicies,
@@ -301,7 +304,7 @@ export const externalMigrationServiceFactory = ({
return namespaces;
};
const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace?: string }) => {
const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
@@ -339,7 +342,7 @@ export const externalMigrationServiceFactory = ({
credentials
} as THCVaultConnection;
const policies = await listHCVaultPolicies(connection, gatewayService, namespace);
const policies = await listHCVaultPolicies(namespace, connection, gatewayService);
return policies;
};
@@ -385,7 +388,7 @@ export const externalMigrationServiceFactory = ({
return mounts;
};
const getVaultSecretPaths = async ({ actor, namespace }: { actor: OrgServiceActor; namespace?: string }) => {
const getVaultSecretPaths = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
@@ -423,7 +426,7 @@ export const externalMigrationServiceFactory = ({
credentials
} as THCVaultConnection;
const secretPaths = await listHCVaultSecretPaths(connection, gatewayService, namespace);
const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService);
return secretPaths;
};
@@ -482,7 +485,7 @@ export const externalMigrationServiceFactory = ({
credentials
} as THCVaultConnection;
const vaultSecrets = await getHCVaultSecretsForPath(connection, gatewayService, vaultNamespace, vaultSecretPath);
const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService);
const secretOperation = await secretService.createManySecretsRaw({
actorId: actor.id,
@@ -522,6 +525,58 @@ export const externalMigrationServiceFactory = ({
}
};
const getVaultKubernetesAuthRoles = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: 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 Kubernetes auth roles" });
}
const vaultConfig = await externalMigrationConfigDAL.findOne({
orgId: actor.orgId,
platform: ExternalMigrationProviders.Vault
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
// Get all Kubernetes auth mounts for this namespace
const authMounts = await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, 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 {
importEnvKeyData,
importVaultData,
@@ -532,6 +587,7 @@ export const externalMigrationServiceFactory = ({
getVaultPolicies,
getVaultMounts,
getVaultSecretPaths,
importVaultSecrets
importVaultSecrets,
getVaultKubernetesAuthRoles
};
};

View File

@@ -2,7 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types";
import {
ExternalMigrationProviders,
TExternalMigrationConfig,
VaultKubernetesAuthRole
} from "./types";
export const externalMigrationQueryKeys = {
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
@@ -13,7 +17,8 @@ export const externalMigrationQueryKeys = {
vaultNamespaces: () => ["vault-namespaces"],
vaultPolicies: () => ["vault-policies"],
vaultMounts: () => ["vault-mounts"],
vaultSecretPaths: () => ["vault-secret-paths"]
vaultSecretPaths: () => ["vault-secret-paths"],
vaultKubernetesAuthRoles: (namespace?: string) => ["vault-kubernetes-auth-roles", namespace]
};
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
@@ -107,3 +112,21 @@ export const useGetVaultSecretPaths = (enabled = true, namespace?: string) => {
enabled
});
};
export const useGetVaultKubernetesAuthRoles = (enabled = true, namespace?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace),
queryFn: async () => {
const { data } = await apiRequest.get<{
roles: VaultKubernetesAuthRole[];
}>("/api/v3/external-migration/vault/auth-roles/kubernetes", {
params: {
namespace
}
});
return data.roles;
},
enabled
});
};

View File

@@ -19,3 +19,28 @@ export type TImportVaultSecretsDTO = {
vaultNamespace: string;
vaultSecretPath: string;
};
export type VaultKubernetesAuthRole = {
name: string;
bound_service_account_names: string[];
bound_service_account_namespaces: string[];
token_ttl?: number;
token_max_ttl?: number;
token_policies?: string[];
token_bound_cidrs?: string[];
token_explicit_max_ttl?: number;
token_no_default_policy?: boolean;
token_num_uses?: number;
token_period?: number;
token_type?: string;
audience?: string;
alias_name_source?: string;
mountPath: string;
config: {
kubernetes_host: string;
kubernetes_ca_cert?: string;
issuer?: string;
disable_iss_validation?: boolean;
disable_local_ca_jwt?: boolean;
};
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { faInfoCircle, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -37,9 +37,15 @@ import {
IdentityKubernetesAuthTokenReviewMode,
IdentityTrustedIp
} from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { useGetExternalMigrationConfig } from "@app/hooks/api/migration/queries";
import {
ExternalMigrationProviders,
VaultKubernetesAuthRole
} from "@app/hooks/api/migration/types";
import { usePopUp, UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityFormTab } from "./types";
import { VaultKubernetesAuthImportModal } from "./VaultKubernetesAuthImportModal";
const schema = z
.object({
@@ -121,6 +127,12 @@ export const IdentityKubernetesAuthForm = ({
enabled: isUpdate
});
const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([
"importFromVault"
] as const);
const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault);
const hasVaultConnection = Boolean(vaultConfig?.connectionId);
const {
control,
handleSubmit,
@@ -192,6 +204,106 @@ export const IdentityKubernetesAuthForm = ({
}
}, [data]);
const handleImportFromVault = (role: VaultKubernetesAuthRole) => {
try {
setValue("kubernetesHost", role.config.kubernetes_host, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
if (role.config.token_reviewer_jwt) {
setValue("tokenReviewerJwt", role.config.token_reviewer_jwt, {
shouldDirty: true,
shouldTouch: true
});
}
if (role.bound_service_account_names?.length > 0) {
// In Vault, "*" means allow all; in Infisical, empty field means allow any
const allowedNames = role.bound_service_account_names.includes("*")
? ""
: role.bound_service_account_names.join(", ");
setValue("allowedNames", allowedNames, {
shouldDirty: true,
shouldTouch: true
});
}
if (role.bound_service_account_namespaces?.length > 0) {
// In Vault, "*" means allow all; in Infisical, empty field means allow any
const allowedNamespaces = role.bound_service_account_namespaces.includes("*")
? ""
: role.bound_service_account_namespaces.join(", ");
setValue("allowedNamespaces", allowedNamespaces, {
shouldDirty: true,
shouldTouch: true
});
}
if (role.token_ttl !== undefined) {
setValue("accessTokenTTL", String(role.token_ttl), {
shouldDirty: true,
shouldTouch: true
});
}
if (role.token_max_ttl !== undefined) {
setValue("accessTokenMaxTTL", String(role.token_max_ttl), {
shouldDirty: true,
shouldTouch: true
});
}
if (role.token_num_uses !== undefined) {
setValue("accessTokenNumUsesLimit", String(role.token_num_uses), {
shouldDirty: true,
shouldTouch: true
});
}
if (role.audience) {
setValue("allowedAudience", role.audience, {
shouldDirty: true,
shouldTouch: true
});
}
if (role.config.kubernetes_ca_cert) {
setValue("caCert", role.config.kubernetes_ca_cert, {
shouldDirty: true,
shouldTouch: true
});
}
if (
subscription?.ipAllowlisting &&
role.token_bound_cidrs &&
role.token_bound_cidrs.length > 0
) {
setValue(
"accessTokenTrustedIps",
role.token_bound_cidrs.map((cidr) => ({ ipAddress: cidr })),
{
shouldDirty: true,
shouldTouch: true
}
);
}
createNotification({
type: "success",
text: `Successfully imported Kubernetes auth configuration from Vault role: ${role.name}`
});
} catch (err) {
console.error("Import error:", err);
createNotification({
type: "error",
text: "Failed to import Kubernetes auth configuration"
});
}
};
const onFormSubmit = async ({
kubernetesHost,
tokenReviewerJwt,
@@ -301,6 +413,28 @@ export const IdentityKubernetesAuthForm = ({
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
{hasVaultConnection && !isUpdate && (
<div className="bg-primary/10 border-primary/30 mb-4 flex items-center justify-between rounded-md border p-3">
<div className="flex items-start gap-2 text-sm">
<FontAwesomeIcon icon={faInfoCircle} className="text-primary mt-0.5" />
<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"
/>
}
onClick={() => handleImportPopUpToggle("importFromVault", true)}
>
Load from Vault
</Button>
</div>
)}
<div className="flex w-full items-center gap-2">
<div className="w-full flex-1">
<OrgPermissionCan
@@ -343,7 +477,7 @@ export const IdentityKubernetesAuthForm = ({
);
}
}}
className="w-full border border-mineshaft-500"
className="border-mineshaft-500 w-full border"
dropdownContainerClassName="max-w-none"
isLoading={isGatewayLoading}
placeholder="Default: Internet Gateway"
@@ -407,6 +541,7 @@ export const IdentityKubernetesAuthForm = ({
placeholder="https://my-example-k8s-api-host.com"
type="text"
value={field.value || ""}
autoComplete="off"
/>
</FormControl>
)}
@@ -425,7 +560,7 @@ export const IdentityKubernetesAuthForm = ({
errorText={error?.message}
tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding."
>
<Input {...field} placeholder="" type="password" />
<Input {...field} placeholder="" type="password" autoComplete="new-password" />
</FormControl>
)}
/>
@@ -441,7 +576,12 @@ export const IdentityKubernetesAuthForm = ({
errorText={error?.message}
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
>
<Input {...field} placeholder="namespaceA, namespaceB" type="text" />
<Input
{...field}
placeholder="namespaceA, namespaceB"
type="text"
autoComplete="off"
/>
</FormControl>
)}
/>
@@ -456,7 +596,11 @@ export const IdentityKubernetesAuthForm = ({
tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any service account."
errorText={error?.message}
>
<Input {...field} placeholder="service-account-1-name, service-account-1-name" />
<Input
{...field}
placeholder="service-account-1-name, service-account-1-name"
autoComplete="off"
/>
</FormControl>
)}
/>
@@ -628,6 +772,11 @@ export const IdentityKubernetesAuthForm = ({
Cancel
</Button>
</div>
<VaultKubernetesAuthImportModal
isOpen={popUp.importFromVault.isOpen}
onOpenChange={(isOpen) => handleImportPopUpToggle("importFromVault", isOpen)}
onImport={handleImportFromVault}
/>
</form>
);
};

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import {
useGetVaultKubernetesAuthRoles,
useGetVaultNamespaces
} from "@app/hooks/api/migration/queries";
import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onImport: (role: VaultKubernetesAuthRole) => void;
};
type ContentProps = {
onClose: () => void;
onImport: (role: VaultKubernetesAuthRole) => void;
};
const Content = ({ onClose, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string>("default");
const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null);
const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const {
data: roles,
isLoading: isLoadingRoles,
refetch: refetchRoles
} = useGetVaultKubernetesAuthRoles(shouldFetchRoles, selectedNamespace);
useEffect(() => {
if (selectedNamespace) {
setShouldFetchRoles(true);
}
}, [selectedNamespace]);
const handleImportAndApply = () => {
if (!selectedRole) {
createNotification({
type: "error",
text: "Please select a Kubernetes role to load"
});
return;
}
onImport(selectedRole);
onClose();
};
return (
<>
<FormControl
label="Namespace"
className="mb-4"
tooltipText="Select the Vault namespace containing the Kubernetes auth configuration."
>
<>
<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);
setSelectedRole(null);
// Refetch roles when namespace changes
refetchRoles();
}
}}
options={namespaces || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => option.name}
isDisabled={isLoadingNamespaces}
placeholder="Select namespace..."
className="w-full"
/>
<p className="text-mineshaft-400 mt-1 text-xs">
Select the Vault namespace to fetch available Kubernetes auth roles
</p>
</>
</FormControl>
<FormControl label="Kubernetes Role" className="mb-6">
<>
<FilterableSelect
value={selectedRole}
onChange={(value) => {
if (value && !Array.isArray(value)) {
setSelectedRole(value as VaultKubernetesAuthRole);
} else {
setSelectedRole(null);
}
}}
options={roles || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => `${option.name} (${option.mountPath})`}
isDisabled={isLoadingRoles || !roles?.length}
placeholder="Select a Kubernetes role to load..."
isClearable
className="w-full"
/>
<p className="text-mineshaft-400 mt-1 text-xs">
Select the Kubernetes role to load configuration from
</p>
</>
</FormControl>
<div className="mt-8 flex space-x-4">
<Button onClick={handleImportAndApply} isDisabled={!selectedRole || isLoadingRoles}>
Load
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const VaultKubernetesAuthImportModal = ({ isOpen, onOpenChange, onImport }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Load Kubernetes Auth from HashiCorp Vault"
subTitle="Load Kubernetes authentication configuration from your Vault instance. The auth method and role settings will be automatically translated and prefilled in the form."
className="max-w-2xl"
>
<Content onClose={() => onOpenChange(false)} onImport={onImport} />
</ModalContent>
</Modal>
);
};