Merge pull request #4667 from Infisical/feat/in-platform-vault-migration-tooling

feat: in-platform migration tooling for Vault policies + scaffolding
This commit is contained in:
Sheen
2025-10-18 00:04:57 +08:00
committed by GitHub
37 changed files with 4224 additions and 124 deletions

View File

@@ -518,6 +518,9 @@ import {
TUsers,
TUsersInsert,
TUsersUpdate,
TVaultExternalMigrationConfigs,
TVaultExternalMigrationConfigsInsert,
TVaultExternalMigrationConfigsUpdate,
TWebhooks,
TWebhooksInsert,
TWebhooksUpdate,
@@ -1345,5 +1348,10 @@ declare module "knex/types/tables" {
TAdditionalPrivilegesInsert,
TAdditionalPrivilegesUpdate
>;
[TableName.VaultExternalMigrationConfig]: KnexOriginal.CompositeTableType<
TVaultExternalMigrationConfigs,
TVaultExternalMigrationConfigsInsert,
TVaultExternalMigrationConfigsUpdate
>;
}
}

View File

@@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.VaultExternalMigrationConfig))) {
await knex.schema.createTable(TableName.VaultExternalMigrationConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("orgId").notNullable();
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
t.string("namespace").notNullable();
t.uuid("connectionId");
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
t.timestamps(true, true, true);
t.unique(["orgId", "namespace"]);
});
await createOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.VaultExternalMigrationConfig);
await dropOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig);
}

View File

@@ -176,5 +176,6 @@ export * from "./user-aliases";
export * from "./user-encryption-keys";
export * from "./user-group-membership";
export * from "./users";
export * from "./vault-external-migration-configs";
export * from "./webhooks";
export * from "./workflow-integrations";

View File

@@ -203,7 +203,9 @@ export enum TableName {
PamFolder = "pam_folders",
PamResource = "pam_resources",
PamAccount = "pam_accounts",
PamSession = "pam_sessions"
PamSession = "pam_sessions",
VaultExternalMigrationConfig = "vault_external_migration_configs"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";

View File

@@ -0,0 +1,26 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const VaultExternalMigrationConfigsSchema = z.object({
id: z.string().uuid(),
orgId: z.string().uuid(),
namespace: z.string(),
connectionId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TVaultExternalMigrationConfigs = z.infer<typeof VaultExternalMigrationConfigsSchema>;
export type TVaultExternalMigrationConfigsInsert = Omit<
z.input<typeof VaultExternalMigrationConfigsSchema>,
TImmutableDBKeys
>;
export type TVaultExternalMigrationConfigsUpdate = Partial<
Omit<z.input<typeof VaultExternalMigrationConfigsSchema>, TImmutableDBKeys>
>;

View File

@@ -176,6 +176,7 @@ import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-gr
import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service";
import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue";
import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service";
import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal";
import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal";
import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal";
import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal";
@@ -534,6 +535,8 @@ export const registerRoutes = async (
const membershipRoleDAL = membershipRoleDALFactory(db);
const roleDAL = roleDALFactory(db);
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
const eventBusService = eventBusFactory(server.redis);
const sseService = sseServiceFactory(eventBusService, server.redis);
@@ -1882,13 +1885,6 @@ export const registerRoutes = async (
notificationService
});
const migrationService = externalMigrationServiceFactory({
externalMigrationQueue,
userDAL,
permissionService,
gatewayService
});
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
permissionService,
licenseService,
@@ -2205,6 +2201,18 @@ export const registerRoutes = async (
kmsService
});
const migrationService = externalMigrationServiceFactory({
externalMigrationQueue,
userDAL,
permissionService,
gatewayService,
kmsService,
appConnectionService,
vaultExternalMigrationConfigDAL,
secretService,
auditLogService
});
// setup the communication with license key server
await licenseService.init();

View File

@@ -7,6 +7,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import {
ExternalMigrationProviders,
VaultImportStatus,
VaultMappingType
} from "@app/services/external-migration/external-migration-types";
@@ -113,4 +114,366 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
return { enabled };
}
});
server.route({
method: "GET",
url: "/vault/configs",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
configs: z
.object({
id: z.string(),
orgId: z.string(),
namespace: z.string(),
connectionId: z.string().nullish(),
createdAt: z.date(),
updatedAt: z.date()
})
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const configs = await server.services.migration.getVaultExternalMigrationConfigs({
actor: req.permission
});
return { configs };
}
});
server.route({
method: "POST",
url: "/vault/configs",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
connectionId: z.string(),
namespace: z.string()
}),
response: {
200: z.object({
config: z.object({
id: z.string(),
orgId: z.string(),
namespace: z.string(),
connectionId: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const config = await server.services.migration.createVaultExternalMigration({
...req.body,
actor: req.permission
});
return { config };
}
});
server.route({
method: "PUT",
url: "/vault/configs/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
body: z.object({
connectionId: z.string(),
namespace: z.string()
}),
response: {
200: z.object({
config: z.object({
id: z.string(),
orgId: z.string(),
namespace: z.string(),
connectionId: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const config = await server.services.migration.updateVaultExternalMigration({
id: req.params.id,
...req.body,
actor: req.permission
});
return { config };
}
});
server.route({
method: "DELETE",
url: "/vault/configs/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: z.object({
config: z.object({
id: z.string(),
orgId: z.string(),
namespace: z.string(),
connectionId: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const config = await server.services.migration.deleteVaultExternalMigration({
id: req.params.id,
actor: req.permission
});
return { config };
}
});
server.route({
method: "GET",
url: "/vault/namespaces",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
namespaces: z.array(z.object({ id: z.string(), name: z.string() }))
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const namespaces = await server.services.migration.getVaultNamespaces({
actor: req.permission
});
return { namespaces };
}
});
server.route({
method: "GET",
url: "/vault/policies",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string()
}),
response: {
200: z.object({
policies: z.array(z.object({ name: z.string(), rules: z.string() }))
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const policies = await server.services.migration.getVaultPolicies({
actor: req.permission,
namespace: req.query.namespace
});
return { policies };
}
});
server.route({
method: "GET",
url: "/vault/mounts",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string()
}),
response: {
200: z.object({
mounts: z.array(z.object({ path: z.string(), type: z.string(), version: z.string().nullish() }))
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mounts = await server.services.migration.getVaultMounts({
actor: req.permission,
namespace: req.query.namespace
});
return { mounts };
}
});
server.route({
method: "GET",
url: "/vault/auth-mounts",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string(),
authType: z.string().optional()
}),
response: {
200: z.object({
mounts: z.array(z.object({ path: z.string(), type: z.string() }))
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mounts = await server.services.migration.getVaultAuthMounts({
actor: req.permission,
namespace: req.query.namespace,
authType: req.query.authType
});
return { mounts };
}
});
server.route({
method: "POST",
url: "/vault/import-secrets",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
projectId: z.string(),
environment: z.string(),
secretPath: z.string(),
vaultNamespace: z.string(),
vaultSecretPath: z.string()
}),
response: {
200: z.object({
status: z.nativeEnum(VaultImportStatus)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.migration.importVaultSecrets({
actor: req.permission,
auditLogInfo: req.auditLogInfo,
...req.body
});
return result;
}
});
server.route({
method: "GET",
url: "/vault/secret-paths",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string(),
mountPath: z.string()
}),
response: {
200: z.object({
secretPaths: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const secretPaths = await server.services.migration.getVaultSecretPaths({
actor: req.permission,
namespace: req.query.namespace,
mountPath: req.query.mountPath
});
return { secretPaths };
}
});
server.route({
method: "GET",
url: "/vault/auth-roles/kubernetes",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string(),
mountPath: z.string()
}),
response: {
200: z.object({
roles: z.array(
z.object({
name: z.string(),
mountPath: z.string(),
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]),
handler: async (req) => {
const roles = await server.services.migration.getVaultKubernetesAuthRoles({
actor: req.permission,
namespace: req.query.namespace,
mountPath: req.query.mountPath
});
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,8 +12,56 @@ 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 { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
import { HCVaultAuthType, HCVaultConnectionMethod } from "./hc-vault-connection-enums";
import {
THCVaultAuthMount,
THCVaultAuthMountResponse,
THCVaultConnection,
THCVaultConnectionConfig,
THCVaultKubernetesAuthConfig,
THCVaultKubernetesAuthRole,
THCVaultKubernetesAuthRoleWithConfig,
THCVaultMount,
THCVaultMountResponse
} from "./hc-vault-connection-types";
// Concurrency limit for HC Vault API requests to avoid rate limiting
const HC_VAULT_CONCURRENCY_LIMIT = 20;
/**
* Creates a concurrency limiter that restricts the number of concurrent async operations
* @param limit - Maximum number of concurrent operations
* @returns A function that takes an async function and executes it with concurrency control
*/
const createConcurrencyLimiter = (limit: number) => {
let activeCount = 0;
const queue: Array<() => void> = [];
const next = () => {
activeCount -= 1;
if (queue.length > 0) {
const resolve = queue.shift();
resolve?.();
}
};
return async <T>(fn: () => Promise<T>): Promise<T> => {
// If we're at the limit, wait in queue
if (activeCount >= limit) {
await new Promise<void>((resolve) => {
queue.push(resolve);
});
}
activeCount += 1;
try {
return await fn();
} finally {
next();
}
};
};
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
@@ -181,30 +229,573 @@ export const validateHCVaultConnectionCredentials = async (
}
};
export const listHCVaultMounts = async (
export const listHCVaultPolicies = async (
namespace: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
try {
const { data: listData } = await requestWithHCVaultGateway<{
data: {
policies: string[];
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/policy`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
});
const policyNames = listData.data.policies || [];
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
const policies = await Promise.all(
policyNames.map((policyName) =>
limiter(async () => {
try {
const { data: policyData } = await requestWithHCVaultGateway<{
data: {
name: string;
rules: string;
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/policy/${policyName}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
});
return {
name: policyData.data.name,
rules: policyData.data.rules
};
} catch (error: unknown) {
logger.error(error, `Unable to fetch policy details for ${policyName}`);
return {
name: policyName,
rules: ""
};
}
})
)
);
return policies;
} catch (error: unknown) {
logger.error(error, "Unable to list HC Vault policies");
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list policies: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list policies from HashiCorp Vault"
});
}
};
export const listHCVaultNamespaces = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const currentNamespace = connection.credentials.namespace || "/";
// Helper function to fetch namespaces at a specific path
const fetchNamespacesAtPath = async (namespacePath: string): Promise<string[] | null> => {
try {
const { data } = await requestWithHCVaultGateway<{
data: {
keys: string[];
key_info?: {
[key: string]: {
id: string;
path: string;
custom_metadata?: Record<string, unknown>;
};
};
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/namespaces?list=true`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespacePath
}
});
return data.data.keys || [];
} catch (error: unknown) {
if (error instanceof AxiosError && error.response?.status === 404) {
// No child namespaces at this path
return null;
}
throw error;
}
};
// Recursive function to get all namespaces at all depths with controlled parallelization
const recursivelyGetAllNamespaces = async (
parentPath: string,
limiter: ReturnType<typeof createConcurrencyLimiter>
): Promise<string[]> => {
const childKeys = await fetchNamespacesAtPath(parentPath);
if (childKeys === null || childKeys.length === 0) {
return [];
}
// Process namespaces in parallel with concurrency control
const namespacesArrays = await Promise.all(
childKeys.map((namespaceKey) =>
limiter(async () => {
// Remove trailing slash from the key
const cleanNamespaceKey = namespaceKey.replace(/\/$/, "");
// Build the full path
let fullNamespacePath: string;
if (parentPath === "/") {
fullNamespacePath = cleanNamespaceKey;
} else {
fullNamespacePath = `${parentPath}/${cleanNamespaceKey}`;
}
// Recursively fetch child namespaces
const childNamespaces = await recursivelyGetAllNamespaces(fullNamespacePath, limiter);
// Return this namespace and all its children
return [fullNamespacePath, ...childNamespaces];
})
)
);
// Flatten the arrays into a single array
return namespacesArrays.flat();
};
try {
// Create concurrency limiter to avoid overwhelming the Vault instance
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
// Get all namespaces starting from currentNamespace
const childNamespaces = await recursivelyGetAllNamespaces(currentNamespace, limiter);
// Build the result array with full paths
const namespaces = childNamespaces.map((path) => ({
id: path,
name: path
}));
// Always include the current/root namespace
namespaces.unshift({
id: currentNamespace,
name: currentNamespace
});
return namespaces;
} catch (error: unknown) {
logger.error(error, "Unable to list HC Vault namespaces");
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list namespaces: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list namespaces from HashiCorp Vault"
});
}
};
export const listHCVaultMounts = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
namespace?: string
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const targetNamespace = namespace || connection.credentials.namespace;
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/mounts`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
}
});
const mounts: string[] = [];
const mounts: THCVaultMount[] = [];
// Filter for "kv" version 2 type only
Object.entries(data.data).forEach(([path, mount]) => {
if (mount.type === "kv" && mount.options?.version === "2") {
mounts.push(path);
}
mounts.push({
path,
type: mount.type,
version: mount.options?.version
});
});
return mounts;
};
export const listHCVaultSecretPaths = async (
namespace: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
filterMountPath?: string
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise<string[] | null> => {
try {
let path: string;
if (kvVersion === "2") {
// For KV v2: /v1/{mount}/metadata/{path}?list=true
path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
} else {
// For KV v1: /v1/{mount}/{path}?list=true
path = secretPath ? `${mountPath}/${secretPath}` : mountPath;
}
const { data } = await requestWithHCVaultGateway<{
data: {
keys: string[];
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/${path}?list=true`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
});
return data.data.keys;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 404) {
return null;
}
throw error;
}
};
// Recursive function to get all secret paths in a mount with controlled parallelization
const recursivelyGetAllPaths = async (
mountPath: string,
kvVersion: "1" | "2",
limiter: ReturnType<typeof createConcurrencyLimiter>,
currentPath: string = ""
): Promise<string[]> => {
const paths = await getPaths(mountPath, currentPath, kvVersion);
if (paths === null || paths.length === 0) {
return [];
}
// Process paths in parallel with concurrency control
const secretPathsArrays = await Promise.all(
paths.map((path) =>
limiter(async () => {
const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path;
const fullItemPath = currentPath ? `${currentPath}/${cleanPath}` : cleanPath;
if (path.endsWith("/")) {
// it's a folder so we recurse into it
return recursivelyGetAllPaths(mountPath, kvVersion, limiter, fullItemPath);
}
// it's a secret so we return it
return [`${mountPath}/${fullItemPath}`];
})
)
);
// Flatten the arrays into a single array
return secretPathsArrays.flat();
};
// Get all mounts
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
// Filter for KV mounts (kv, kv-v1, kv-v2)
let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
// If filterMountPath is provided, filter to only that mount
if (filterMountPath) {
const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash
kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath);
}
// Create concurrency limiter to avoid overwhelming the Vault instance
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
// Collect all secret paths from all KV mounts in parallel
const allSecretPathsArrays = await Promise.all(
kvMounts.map(async (mount) => {
const kvVersion = mount.version === "2" ? "2" : "1";
const cleanMountPath = mount.path.replace(/\/$/, ""); // Remove trailing slash
return recursivelyGetAllPaths(cleanMountPath, kvVersion, limiter);
})
);
// Flatten the arrays into a single array
const allSecretPaths = allSecretPathsArrays.flat();
return allSecretPaths;
};
export const getHCVaultSecretsForPath = async (
namespace: string,
secretPath: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
try {
// Extract mount and path from the secretPath
// secretPath format: {mount}/{path}
const pathParts = secretPath.split("/");
const mountPath = pathParts[0];
const actualPath = pathParts.slice(1).join("/");
if (!mountPath || !actualPath) {
throw new BadRequestError({
message: "Invalid secret path format. Expected format: {mount}/{path}"
});
}
// Get mounts to determine KV version
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
const mount = mounts.find((m) => m.path.replace(/\/$/, "") === mountPath);
if (!mount) {
throw new BadRequestError({
message: `Mount '${mountPath}' not found in HashiCorp Vault`
});
}
const kvVersion = mount.version === "2" ? "2" : "1";
// Fetch secrets based on KV version
if (kvVersion === "2") {
// For KV v2: /v1/{mount}/data/{path}
const { data } = await requestWithHCVaultGateway<{
data: {
data: Record<string, string>; // KV v2 has nested data structure
metadata: {
created_time: string;
deletion_time: string;
destroyed: boolean;
version: number;
};
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/${mountPath}/data/${actualPath}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
});
return data.data.data;
}
// For KV v1: /v1/{mount}/{path}
const { data } = await requestWithHCVaultGateway<{
data: Record<string, string>; // KV v1 has flat data structure
lease_duration: number;
lease_id: string;
renewable: boolean;
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/${mountPath}/${actualPath}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
});
return data.data;
} catch (error: unknown) {
logger.error(error, "Unable to fetch secrets from HC Vault path");
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to fetch secrets: ${error.message || "Unknown error"}`
});
}
if (error instanceof BadRequestError) {
throw error;
}
throw new BadRequestError({
message: "Unable to fetch secrets from HashiCorp Vault"
});
}
};
export const getHCVaultAuthMounts = async (
namespace: string,
authType: HCVaultAuthType | undefined,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
): Promise<THCVaultAuthMount[]> => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
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 authType is specified, filter by it; otherwise, include all
if (!authType || authMethod.type === authType) {
authMounts.push({
path,
type: authMethod.type,
description: authMethod.description,
accessor: authMethod.accessor
});
}
});
return authMounts;
} catch (error: unknown) {
const authTypeStr = authType || "all";
logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`);
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: `Unable to list ${authTypeStr} 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);
// 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 with concurrency control
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
const roleDetailsPromises = roleNames.map((roleName) =>
limiter(async () => {
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

@@ -21,7 +21,8 @@ export const hcVaultConnectionService = (
try {
const mounts = await listHCVaultMounts(appConnection, gatewayService);
return mounts;
// Filter for KV version 2 mounts only and extract just the paths
return mounts.filter((mount) => mount.type === "kv" && mount.version === "2").map((mount) => mount.path);
} catch (error) {
logger.error(error, "Failed to establish connection with Hashicorp Vault");
return [];

View File

@@ -33,3 +33,65 @@ export type THCVaultMountResponse = {
};
};
};
export type THCVaultMount = {
path: string;
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

@@ -1,9 +1,33 @@
import { OrgMembershipRole } from "@app/db/schemas";
import {
AuditLogInfo,
EventType,
SecretApprovalEvent,
TAuditLogServiceFactory
} from "@app/ee/services/audit-log/audit-log-types";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
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,
listHCVaultPolicies,
listHCVaultSecretPaths,
THCVaultConnection
} from "../app-connection/hc-vault";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TSecretServiceFactory } from "../secret/secret-service";
import { SecretProtectionType } from "../secret/secret-types";
import { TUserDALFactory } from "../user/user-dal";
import {
decryptEnvKeyDataFn,
@@ -15,16 +39,29 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue";
import {
ExternalMigrationProviders,
ExternalPlatforms,
TCreateVaultExternalMigrationDTO,
TDeleteVaultExternalMigrationDTO,
THasCustomVaultMigrationDTO,
TImportEnvKeyDataDTO,
TImportVaultDataDTO
TImportVaultDataDTO,
TUpdateVaultExternalMigrationDTO,
VaultImportStatus
} from "./external-migration-types";
import { TVaultExternalMigrationConfigDALFactory } from "./vault-external-migration-config-dal";
type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory;
secretService: TSecretServiceFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
externalMigrationQueue: TExternalMigrationQueueFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
vaultExternalMigrationConfigDAL: Pick<
TVaultExternalMigrationConfigDALFactory,
"create" | "findOne" | "transaction" | "find" | "updateById" | "deleteById" | "findById"
>;
userDAL: Pick<TUserDALFactory, "findById">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
@@ -33,7 +70,12 @@ export const externalMigrationServiceFactory = ({
permissionService,
externalMigrationQueue,
userDAL,
gatewayService
gatewayService,
secretService,
auditLogService,
appConnectionService,
vaultExternalMigrationConfigDAL,
kmsService
}: TExternalMigrationServiceFactoryDep) => {
const importEnvKeyData = async ({
decryptionKey,
@@ -171,9 +213,554 @@ export const externalMigrationServiceFactory = ({
return actorOrgId in vaultMigrationTransformMappings;
};
const validateVaultExternalMigrationConnection = async ({
connection,
namespace
}: {
connection: THCVaultConnection;
namespace: string;
}) => {
// Allow root namespace access when no namespace is configured on the connection
const isRootAccess = namespace === "root" || namespace === "/";
const hasNoNamespace = connection.credentials.namespace === undefined;
if (hasNoNamespace && isRootAccess) {
// Skip validation for root access with no configured namespace
} else if (connection.credentials.namespace !== namespace) {
throw new BadRequestError({ message: "Namespace value does not match the namespace of the connection" });
}
try {
await listHCVaultPolicies(namespace, connection, gatewayService);
await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService);
const mounts = await listHCVaultMounts(connection, gatewayService);
const sampleKvMount = mounts.find((mount) => mount.type === "kv");
if (sampleKvMount) {
await listHCVaultSecretPaths(namespace, connection, gatewayService, sampleKvMount.path);
}
} catch (error) {
throw new BadRequestError({
message: `Failed to establish namespace confiugration. ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
const createVaultExternalMigration = async ({ namespace, connectionId, actor }: TCreateVaultExternalMigrationDTO) => {
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 configure vault external migration" });
}
const connection = await appConnectionService.connectAppConnectionById<THCVaultConnection>(
AppConnection.HCVault,
connectionId,
actor
);
await validateVaultExternalMigrationConnection({
connection,
namespace
});
try {
const config = await vaultExternalMigrationConfigDAL.create({
namespace,
connectionId,
orgId: actor.orgId
});
return config;
} catch (error) {
if (
error instanceof DatabaseError &&
(error.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation
) {
throw new BadRequestError({
message: `Vault external migration already exists for this namespace`
});
}
throw error;
}
};
const updateVaultExternalMigration = async ({
id,
namespace,
connectionId,
actor
}: TUpdateVaultExternalMigrationDTO) => {
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 update vault external migration" });
}
if (connectionId) {
const connection = await appConnectionService.connectAppConnectionById<THCVaultConnection>(
AppConnection.HCVault,
connectionId,
actor
);
await validateVaultExternalMigrationConnection({
connection,
namespace
});
}
const config = await vaultExternalMigrationConfigDAL.updateById(id, {
namespace,
connectionId
});
return config;
};
const getVaultExternalMigrationConfigs = async ({ actor }: { actor: OrgServiceActor }) => {
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 external migration configs" });
}
const configs = await vaultExternalMigrationConfigDAL.find({
orgId: actor.orgId
});
return configs;
};
const getVaultNamespaces = async ({ actor }: { actor: OrgServiceActor }) => {
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 namespaces" });
}
// Get all configured namespaces for this org
const vaultConfigs = await vaultExternalMigrationConfigDAL.find({
orgId: actor.orgId
});
// Return the configured namespaces as an array of objects with id and name
// where both id and name are the namespace path
const namespaces = vaultConfigs.map((config) => ({
id: config.namespace,
name: config.namespace
}));
return namespaces;
};
const getVaultPolicies = 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 policies" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const policies = await listHCVaultPolicies(namespace, connection, gatewayService);
return policies;
};
const getVaultMounts = 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 mounts" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
return mounts;
};
const getVaultSecretPaths = async ({
actor,
namespace,
mountPath
}: {
actor: OrgServiceActor;
namespace: string;
mountPath: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
if (!hasRole(OrgMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath);
return secretPaths;
};
const importVaultSecrets = async ({
actor,
projectId,
environment,
secretPath,
vaultNamespace,
vaultSecretPath,
auditLogInfo
}: {
actor: OrgServiceActor;
projectId: string;
environment: string;
secretPath: string;
vaultNamespace: string;
vaultSecretPath: string;
auditLogInfo: AuditLogInfo;
}) => {
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 import vault secrets" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace: vaultNamespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService);
try {
const secretOperation = await secretService.createManySecretsRaw({
actorId: actor.id,
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
secretPath,
environment,
projectId,
secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({
secretKey,
secretValue
}))
});
if (secretOperation.type === SecretProtectionType.Approval) {
await auditLogService.createAuditLog({
projectId,
...auditLogInfo,
event: {
type: EventType.SECRET_APPROVAL_REQUEST,
metadata: {
committedBy: secretOperation.approval.committerUserId,
secretApprovalRequestId: secretOperation.approval.id,
secretApprovalRequestSlug: secretOperation.approval.slug,
secretPath,
environment,
secrets: Object.entries(vaultSecrets).map(([secretKey]) => ({
secretKey
})),
eventType: SecretApprovalEvent.CreateMany
}
}
});
return { status: VaultImportStatus.ApprovalRequired };
}
return { status: VaultImportStatus.Imported };
} catch (error) {
throw new BadRequestError({
message: `Failed to import Vault secrets. ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
const deleteVaultExternalMigration = async ({ id, actor }: TDeleteVaultExternalMigrationDTO) => {
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 delete vault external migration configs" });
}
const config = await vaultExternalMigrationConfigDAL.findById(id);
if (!config) {
throw new NotFoundError({ message: "Vault migration config not found" });
}
if (config.orgId !== actor.orgId) {
throw new ForbiddenRequestError({ message: "Config does not belong to this organization" });
}
const deletedConfig = await vaultExternalMigrationConfigDAL.deleteById(id);
return deletedConfig;
};
const getVaultAuthMounts = async ({
actor,
namespace,
authType
}: {
actor: OrgServiceActor;
namespace: string;
authType?: 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 auth mounts" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService);
return authMounts;
};
const getVaultKubernetesAuthRoles = async ({
actor,
namespace,
mountPath
}: {
actor: OrgServiceActor;
namespace: string;
mountPath: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
if (!hasRole(OrgMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
// Get roles for the specified mount path only
const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService);
return roles;
};
return {
importEnvKeyData,
importVaultData,
hasCustomVaultMigration
hasCustomVaultMigration,
createVaultExternalMigration,
getVaultExternalMigrationConfigs,
updateVaultExternalMigration,
deleteVaultExternalMigration,
getVaultNamespaces,
getVaultPolicies,
getVaultMounts,
getVaultAuthMounts,
getVaultSecretPaths,
importVaultSecrets,
getVaultKubernetesAuthRoles
};
};

View File

@@ -1,4 +1,4 @@
import { TOrgPermission } from "@app/lib/types";
import { OrgServiceActor, TOrgPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
@@ -121,3 +121,26 @@ export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
export enum VaultImportStatus {
Imported = "imported",
ApprovalRequired = "approval-required"
}
export type TCreateVaultExternalMigrationDTO = {
namespace: string;
connectionId: string;
actor: OrgServiceActor;
};
export type TUpdateVaultExternalMigrationDTO = {
id: string;
namespace: string;
connectionId: string | null;
actor: OrgServiceActor;
};
export type TDeleteVaultExternalMigrationDTO = {
id: string;
actor: OrgServiceActor;
};

View File

@@ -0,0 +1,67 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
export type TVaultExternalMigrationConfigDALFactory = ReturnType<typeof vaultExternalMigrationConfigDALFactory>;
export const vaultExternalMigrationConfigDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.VaultExternalMigrationConfig);
const findOne = async (filter: { orgId: string; namespace: string }, tx?: Knex) => {
try {
const result = await (tx || db?.replicaNode?.() || db)(TableName.VaultExternalMigrationConfig)
.leftJoin(
TableName.AppConnection,
`${TableName.AppConnection}.id`,
`${TableName.VaultExternalMigrationConfig}.connectionId`
)
/* eslint-disable @typescript-eslint/no-misused-promises */
.where(buildFindFilter(prependTableNameToFindFilter(TableName.VaultExternalMigrationConfig, filter)))
.select(selectAllTableCols(TableName.VaultExternalMigrationConfig))
.select(
db.ref("id").withSchema(TableName.AppConnection).as("appConnectionId"),
db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"),
db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"),
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"),
db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"),
db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"),
db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"),
db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"),
db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"),
db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"),
db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"),
db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt")
)
.first();
if (!result) return undefined;
return {
...result,
connection: result.appConnectionId
? {
id: result.appConnectionId,
name: result.appConnectionName,
app: result.appConnectionApp,
encryptedCredentials: result.appConnectionEncryptedCredentials,
orgId: result.appConnectionOrgId,
method: result.appConnectionMethod,
description: result.appConnectionDescription,
version: result.appConnectionVersion,
gatewayId: result.appConnectionGatewayId,
projectId: result.appConnectionProjectId,
createdAt: result.appConnectionCreatedAt,
updatedAt: result.appConnectionUpdatedAt
}
: undefined
};
} catch (error) {
throw new DatabaseError({ error, name: "Find one" });
}
};
return { ...orm, findOne };
};

View File

@@ -1,16 +1,22 @@
---
title: "External Migrations"
sidebarTitle: "Overview"
description: "Learn how to migrate secrets from third-party secrets management platforms to Infisical."
description: "Learn how to migrate resources from third-party secrets management platforms to Infisical."
---
## Overview
Infisical supports migrating secrets from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing secrets from a different platform.
Infisical supports migrating resources from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing resources from a different platform.
Infisical offers two types of migration approaches:
- **In-Platform Migration Tooling**: Configure platform connections to enable granular, on-demand imports of secrets, policies, and configurations directly within the Infisical UI. This allows you to migrate resources incrementally as needed.
- **Bulk Data Import**: Perform one-time organization-level migrations to import all resources from external platforms at once. This is ideal for initial migrations when moving entirely to Infisical.
## Supported Platforms
- [EnvKey](./envkey)
- [Vault](./vault)
We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues).
We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues).

View File

@@ -1,40 +1,239 @@
---
title: "Migrating from Vault to Infisical"
sidebarTitle: "Vault"
description: "Learn how to migrate secrets from Vault to Infisical."
description: "Learn how to migrate resources from Vault to Infisical."
---
## Migrating from Vault
Infisical provides two approaches for migrating from HashiCorp Vault.
Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance.
### Which approach should I use?
Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues).
**Choose In-Platform Migration Tooling if you want to:**
- Migrate specific secrets, not everything at once
- Import secrets into existing Infisical projects
- Translate Vault policies to Infisical access controls
- Import Kubernetes authentication configurations
- Have more control over the migration process
### Prerequisites
**Choose Bulk Data Import if you want to:**
- A Vault instance with the KV secret engine enabled.
- An access token to your Vault instance.
### Project Mapping
When migrating from Vault, you'll need to choose how you want to map your Vault resources to Infisical projects.
There are two options for project mapping:
- `Namespace`: This will map your selected Vault namespace to a single Infisical project. When you select this option, each KV secret engine within the namespace will be mapped to a single Infisical project. Each KV secret engine will be mapped to a Infisical environment within the project. This means if you have 3 KV secret engines, you'll have 3 environments inside the same project, where the name of the environments correspond to the name of the KV secret engines.
- `Key Vault`: This will map all the KV secret engines within your Vault instance to a Infisical project. Each KV engine will be created as a Infisical project. This means if you have 3 KV secret engines, you'll have 3 Infisical projects. For each of the created projects, a single default environment will be created called `Production`, which will contain all your secrets from the corresponding KV secret engine.
- Migrate all secrets from Vault in one go
- Automatically create new Infisical projects from your Vault structure
- Perform a one-time migration when moving entirely from Vault to Infisical
## In-Platform Migration Tooling
This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical.
### Step 1: Set Up Your Vault Connection
<Steps>
<Step title="Create a Vault policy">
In order to migrate from Vault, you'll need to create a Vault policy that allows Infisical to read the secrets and metadata from the KV v2 secrets engines within your Vault instance.
In your Vault instance, create a policy that allows Infisical to read your secrets, policies, and authentication configurations. This policy grants read-only access and doesn't allow Infisical to modify anything in Vault.
<Accordion title="View the complete policy">
```hcl
# System endpoints - for listing namespaces, policies, mounts, and auth methods
path "sys/namespaces" {
capabilities = ["list"]
}
```python
path "sys/policy" {
capabilities = ["read", "list"]
}
path "sys/policy/*" {
capabilities = ["read"]
}
path "sys/mounts" {
capabilities = ["read"]
}
path "sys/auth" {
capabilities = ["read"]
}
# KV v2 secrets - for listing and reading secrets
# Replace '+' with your actual KV v2 mount paths (e.g., "secret", "kv")
path "+/metadata/*" {
capabilities = ["list", "read"]
}
path "+/data/*" {
capabilities = ["read"]
}
# KV v1 secrets - for listing and reading secrets
# Replace '+' with your actual KV v1 mount paths (e.g., "secret", "kv-v1")
# WARNING: This is broad - ideally specify exact mount names
path "+/*" {
capabilities = ["list", "read"]
}
# Kubernetes auth - for reading auth configuration and roles
path "auth/+/config" {
capabilities = ["read"]
}
path "auth/+/role" {
capabilities = ["list"]
}
path "auth/+/role/*" {
capabilities = ["read"]
}
```
</Accordion>
Save this policy in Vault with the name `infisical-in-platform-migration`.
</Step>
<Step title="Create an App Connection in Infisical">
In Infisical, navigate to **Organization Settings > App Connections** and create a new HashiCorp Vault connection.
Follow the [HashiCorp Vault App Connection documentation](/integrations/app-connections/hashicorp-vault) for detailed setup instructions. When configuring authentication (Token or AppRole), make sure it uses the `infisical-in-platform-migration` policy you created.
</Step>
<Step title="Add Vault Namespaces for Migration">
Navigate to **Organization Settings > External Migrations** in Infisical.
Under the "In-Platform Migration Tooling" section for HashiCorp Vault, click **"+ Add Namespace"**.
![In-Platform Migration Tooling](/images/platform/external-migrations/vault-in-platform/external-migration-overview.png)
Configure your namespace:
![Namespace Configuration](/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png)
- **Namespace**: Enter your Vault namespace path (e.g., `admin/namespace1`). If you intend to use the root namespace, set the namespace value to "root".
- **Connection**: Select the App Connection you created in the previous step.
<Note>
You can add multiple namespaces with different connections if you have multiple Vault instances or namespaces to migrate from.
</Note>
</Step>
</Steps>
### Step 2: Import Your Resources
Once your Vault connection is configured, you'll see import options throughout Infisical wherever relevant. Here's what you can import:
#### Import Secrets into a Project
You can import secrets from Vault directly into a specific environment and secret path:
1. Navigate to your project and select a specific environment (e.g., Development, Production)
2. In the secrets view, click the dropdown icon (caret) next to the **"+ Add Secret"** button
3. Select **"Add from HashiCorp Vault"**
![Import Vault Secrets](/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png)
4. Choose your Vault namespace and the secret path you want to import
5. Click **"Import Secrets"**
The secrets will be imported into your current environment and folder path.
#### Import Kubernetes Authentication Configurations
When setting up Kubernetes authentication for a machine identity, you can import the configuration from Vault:
1. Navigate to **Access Control > Machine Identities** and select an identity
2. Click **"Add Authentication Method"** and choose **Kubernetes Auth**
3. In the configuration modal, click **"Load from Vault"**
![Load Kubernetes Auth from Vault](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png)
4. Select your Vault namespace and the Kubernetes role
5. Click **"Load"**
![Kubernetes Auth Form Populated](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png)
The authentication settings (service accounts, TTL, policies, etc.) will be automatically populated from your Vault configuration.
<Note>
Sensitive values like service account JWTs cannot be retrieved from Vault and
must be manually provided in the form after importing the configuration.
</Note>
#### Import and Translate Access Control Policies
When configuring project role-based access control, you can import Vault HCL policies and automatically translate them to Infisical permissions.
<Note>
Policy translation is best-effort and provides a starting point based on your
Vault configuration. The translated permissions should be reviewed and
adjusted as needed since Vault and Infisical have different access control
models. Infisical will analyze path patterns and capabilities to suggest
equivalent permissions.
</Note>
**To import and translate a policy:**
1. Navigate to your project, then go to **Access Control > Roles** and create or edit a role
2. In the policy configuration, click **"Add from HashiCorp Vault"**
![Import Vault Policy Button](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png)
3. Select your Vault namespace
4. Either choose an existing policy from the dropdown or paste your own HCL policy
![Translate Vault Policy Modal](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png)
5. Review the automatically translated Infisical permissions
6. Make any adjustments and save
<Tip>
**How policy translation works:**
- Vault path patterns are analyzed to identify KV secret engines and environments
- Vault capabilities (`read`,`list`, `create`, etc.) are mapped to Infisical permissions
- Wildcards in paths are converted to glob patterns
- Secret paths are preserved for granular access control
Always review the translated permissions carefully, as Vault's capability-based model may not map 1:1 with Infisical's permission structure.
</Tip>
---
## Bulk Data Import
This migration approach imports all secrets from your Vault instance in one operation and automatically creates new Infisical projects based on your Vault structure.
### Understanding Project Mapping
Before starting the bulk import, you need to decide how your Vault structure will map to Infisical projects:
<Accordion title="Namespace Mapping (One Project Per Namespace)">
Each Vault namespace becomes a single Infisical project, with each KV secret engine becoming an environment within that project.
**Example:** If you have a namespace with 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`):
- Creates: 1 Infisical project
- Environments: 3 (`dev-secrets`, `staging-secrets`, `prod-secrets`)
</Accordion>
<Accordion title="Key Vault Mapping (One Project Per KV Engine)">
Each KV secret engine becomes its own Infisical project with a single `Production` environment.
**Example:** If you have 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`):
- Creates: 3 Infisical projects (`dev-secrets`, `staging-secrets`, `prod-secrets`)
- Each project has: 1 environment (`Production`)
</Accordion>
### How to Perform a Bulk Import
<Steps>
<Step title="Create a Vault policy for bulk import">
In your Vault instance, create a policy that allows Infisical to read all secrets and metadata. This policy grants read-only access.
<Accordion title="View the bulk import policy">
```hcl
# Allow listing secret engines/mounts
path "sys/mounts" {
capabilities = ["read", "list"]
@@ -63,65 +262,54 @@ There are two options for project mapping:
capabilities = ["read", "list"]
}
```
</Accordion>
Save this policy with the name `infisical-migration`.
Save this policy in Vault with the name `infisical-bulk-migration`.
</Step>
<Step title="Generate an access token">
You can use the Vault CLI to easily generate an access token for the new `infisical-migration` policy that you created in the previous step.
Use the Vault CLI to generate an access token:
```bash
vault token create --policy="infisical-migration"
vault token create --policy="infisical-bulk-migration"
```
After generating the token, you should see the following output:
Copy the `token` value from the output - you'll need it in the next step.
```t
$ vault token create --policy="infisical-migration"
Key Value
--- -----
token <your-access-token>
token_accessor p6kJDiBSzYYdabJUIpGCsCBm
token_duration 768h
token_renewable true
token_policies ["default" "infisical-migration"]
identity_policies []
policies ["default" "infisical-migration"]
```
Copy the `token` field and save it for later, as you'll need this when configuring the migration to Infisical.
</Step>
<Step title="Navigate to Infisical external migrations">
Open the Infisical dashboard and go to Organization Settings > External Migrations.
<Step title="Start the import in Infisical">
In Infisical, navigate to **Organization Settings > External Migrations**.
![Infisical Organization settings](/images/platform/external-migrations/infisical-external-migration-dashboard.png)
Under the "Bulk Data Import" section, click **"+ Import"**.
</Step>
<Step title="Select the Vault platform">
Select the Vault platform and click on Next.
<Step title="Select Vault as the source">
Select **HashiCorp Vault** as the migration source and click **Next**.
![Select Vault platform](/images/platform/external-migrations/infisical-import-vault-modal.png)
</Step>
<Step title="Configure the Vault migration">
Enter the Vault access token that you generated in the previous step and click Import data.
<Step title="Configure and start the migration">
Fill in your Vault connection details:
![Configure Vault migration](/images/platform/external-migrations/infisical-import-vault.png)
- `Vault URL`: The URL of your Vault instance.
- `Vault Namespace`: The namespace of your Vault instance. This is optional, and can be left blank if you're not using namespaces for your Vault instance.
- `Vault Access Token`: The access token that you generated in the previous step.
- **Vault URL**: Your Vault instance URL (e.g., `https://vault.example.com`)
- **Vault Namespace**: Optional - only needed if using Vault Enterprise namespaces
- **Vault Access Token**: The token you generated in step 2
- **Project Mapping**: Choose how to structure your Infisical projects (see [Understanding Project Mapping](#understanding-project-mapping))
- `Project Mapping`: Choose how you want to map your Vault resources to Infisical projects. You can review the mapping options in the [Project Mapping](#project-mapping) section.
Click **"Import Data"** to start the migration.
Click on Import data to start the migration.
<Note>
The import runs in the background and may take several minutes. You'll receive an email when it completes.
</Note>
</Step>
</Steps>
<Note>
It may take several minutes to complete the migration. You will receive an email when the migration is complete, or if there were any errors during the migration process.
</Note>

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

View File

@@ -1,2 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";

View File

@@ -1,8 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { projectKeys } from "../projects";
import { externalMigrationQueryKeys } from "./queries";
import { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types";
export const useImportEnvKey = () => {
const queryClient = useQueryClient();
@@ -65,3 +69,97 @@ export const useImportVault = () => {
}
});
};
export const useImportVaultSecrets = () => {
const queryClient = useQueryClient();
return useMutation<{ status: VaultImportStatus }, object, TImportVaultSecretsDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post<{ status: VaultImportStatus }>(
"/api/v3/external-migration/vault/import-secrets",
dto
);
return data;
},
onSuccess: (_, { projectId, environment, secretPath }) => {
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
queryClient.invalidateQueries({
queryKey: secretKeys.getProjectSecret({
projectId,
environment,
secretPath
})
});
}
});
};
export const useCreateVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<
TVaultExternalMigrationConfig,
Error,
{ connectionId: string; namespace: string }
>({
mutationFn: async ({ connectionId, namespace }) => {
const { data } = await apiRequest.post<{ config: TVaultExternalMigrationConfig }>(
"/api/v3/external-migration/vault/configs",
{
connectionId,
namespace
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
export const useUpdateVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<
TVaultExternalMigrationConfig,
Error,
{ id: string; connectionId: string; namespace: string }
>({
mutationFn: async ({ id, connectionId, namespace }) => {
const { data } = await apiRequest.put<{ config: TVaultExternalMigrationConfig }>(
`/api/v3/external-migration/vault/configs/${id}`,
{
connectionId,
namespace
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
export const useDeleteVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<TVaultExternalMigrationConfig, Error, { id: string }>({
mutationFn: async ({ id }) => {
const { data } = await apiRequest.delete<{ config: TVaultExternalMigrationConfig }>(
`/api/v3/external-migration/vault/configs/${id}`
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};

View File

@@ -2,12 +2,35 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { ExternalMigrationProviders } from "./types";
import {
ExternalMigrationProviders,
TVaultExternalMigrationConfig,
VaultKubernetesAuthRole
} from "./types";
const externalMigrationQueryKeys = {
export const externalMigrationQueryKeys = {
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
"custom-migration-available",
provider
],
vaultConfigs: () => ["vault-external-migration-configs"],
vaultNamespaces: () => ["vault-namespaces"],
vaultPolicies: (namespace?: string) => ["vault-policies", namespace],
vaultMounts: (namespace?: string) => ["vault-mounts", namespace],
vaultAuthMounts: (namespace?: string, authType?: string) => [
"vault-auth-mounts",
namespace,
authType
],
vaultSecretPaths: (namespace?: string, mountPath?: string) => [
"vault-secret-paths",
namespace,
mountPath
],
vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [
"vault-kubernetes-auth-roles",
namespace,
mountPath
]
};
@@ -20,3 +43,132 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid
)
});
};
export const useGetVaultExternalMigrationConfigs = () => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultConfigs(),
queryFn: async () => {
const { data } = await apiRequest.get<{ configs: TVaultExternalMigrationConfig[] }>(
"/api/v3/external-migration/vault/configs"
);
return data.configs;
}
});
};
export const useGetVaultNamespaces = () => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultNamespaces(),
queryFn: async () => {
const { data } = await apiRequest.get<{
namespaces: Array<{ id: string; name: string }>;
}>("/api/v3/external-migration/vault/namespaces");
return data.namespaces;
}
});
};
export const useGetVaultPolicies = (enabled = true, namespace?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultPolicies(namespace),
queryFn: async () => {
const { data } = await apiRequest.get<{
policies: Array<{ name: string; rules: string }>;
}>("/api/v3/external-migration/vault/policies", {
params: {
namespace
}
});
return data.policies;
},
enabled
});
};
export const useGetVaultMounts = (enabled = true, namespace?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultMounts(namespace),
queryFn: async () => {
const { data } = await apiRequest.get<{
mounts: Array<{ path: string; type: string; version: string | null }>;
}>("/api/v3/external-migration/vault/mounts", {
params: {
namespace
}
});
return data.mounts;
},
enabled
});
};
export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath),
queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{
secretPaths: string[];
}>("/api/v3/external-migration/vault/secret-paths", {
params: {
namespace,
mountPath
}
});
return data.secretPaths;
},
enabled: enabled && !!namespace && !!mountPath
});
};
export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType),
queryFn: async () => {
const { data } = await apiRequest.get<{
mounts: Array<{ path: string; type: string }>;
}>("/api/v3/external-migration/vault/auth-mounts", {
params: {
namespace,
...(authType && { authType })
}
});
return data.mounts;
},
enabled
});
};
export const useGetVaultKubernetesAuthRoles = (
enabled = true,
namespace?: string,
mountPath?: string
) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath),
queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{
roles: VaultKubernetesAuthRole[];
}>("/api/v3/external-migration/vault/auth-roles/kubernetes", {
params: {
namespace,
mountPath
}
});
return data.roles;
},
enabled: enabled && !!namespace && !!mountPath
});
};

View File

@@ -2,3 +2,50 @@ export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
export enum VaultImportStatus {
Imported = "imported",
ApprovalRequired = "approval-required"
}
export type TVaultExternalMigrationConfig = {
id: string;
orgId: string;
namespace: string;
connectionId: string | null;
createdAt: string;
updatedAt: string;
};
export type TImportVaultSecretsDTO = {
projectId: string;
environment: string;
secretPath: string;
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,12 @@ import {
IdentityKubernetesAuthTokenReviewMode,
IdentityTrustedIp
} from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration/queries";
import { 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 +124,12 @@ export const IdentityKubernetesAuthForm = ({
enabled: isUpdate
});
const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([
"importFromVault"
] as const);
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const {
control,
handleSubmit,
@@ -192,6 +201,99 @@ export const IdentityKubernetesAuthForm = ({
}
}, [data]);
const handleImportFromVault = (role: VaultKubernetesAuthRole) => {
try {
setValue("kubernetesHost", role.config.kubernetes_host, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: 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: "info",
text: `Successfully prefilled values from Kubernetes auth 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 +403,28 @@ export const IdentityKubernetesAuthForm = ({
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
{hasVaultConnection && !isUpdate && (
<div className="mb-4 flex items-center justify-between rounded-md border border-primary/30 bg-primary/10 p-3">
<div className="flex items-start gap-2 text-sm">
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<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
@@ -407,6 +531,7 @@ export const IdentityKubernetesAuthForm = ({
placeholder="https://my-example-k8s-api-host.com"
type="text"
value={field.value || ""}
autoComplete="off"
/>
</FormControl>
)}
@@ -425,7 +550,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 +566,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 +586,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 +762,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,200 @@
import { useEffect, useState } from "react";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import {
useGetVaultAuthMounts,
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 | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null);
const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: authMounts, isLoading: isLoadingMounts } = useGetVaultAuthMounts(
shouldFetchMounts,
selectedNamespace ?? undefined,
"kubernetes"
);
const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles(
shouldFetchRoles,
selectedNamespace ?? undefined,
selectedMountPath ?? undefined
);
// Enable fetching mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchMounts(true);
}
}, [selectedNamespace]);
// Enable fetching roles when both namespace and mount path are selected
useEffect(() => {
if (selectedNamespace && selectedMountPath) {
setShouldFetchRoles(true);
} else {
setShouldFetchRoles(false);
}
}, [selectedNamespace, selectedMountPath]);
const 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);
setSelectedMountPath(null);
setSelectedRole(null);
}
}}
options={namespaces || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
isDisabled={isLoadingNamespaces}
placeholder="Select namespace..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available auth mounts
</p>
</>
</FormControl>
<FormControl
label="Auth Engine"
className="mb-4"
tooltipText="Select the Kubernetes auth engine to narrow down available roles."
>
<>
<FilterableSelect
value={
selectedMountPath
? authMounts?.find((mount) => mount.path === selectedMountPath)
: null
}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const mount = value as { path: string; type: string };
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
setSelectedRole(null);
} else {
setSelectedMountPath(null);
}
}}
options={authMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !authMounts?.length}
placeholder="Select auth engine..."
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a Kubernetes auth engine to filter available 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}
isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath}
placeholder={
!selectedMountPath
? "Select an auth engine first..."
: "Select a Kubernetes role to load..."
}
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
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
bodyClassName="overflow-visible"
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>
);
};

View File

@@ -7,6 +7,7 @@ import { OrgMembershipRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
import { VaultConnectionSection } from "./components/VaultConnectionSection";
export const ExternalMigrationsTab = () => {
const { hasOrgRole } = useOrgPermission();
@@ -14,45 +15,70 @@ export const ExternalMigrationsTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<div className="flex items-center gap-2">
<p className="text-xl font-medium text-mineshaft-100">Import from external source</p>
<div className="flex flex-col gap-6">
{/* In-Platform Migration Tooling Section */}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<h2 className="text-xl font-medium text-mineshaft-100">In-Platform Migration Tooling</h2>
<p className="mt-1 mb-6 text-sm text-gray-400">
Configure platform connections to enable migration features throughout Infisical, such
as importing policies and resources directly within the UI.
</p>
</div>
<VaultConnectionSection />
</div>
<div>
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
>
<div className="ml-2 inline-block rounded-md bg-yellow/20 px-1.5 pt-[0.04rem] pb-[0.03rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="text-xxs mb-[0.07rem] ml-1.5"
/>
</div>
</a>
</div>
{/* Bulk Data Import Section */}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<h2 className="text-xl font-medium text-mineshaft-100">Bulk Data Import</h2>
<p className="mt-1 mb-6 text-sm text-gray-400">
Perform one-time bulk imports of data from external platforms.
</p>
</div>
<Button
onClick={() => {
handlePopUpOpen("selectImportPlatform");
}}
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Import
</Button>
</div>
<p className="mb-4 text-gray-400">Import data from another platform to Infisical.</p>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="text-base font-medium text-mineshaft-100">
Import from external source
</p>
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 pt-[0.04rem] pb-[0.03rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="text-xxs mb-[0.07rem] ml-1.5"
/>
</div>
</a>
</div>
<p className="mt-1 text-sm text-gray-400">
Import data from another platform to Infisical.
</p>
</div>
<SelectImportFromPlatformModal
isOpen={popUp.selectImportPlatform.isOpen}
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
/>
<Button
onClick={() => {
handlePopUpOpen("selectImportPlatform");
}}
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Import
</Button>
</div>
<SelectImportFromPlatformModal
isOpen={popUp.selectImportPlatform.isOpen}
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,197 @@
import { useState } from "react";
import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
import {
useDeleteVaultExternalMigrationConfig,
useGetVaultExternalMigrationConfigs
} from "@app/hooks/api/migration";
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
import { VaultNamespaceConfigModal } from "./VaultNamespaceConfigModal";
export const VaultConnectionSection = () => {
const [selectedConfig, setSelectedConfig] = useState<TVaultExternalMigrationConfig | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [configToDelete, setConfigToDelete] = useState<TVaultExternalMigrationConfig | null>(null);
const { data: configs = [], isPending: isLoadingConfigs } = useGetVaultExternalMigrationConfigs();
const { data: appConnections = [] } = useListAppConnections();
const { mutateAsync: deleteConfig } = useDeleteVaultExternalMigrationConfig();
const handleEdit = (config: TVaultExternalMigrationConfig) => {
setSelectedConfig(config);
setIsModalOpen(true);
};
const handleAdd = () => {
setSelectedConfig(null);
setIsModalOpen(true);
};
const handleDeleteClick = (config: TVaultExternalMigrationConfig) => {
setConfigToDelete(config);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id });
createNotification({
type: "success",
text: "Namespace configuration deleted successfully"
});
setIsDeleteModalOpen(false);
setConfigToDelete(null);
} catch (error) {
console.error("Failed to delete namespace config:", error);
createNotification({
type: "error",
text: "Failed to delete namespace configuration"
});
}
};
const getConnectionName = (connectionId: string | null) => {
if (!connectionId) return "None";
const connection = appConnections.find((conn) => conn.id === connectionId);
return connection?.name || "Unknown";
};
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault logo"
className="h-10 w-10 rounded-md bg-bunker-500 p-2"
/>
<div>
<h3 className="text-lg font-medium text-mineshaft-100">HashiCorp Vault</h3>
<p className="text-sm text-gray-400">
Enable in-platform migration tooling for policy imports, auth methods, and secret
engine migrations
</p>
</div>
</div>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleAdd}
>
Add Namespace
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Namespace</Th>
<Th>Connection</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoadingConfigs && (
<TableSkeleton columns={3} innerKey="vault-configs-loading" rows={3} />
)}
{!isLoadingConfigs && configs.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No namespace configurations" icon={faPlus} className="py-8">
<p className="mb-4 text-sm text-mineshaft-400">
Add a namespace configuration to enable in-platform migration features.
</p>
</EmptyState>
</Td>
</Tr>
)}
{!isLoadingConfigs &&
configs.map((config) => (
<Tr key={config.id} className="group h-10">
<Td>{config.namespace}</Td>
<Td>{getConnectionName(config.connectionId)}</Td>
<Td>
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="plain"
colorSchema="secondary"
size="xs"
onClick={() => handleEdit(config)}
leftIcon={<FontAwesomeIcon icon={faEdit} />}
>
Edit
</Button>
<Button
variant="plain"
colorSchema="danger"
size="xs"
onClick={() => handleDeleteClick(config)}
leftIcon={<FontAwesomeIcon icon={faTrash} />}
>
Delete
</Button>
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
<p className="mt-4 text-xs text-mineshaft-400">
Configure namespace-specific connections to enable in-platform migration features. Manage
connections in the{" "}
<Link
to="/organization/app-connections"
className="text-primary underline hover:text-primary-300"
>
App Connections
</Link>{" "}
section.
</p>
<VaultNamespaceConfigModal
isOpen={isModalOpen}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) setSelectedConfig(null);
}}
editConfig={selectedConfig || undefined}
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete namespace configuration for "${configToDelete?.namespace}"?`}
onChange={(open) => {
setIsDeleteModalOpen(open);
if (!open) setConfigToDelete(null);
}}
deleteKey="confirm"
onDeleteApproved={handleDeleteConfirm}
/>
</div>
);
};

View File

@@ -0,0 +1,189 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
import {
useCreateVaultExternalMigrationConfig,
useUpdateVaultExternalMigrationConfig
} from "@app/hooks/api/migration";
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
const schema = z.object({
namespace: z
.string()
.min(1, "Namespace is required. If you intend to use the root namespace, use root or /."),
connectionId: z.string().min(1, "Connection is required")
});
type FormData = z.infer<typeof schema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
editConfig?: TVaultExternalMigrationConfig;
};
export const VaultNamespaceConfigModal = ({ isOpen, onOpenChange, editConfig }: Props) => {
const isEdit = Boolean(editConfig);
const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections();
const vaultConnections = useMemo(
() => appConnections.filter((conn) => conn.app === AppConnection.HCVault),
[appConnections]
);
const { mutateAsync: createConfig, isPending: isCreating } =
useCreateVaultExternalMigrationConfig();
const { mutateAsync: updateConfig, isPending: isUpdating } =
useUpdateVaultExternalMigrationConfig();
const {
control,
handleSubmit,
reset,
formState: { errors, isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
namespace: "",
connectionId: ""
}
});
// Reset form when editConfig changes or modal opens
useEffect(() => {
if (isOpen) {
reset({
namespace: editConfig?.namespace || "",
connectionId: editConfig?.connectionId || ""
});
}
}, [isOpen, editConfig, reset]);
const onFormSubmit = async (data: FormData) => {
try {
if (isEdit && editConfig) {
await updateConfig({
id: editConfig.id,
namespace: data.namespace,
connectionId: data.connectionId
});
createNotification({
type: "success",
text: "Namespace configuration updated successfully"
});
} else {
await createConfig({
namespace: data.namespace,
connectionId: data.connectionId
});
createNotification({
type: "success",
text: "Namespace configuration created successfully"
});
}
reset();
onOpenChange(false);
} catch (error) {
console.error("Failed to save namespace config:", error);
createNotification({
type: "error",
text: `Failed to ${isEdit ? "update" : "create"} namespace configuration`
});
}
};
const handleClose = () => {
reset();
onOpenChange(false);
};
return (
<Modal isOpen={isOpen} onOpenChange={handleClose}>
<ModalContent
title={isEdit ? "Edit Namespace Configuration" : "Add Namespace Configuration"}
subTitle={`Configure a HashiCorp Vault namespace ${isEdit ? "configuration" : "for migration tooling"}`}
bodyClassName="overflow-visible"
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="namespace"
render={({ field }) => (
<FormControl
label="Namespace"
isError={Boolean(errors.namespace)}
errorText={errors.namespace?.message}
className="mb-4"
>
<Input {...field} placeholder="e.g., admin, dev, prod" autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="connectionId"
render={({ field }) => {
const selectedConnection = vaultConnections.find((conn) => conn.id === field.value);
return (
<FormControl
label="Vault Connection"
isError={Boolean(errors.connectionId)}
errorText={errors.connectionId?.message}
tooltipText="Select a HashiCorp Vault app connection for this namespace"
>
<FilterableSelect
value={selectedConnection || null}
onChange={(newValue) => {
const singleValue = Array.isArray(newValue) ? newValue[0] : newValue;
if (singleValue && "id" in singleValue) {
field.onChange(singleValue.id);
} else {
field.onChange("");
}
}}
isLoading={isLoadingConnections}
options={vaultConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center gap-2">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting || isCreating || isUpdating}
isDisabled={isSubmitting || isCreating || isUpdating}
>
{isEdit ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain" onClick={handleClose}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -6,12 +6,17 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
IconButton
IconButton,
Tooltip
} from "@app/components/v2";
import { useOrgPermission } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal";
type Props = {
isDisabled?: boolean;
@@ -22,9 +27,16 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addPolicy",
"addPolicyOptions",
"applyTemplate"
"applyTemplate",
"importFromVault"
] as const);
const { hasOrgRole } = useOrgPermission();
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin);
const isVaultImportDisabled = isDisabled || !isOrgAdmin;
return (
<div>
<Button
@@ -64,6 +76,35 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
>
Add From Template
</Button>
{hasVaultConnection && (
<Tooltip
content={
!isOrgAdmin
? "Only organization admins can import policies from HashiCorp Vault"
: undefined
}
>
<Button
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
}
onClick={() => {
handlePopUpOpen("importFromVault");
handlePopUpClose("addPolicyOptions");
}}
isDisabled={isVaultImportDisabled}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add from HashiCorp Vault
</Button>
</Tooltip>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -77,6 +118,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
isOpen={popUp.applyTemplate.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
/>
<VaultPolicyImportModal
isOpen={popUp.importFromVault.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
/>
</div>
);
};

View File

@@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent,
TextArea
} from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import {
useGetVaultMounts,
useGetVaultNamespaces,
useGetVaultPolicies
} from "@app/hooks/api/migration/queries";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
import { parseVaultPolicyToInfisical } from "./VaultPolicyImportModal.utils";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onClose: () => void;
};
const Content = ({ onClose }: ContentProps) => {
const rootForm = useFormContext<TFormSchema>();
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedPolicy, setSelectedPolicy] = useState<string | null>(null);
const [hclPolicy, setHclPolicy] = useState<string>("");
const [shouldFetchPolicies, setShouldFetchPolicies] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: policies, isLoading: isLoadingPolicies } = useGetVaultPolicies(
shouldFetchPolicies,
selectedNamespace ?? undefined
);
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
shouldFetchMounts,
selectedNamespace ?? undefined
);
// Enable fetching policies and mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchPolicies(true);
setShouldFetchMounts(true);
}
}, [selectedNamespace]);
// Auto-populate HCL when a policy is selected
useEffect(() => {
if (selectedPolicy && policies) {
const policy = policies.find((p) => p.name === selectedPolicy);
if (policy) {
setHclPolicy(policy.rules);
}
}
}, [selectedPolicy, policies]);
const handleTranslateAndApply = () => {
if (!hclPolicy.trim()) {
createNotification({ type: "error", text: "Please provide a Vault HCL policy" });
return;
}
if (!mounts || mounts.length === 0) {
createNotification({
type: "error",
text: "No Vault mounts found. Please ensure you have KV secret engines configured."
});
return;
}
try {
const parsedPermissions = parseVaultPolicyToInfisical(hclPolicy, mounts);
if (!parsedPermissions || Object.keys(parsedPermissions).length === 0) {
createNotification({
type: "warning",
text: "No translatable permissions found in the policy. Ensure the policy contains KV secret paths (e.g., secret/data/*, secret/metadata/*)."
});
return;
}
// Apply the parsed permissions to the form
(Object.keys(parsedPermissions) as ProjectPermissionSub[]).forEach((subjectKey) => {
const value = parsedPermissions[subjectKey];
if (!value) return;
const existingValue = rootForm.getValues(`permissions.${subjectKey}`) as unknown[];
if (Array.isArray(existingValue) && existingValue.length > 0) {
// Merge with existing permissions
rootForm.setValue(`permissions.${subjectKey}`, [...existingValue, ...value] as never, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
} else {
rootForm.setValue(`permissions.${subjectKey}`, value as never, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
}
});
createNotification({
type: "info",
text: "Vault policy translated and prefilled"
});
onClose();
} catch (err) {
console.error("Translation error:", err);
createNotification({
type: "error",
text: "Failed to translate policy. Please check the HCL format."
});
}
};
return (
<>
<div className="mb-4 rounded-md bg-primary/10 p-3 text-sm text-mineshaft-200">
<div className="flex items-start gap-2">
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<div>
<div className="mb-2">
<strong>How Policy Translation Works</strong>
</div>
<div className="space-y-1.5 text-xs leading-relaxed">
<p>
Policies are translated by identifying KV secret engine mounts and parsing path
structures to extract environments and secret paths.
</p>
<p>
<strong>Key assumptions:</strong> The first path segment after the mount is treated
as the environment (e.g., <code className="text-xs">secret/data/prod/app</code>
env: <code className="text-xs">prod</code>, path:{" "}
<code className="text-xs">/app</code>). Vault capabilities and wildcards are
automatically mapped to equivalent Infisical permissions and glob patterns.
</p>
</div>
</div>
</div>
</div>
<FormControl
label="Namespace"
className="mb-4"
tooltipText="Required to fetch mount information. Policies will be translated using your Vault's KV secret engine mounts to extract environments and secret paths."
>
<>
<FilterableSelect
value={namespaces?.find((ns) => ns.id === selectedNamespace)}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const namespace = value as { id: string; name: string };
setSelectedNamespace(namespace.name);
setSelectedPolicy(null);
}
}}
options={namespaces || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
isDisabled={isLoadingNamespaces}
placeholder="Select namespace..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch policies and mount information
</p>
</>
</FormControl>
<FormControl label="Select Vault Policy (Optional)" className="mb-4">
<>
<FilterableSelect
value={selectedPolicy ? policies?.find((p) => p.name === selectedPolicy) : null}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const policy = value as { name: string; rules: string };
setSelectedPolicy(policy.name);
} else {
setSelectedPolicy(null);
}
}}
options={policies || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => option.name}
isDisabled={isLoadingPolicies}
placeholder="Choose a policy to import..."
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select a policy to auto-populate the HCL editor below, or skip to paste your own
</p>
</>
</FormControl>
<FormControl label="Vault HCL Policy" className="mb-6">
<>
<TextArea
value={hclPolicy}
onChange={(e) => setHclPolicy(e.target.value)}
placeholder={`path "secret/data/prod/app/*" {
capabilities = ["create", "read", "update", "delete"]
}
path "secret/metadata/prod/*" {
capabilities = ["list"]
}`}
rows={12}
className="font-mono text-sm"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Paste your HCL policy here or select one from the dropdown above. The translator will
extract environments and paths automatically.
</p>
</>
</FormControl>
<div className="mt-8 flex space-x-4">
<Button
onClick={handleTranslateAndApply}
isDisabled={!hclPolicy.trim() || isLoadingMounts || !mounts}
>
Translate & Apply
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const VaultPolicyImportModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Import from HashiCorp Vault"
subTitle="Select a policy from your Vault namespace or paste your own HCL policy to translate it into Infisical permissions."
className="max-w-3xl"
>
<Content onClose={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,449 @@
import {
PermissionConditionOperators,
ProjectPermissionActions,
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
// ============================================================================
// Types
// ============================================================================
export type VaultMount = {
path: string;
type: string;
version: string | null;
};
type ArrayElement<T> = T extends (infer U)[] ? U : never;
export type SecretPermissionRule = ArrayElement<
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.Secrets]
>;
export type FolderPermissionRule = ArrayElement<
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.SecretFolders]
>;
type ParsedVaultPath = {
environment: string | null;
secretPath: string | null;
mount: VaultMount | null;
isWildcardMount: boolean;
};
// ============================================================================
// Path Parsing
// ============================================================================
/**
* Parses a Vault policy path to extract mount, environment, and secret path.
*
* Handles three types of path patterns:
* 1. Global wildcards: "*" or "+" → matches all mounts, environments, paths
* 2. Wildcard mounts: "* /data/prod/*" → matches all mounts with specific path
* 3. Regular paths: "secret/data/prod/api-keys" → specific mount and path
*
* For KV v2 mounts:
* - data/ paths → secret operations (read, write values)
* - metadata/ paths → folder operations (create, delete folders)
*
* Path structure after mount:
* - KV v2: [data|metadata]/{environment}/{secretPath}
* - KV v1: {environment}/{secretPath}
*/
export const parseVaultPath = (vaultPath: string, mounts: VaultMount[]): ParsedVaultPath => {
// Case 1: Global wildcard (e.g., "*" or "+") - matches everything
if (vaultPath === "*" || vaultPath === "+") {
const syntheticMount: VaultMount = {
path: "*",
type: "kv",
version: "1" // Default to v1 for global wildcards
};
return {
environment: "*",
secretPath: "/*",
mount: syntheticMount,
isWildcardMount: true
};
}
// Case 2: Wildcard mount (e.g., "*/data/*") - matches any mount with pattern
const isWildcardMount = vaultPath.startsWith("*/") || vaultPath.startsWith("+/");
if (isWildcardMount) {
let remainingPath = vaultPath.slice(2); // Remove "*/" or "+/"
if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1);
let environment: string | null = null;
let secretPath: string | null = null;
let isDataPath = false;
let isMetadataPath = false;
// Check for KV v2 data/ or metadata/ prefix
if (remainingPath.startsWith("data/")) {
isDataPath = true;
remainingPath = remainingPath.slice(5);
} else if (remainingPath.startsWith("metadata/")) {
isMetadataPath = true;
remainingPath = remainingPath.slice(9);
}
// Parse remaining segments
const segments = remainingPath.split("/").filter(Boolean);
if (segments.length > 0) {
if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) {
environment = "*";
secretPath = "/*";
} else {
[environment] = segments;
secretPath = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
}
}
// Create synthetic mount based on detected version
const syntheticMount: VaultMount = {
path: "*",
type: "kv",
version: isDataPath || isMetadataPath ? "2" : "1"
};
return { environment, secretPath, mount: syntheticMount, isWildcardMount: true };
}
// Case 3: Regular path (e.g., "secret/data/prod/api-keys")
// Find matching mount (longest path first for most specific match)
const sortedMounts = [...mounts].sort((a, b) => b.path.length - a.path.length);
const mount = sortedMounts.find((m) => vaultPath.startsWith(m.path));
if (!mount) {
return { environment: null, secretPath: null, mount: null, isWildcardMount: false };
}
// Remove mount prefix
let remainingPath = vaultPath.slice(mount.path.length);
if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1);
const isKvV2 = mount.version === "2" || mount.type === "kv";
// For KV v2, remove data/ or metadata/ prefix
if (isKvV2) {
if (remainingPath.startsWith("data/")) {
remainingPath = remainingPath.slice(5);
} else if (remainingPath.startsWith("metadata/")) {
remainingPath = remainingPath.slice(9);
}
}
// Parse environment and secret path
const segments = remainingPath.split("/").filter(Boolean);
let environment: string | null = null;
let secretPath: string | null = null;
if (segments.length > 0) {
if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) {
// Single wildcard segment
environment = null;
secretPath = "/*";
} else {
// First segment is the environment
[environment] = segments;
// Remaining segments form the secret path
secretPath = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
}
}
return { environment, secretPath, mount, isWildcardMount: false };
};
// ============================================================================
// Capability Mapping
// ============================================================================
/**
* Maps Vault capabilities to Infisical secret actions.
*
* Mapping:
* - create → Create
* - list → DescribeSecret (view metadata without values)
* - read → DescribeSecret + ReadValue (full access)
* - update/patch → Edit
* - delete → Delete
*/
const mapVaultCapabilitiesToSecretActions = (capabilities: string[]): Record<string, boolean> => {
const actions: Record<string, boolean> = {};
if (capabilities.includes("create")) {
actions[ProjectPermissionSecretActions.Create] = true;
}
if (capabilities.includes("list")) {
actions[ProjectPermissionSecretActions.DescribeSecret] = true;
}
if (capabilities.includes("read")) {
actions[ProjectPermissionSecretActions.DescribeSecret] = true;
actions[ProjectPermissionSecretActions.ReadValue] = true;
}
if (capabilities.includes("update") || capabilities.includes("patch")) {
actions[ProjectPermissionSecretActions.Edit] = true;
}
if (capabilities.includes("delete")) {
actions[ProjectPermissionSecretActions.Delete] = true;
}
return actions;
};
/**
* Maps Vault capabilities to Infisical folder actions.
*
* Mapping:
* - create → Create
* - update/patch → Edit
* - delete → Delete
*
* Note: 'list' is not mapped for folders as it's handled at the secret level
*/
const mapVaultCapabilitiesToFolderActions = (capabilities: string[]): Record<string, boolean> => {
const actions: Record<string, boolean> = {};
if (capabilities.includes("create")) {
actions[ProjectPermissionActions.Create] = true;
}
if (capabilities.includes("update") || capabilities.includes("patch")) {
actions[ProjectPermissionActions.Edit] = true;
}
if (capabilities.includes("delete")) {
actions[ProjectPermissionActions.Delete] = true;
}
return actions;
};
// ============================================================================
// Condition Building
// ============================================================================
type PermissionCondition = {
lhs: string;
operator: string;
rhs: string;
};
/**
* Converts Vault wildcard patterns to Infisical glob patterns.
* - Vault '+' → picomatch '*' (matches single segment)
* - Vault '*' → picomatch '**' (matches any depth)
*/
const convertVaultWildcardToGlob = (vaultPattern: string): string => {
// Use a placeholder to avoid replacing + twice
// Step 1: Replace + with a placeholder
let result = vaultPattern.replace(/\+/g, "__PLUS__");
// Step 2: Replace * with **
result = result.replace(/\*/g, "**");
// Step 3: Replace placeholder with *
result = result.replace(/__PLUS__/g, "*");
return result;
};
/**
* Builds permission conditions for environment and secret path filtering.
* Returns empty array if no restrictions are needed (matches everything).
*/
const buildConditions = (
environment: string | null,
secretPath: string | null
): PermissionCondition[] => {
const conditions: PermissionCondition[] = [];
// Add environment condition if present and not matching everything
if (environment) {
const globEnv = convertVaultWildcardToGlob(environment);
// Skip if matches everything (Vault * becomes **)
if (globEnv !== "**") {
const hasWildcard = globEnv.includes("*");
conditions.push({
lhs: "environment",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globEnv
});
}
}
// Add secret path condition if present and not matching everything
if (secretPath && secretPath !== "/*") {
const globPath = convertVaultWildcardToGlob(secretPath);
// After conversion, /* becomes /** which matches everything
if (globPath !== "/**") {
const hasWildcard = globPath.includes("*");
conditions.push({
lhs: "secretPath",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globPath
});
}
}
return conditions;
};
// ============================================================================
// Rule Deduplication
// ============================================================================
/**
* Creates a unique key for deduplication of permission rules.
* Combines all actions and conditions into a single string identifier.
*/
const createPermissionRuleKey = (rule: SecretPermissionRule | FolderPermissionRule): string => {
const actions = Object.entries(rule)
.filter(([key]) => key !== "conditions")
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join("|");
const conditions = (rule.conditions || [])
.map((c) => `${c.lhs}${c.operator}${c.rhs}`)
.sort()
.join("|");
return `${actions}::${conditions}`;
};
/**
* Adds a permission rule to the list if it's not a duplicate.
*/
const addPermissionRuleIfUnique = <T extends SecretPermissionRule | FolderPermissionRule>(
rule: T,
rulesList: T[],
seenRules: Set<string>
): void => {
const ruleKey = createPermissionRuleKey(rule);
if (!seenRules.has(ruleKey)) {
seenRules.add(ruleKey);
rulesList.push(rule);
}
};
// ============================================================================
// Main Parser
// ============================================================================
/**
* Parses Vault HCL policy and converts it to Infisical permissions.
*
* Process:
* 1. Clean HCL (remove comments, whitespace)
* 2. Extract path blocks with regex
* 3. For each path:
* - Parse to extract mount, environment, and secret path
* - Determine if it's a data path (secrets) or metadata path (folders)
* - Map Vault capabilities to Infisical actions
* - Build conditions for environment and path filtering
* - Create permission rule and add if unique
*
* @param hclPolicy - Raw Vault HCL policy string
* @param mounts - List of Vault mounts to match paths against
* @returns Parsed permissions object ready for Infisical role creation
*/
export const parseVaultPolicyToInfisical = (
hclPolicy: string,
mounts: VaultMount[]
): Partial<TFormSchema["permissions"]> => {
const secretsPermissions: SecretPermissionRule[] = [];
const foldersPermissions: FolderPermissionRule[] = [];
const seenSecretRules = new Set<string>();
const seenFolderRules = new Set<string>();
try {
// Step 1: Clean HCL policy - remove comments and extra whitespace
const cleanedPolicy = hclPolicy
.split("\n")
.map((line) => line.replace(/#.*$/, "").trim())
.filter((line) => line.length > 0)
.join(" ");
// Step 2: Extract path blocks using regex
const pathRegex = /path\s+"([^"]+)"\s*\{[^}]*capabilities\s*=\s*\[([^\]]+)\][^}]*\}/gi;
let match = pathRegex.exec(cleanedPolicy);
// Step 3: Process each path block
while (match !== null) {
const [, path, capabilitiesStr] = match;
// Parse capabilities list
const capabilities = capabilitiesStr
.split(",")
.map((c) => c.trim().replace(/["'\s]/g, ""))
.filter((c) => c.length > 0);
// Parse the Vault path
const { environment, secretPath, mount } = parseVaultPath(path, mounts);
// Only process KV (Key-Value) secret engines
if (mount && (mount.type === "kv" || mount.type === "generic")) {
const isKvV2 = mount.version === "2";
const isMetadata = isKvV2 && path.includes("/metadata/");
if (isMetadata) {
// Metadata paths → Folder permissions only (KV v2 metadata endpoint)
const actions = mapVaultCapabilitiesToFolderActions(capabilities);
if (Object.keys(actions).length > 0) {
const conditions = buildConditions(environment, secretPath);
addPermissionRuleIfUnique(
{ ...actions, conditions },
foldersPermissions,
seenFolderRules
);
}
} else {
// Data paths → Both secret AND folder permissions (KV v1 and v2 data paths)
// Users need both to fully manage secrets and their containing folders
const conditions = buildConditions(environment, secretPath);
// Create secret permissions
const secretActions = mapVaultCapabilitiesToSecretActions(capabilities);
if (Object.keys(secretActions).length > 0) {
addPermissionRuleIfUnique(
{ ...secretActions, conditions },
secretsPermissions,
seenSecretRules
);
}
// Create folder permissions for create/update/delete capabilities
const folderActions = mapVaultCapabilitiesToFolderActions(capabilities);
if (Object.keys(folderActions).length > 0) {
addPermissionRuleIfUnique(
{ ...folderActions, conditions },
foldersPermissions,
seenFolderRules
);
}
}
}
match = pathRegex.exec(cleanedPolicy);
}
} catch (err) {
console.error("Error parsing HCL policy:", err);
}
// Build final permissions object
const permissions: Partial<TFormSchema["permissions"]> = {};
if (secretsPermissions.length > 0) {
permissions[ProjectPermissionSub.Secrets] = secretsPermissions;
}
if (foldersPermissions.length > 0) {
permissions[ProjectPermissionSub.SecretFolders] = foldersPermissions;
}
return permissions;
};

View File

@@ -77,6 +77,11 @@ import {
fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import {
useGetVaultExternalMigrationConfigs,
useImportVaultSecrets
} from "@app/hooks/api/migration";
import { VaultImportStatus } from "@app/hooks/api/migration/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
@@ -98,6 +103,7 @@ import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm";
import { MoveSecretsModal } from "./MoveSecretsModal";
import { VaultSecretImportModal } from "./VaultSecretImportModal";
type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[];
type TParsedFolderEnv = Record<
@@ -171,7 +177,8 @@ export const ActionBar = ({
"upgradePlan",
"replicateFolder",
"confirmUpload",
"requestAccess"
"requestAccess",
"importFromVault"
] as const);
const isProtectedBranch = Boolean(protectedBranchPolicyName);
const { subscription } = useSubscription();
@@ -185,6 +192,7 @@ export const ActionBar = ({
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
options: { onSuccess: undefined }
});
const { mutateAsync: importVaultSecrets } = useImportVaultSecrets();
const queryClient = useQueryClient();
const { addPendingChange } = useBatchModeActions();
@@ -193,6 +201,8 @@ export const ActionBar = ({
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const { permission } = useProjectPermission();
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const handleFolderCreate = async (folderName: string, description: string | null) => {
try {
@@ -663,6 +673,40 @@ export const ActionBar = ({
}
};
const handleVaultImport = async (vaultPath: string, namespace: string) => {
try {
const result = await importVaultSecrets({
projectId,
environment,
secretPath,
vaultNamespace: namespace,
vaultSecretPath: vaultPath
});
if (result.status === VaultImportStatus.ApprovalRequired) {
createNotification({
type: "info",
text: "Secret change request created successfully. Awaiting approval."
});
} else {
createNotification({
type: "success",
text: "Successfully imported secrets from HashiCorp Vault"
});
}
} catch (err) {
console.error("Vault import error:", err);
const error = err as AxiosError<{ message?: string }>;
const errorMessage =
error.response?.data?.message || "Failed to import secrets from Vault. Please try again.";
createNotification({
type: "error",
text: errorMessage
});
}
};
const isTableFiltered =
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
@@ -1059,6 +1103,39 @@ export const ActionBar = ({
</Button>
)}
</ProjectPermissionCan>
{hasVaultConnection && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
>
{(isAllowed) => (
<Button
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
}
onClick={() => {
handlePopUpOpen("importFromVault");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add from HashiCorp Vault
</Button>
)}
</ProjectPermissionCan>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -1277,6 +1354,13 @@ export const ActionBar = ({
</div>
</ModalContent>
</Modal>
<VaultSecretImportModal
isOpen={popUp.importFromVault.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
environment={environment}
secretPath={secretPath}
onImport={handleVaultImport}
/>
</>
);
};

View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from "react";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import {
useGetVaultMounts,
useGetVaultNamespaces,
useGetVaultSecretPaths
} from "@app/hooks/api/migration/queries";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
environment: string;
secretPath: string;
onImport: (vaultPath: string, namespace: string) => void;
};
type ContentProps = {
onClose: () => void;
environment: string;
secretPath: string;
onImport: (vaultPath: string, namespace: string) => void;
};
const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [shouldFetchPaths, setShouldFetchPaths] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: secretPaths, isLoading: isLoadingPaths } = useGetVaultSecretPaths(
shouldFetchPaths,
selectedNamespace ?? undefined,
selectedMountPath ?? undefined
);
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
shouldFetchMounts,
selectedNamespace ?? undefined
);
// Filter to only show KV mounts
const kvMounts = mounts?.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
// Enable fetching mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchMounts(true);
}
}, [selectedNamespace]);
// Enable fetching paths when both namespace and mount path are selected
useEffect(() => {
if (selectedNamespace && selectedMountPath) {
setShouldFetchPaths(true);
} else {
setShouldFetchPaths(false);
}
}, [selectedNamespace, selectedMountPath]);
const handleImport = () => {
if (!selectedPath) {
createNotification({ type: "error", text: "Please select a Vault secret path to import" });
return;
}
if (!selectedNamespace) {
createNotification({ type: "error", text: "Please select a namespace" });
return;
}
if (!mounts || mounts.length === 0) {
createNotification({
type: "error",
text: "No Vault mounts found. Please ensure you have KV secret engines configured."
});
return;
}
onImport(selectedPath, selectedNamespace);
onClose();
};
return (
<>
<div className="mb-4 rounded-md bg-primary/10 p-3 text-sm text-mineshaft-200">
<div className="flex items-start gap-2">
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<div>
<div className="mb-2">
<strong>Import Secrets from HashiCorp Vault</strong>
</div>
<div className="space-y-1.5 text-xs leading-relaxed">
<p>
Select a Vault namespace and secret path to import secrets into the current
Infisical environment (<code className="text-xs">{environment}</code>) at path{" "}
<code className="text-xs">{secretPath}</code>.
</p>
</div>
</div>
</div>
</div>
<FormControl
label="Namespace"
className="mb-4"
tooltipText="Select the Vault namespace containing the secrets you want to import."
>
<>
<FilterableSelect
value={namespaces?.find((ns) => ns.name === selectedNamespace)}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const namespace = value as { id: string; name: string };
setSelectedNamespace(namespace.name);
setSelectedMountPath(null);
setSelectedPath(null);
}
}}
options={namespaces || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
isDisabled={isLoadingNamespaces}
placeholder="Select namespace..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available mounts
</p>
</>
</FormControl>
<FormControl
label="Secrets Engine"
className="mb-4"
tooltipText="Select the KV secrets engine to narrow down secret paths."
>
<>
<FilterableSelect
value={kvMounts?.find((mount) => mount.path === selectedMountPath)}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const mount = value as { path: string; type: string; version: string | null };
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
setSelectedPath(null);
}
}}
options={kvMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !kvMounts?.length}
placeholder="Select secrets engine..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a KV secrets engine to filter available secret paths
</p>
</>
</FormControl>
<FormControl label="Vault Secret Path" className="mb-6">
<>
<FilterableSelect
value={selectedPath ? { path: selectedPath } : null}
onChange={(value) => {
if (value && !Array.isArray(value)) {
setSelectedPath((value as { path: string }).path);
} else {
setSelectedPath(null);
}
}}
options={(secretPaths || []).map((path) => ({ path }))}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path}
isDisabled={isLoadingPaths || !secretPaths?.length || !selectedMountPath}
placeholder={
!selectedMountPath
? "Select a mount path first..."
: "Select a Vault path to import..."
}
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a secret path from the selected mount to import into Infisical
</p>
</>
</FormControl>
<div className="mt-8 flex space-x-4">
<Button
onClick={handleImport}
isDisabled={!selectedPath || isLoadingMounts || isLoadingPaths}
>
Import Secrets
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const VaultSecretImportModal = ({
isOpen,
onOpenChange,
environment,
secretPath,
onImport
}: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
bodyClassName="overflow-visible"
title="Import from HashiCorp Vault"
subTitle="Select a Vault namespace and secret path to import secrets into the current environment and folder."
className="max-w-2xl"
>
<Content
onClose={() => onOpenChange(false)}
environment={environment}
secretPath={secretPath}
onImport={onImport}
/>
</ModalContent>
</Modal>
);
};