diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index 008e20902..8ab5acff2 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -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 }; + } + }); }; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts index a1e2c8f09..ce0e00f4a 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts @@ -2,3 +2,7 @@ export enum HCVaultConnectionMethod { AccessToken = "access-token", AppRole = "app-role" } + +export enum HCVaultAuthType { + Kubernetes = "kubernetes" +} 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 6f7016ac1..84574cfb0 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 @@ -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, - namespace?: string + gatewayService: Pick ) => { 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, - namespace?: string + gatewayService: Pick ) => { 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 => { 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, namespace: string, - secretPath: string + secretPath: string, + connection: THCVaultConnection, + gatewayService: Pick ) => { 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 +): Promise => { + 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(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 +): Promise => { + 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" + }); + } +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index 7cd861c60..d25dbc9e6 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -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 | 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; +}; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 917886ec6..a1bc57c60 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -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 }; }; diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index 4b4d94904..f257ea005 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -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 + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts index 230b11da9..217d6fdff 100644 --- a/frontend/src/hooks/api/migration/types.ts +++ b/frontend/src/hooks/api/migration/types.ts @@ -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; + }; +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index d2926e024..51be8a5d5 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -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 = ({ Advanced + {hasVaultConnection && !isUpdate && ( +
+
+ + Load values from HashiCorp Vault +
+ +
+ )}
)} @@ -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." > - + )} /> @@ -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." > - + )} /> @@ -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} > - + )} /> @@ -628,6 +772,11 @@ export const IdentityKubernetesAuthForm = ({ Cancel
+ handleImportPopUpToggle("importFromVault", isOpen)} + onImport={handleImportFromVault} + /> ); }; 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 new file mode 100644 index 000000000..e56047450 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/VaultKubernetesAuthImportModal.tsx @@ -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("default"); + const [selectedRole, setSelectedRole] = useState(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 ( + <> + + <> + 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" + /> +

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

+ +
+ + + <> + { + 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" + /> +

+ Select the Kubernetes role to load configuration from +

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