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