mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: add in-platform migrator for kubernetes auth
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,3 +2,7 @@ export enum HCVaultConnectionMethod {
|
||||
AccessToken = "access-token",
|
||||
AppRole = "app-role"
|
||||
}
|
||||
|
||||
export enum HCVaultAuthType {
|
||||
Kubernetes = "kubernetes"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user