From c7d9df419ef3fcac9a02207f664efb794fbaf313 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 14 Oct 2025 03:43:24 +0800 Subject: [PATCH 01/43] feat: in-platform migration tooling for Vault policies + scaffolding --- backend/src/@types/knex.d.ts | 8 + ...4547_add-in-platform-external-migration.ts | 28 + .../db/schemas/external-migration-configs.ts | 23 + backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 4 +- backend/src/server/routes/index.ts | 20 +- .../routes/v3/external-migration-router.ts | 147 +++++ .../hc-vault/hc-vault-connection-fns.ts | 171 +++++- .../hc-vault/hc-vault-connection-service.ts | 3 +- .../hc-vault/hc-vault-connection-types.ts | 6 + .../external-migration-config-dal.ts | 80 +++ .../external-migration-service.ts | 211 ++++++- .../external-migration-types.ts | 8 +- frontend/src/hooks/api/migration/index.ts | 1 + .../src/hooks/api/migration/mutations.tsx | 25 + frontend/src/hooks/api/migration/queries.tsx | 74 ++- frontend/src/hooks/api/migration/types.ts | 9 + .../ExternalMigrationsTab.tsx | 98 ++-- .../components/VaultConnectionSection.tsx | 100 ++++ .../components/AddPoliciesButton.tsx | 53 +- .../components/VaultPolicyImportModal.tsx | 517 ++++++++++++++++++ 21 files changed, 1525 insertions(+), 62 deletions(-) create mode 100644 backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts create mode 100644 backend/src/db/schemas/external-migration-configs.ts create mode 100644 backend/src/services/external-migration/external-migration-config-dal.ts create mode 100644 frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx create mode 100644 frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 07fe2c97d..8efcce2b7 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -83,6 +83,9 @@ import { TExternalKms, TExternalKmsInsert, TExternalKmsUpdate, + TExternalMigrationConfigs, + TExternalMigrationConfigsInsert, + TExternalMigrationConfigsUpdate, TFolderCheckpointResources, TFolderCheckpointResourcesInsert, TFolderCheckpointResourcesUpdate, @@ -1345,5 +1348,10 @@ declare module "knex/types/tables" { TAdditionalPrivilegesInsert, TAdditionalPrivilegesUpdate >; + [TableName.ExternalMigrationConfig]: KnexOriginal.CompositeTableType< + TExternalMigrationConfigs, + TExternalMigrationConfigsInsert, + TExternalMigrationConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts new file mode 100644 index 000000000..f9a2adf35 --- /dev/null +++ b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.ExternalMigrationConfig))) { + await knex.schema.createTable(TableName.ExternalMigrationConfig, (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("platform").notNullable(); + + t.uuid("connectionId"); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + + t.timestamps(true, true, true); + t.unique(["orgId", "platform"]); + }); + + await createOnUpdateTrigger(knex, TableName.ExternalMigrationConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ExternalMigrationConfig); + await dropOnUpdateTrigger(knex, TableName.ExternalMigrationConfig); +} diff --git a/backend/src/db/schemas/external-migration-configs.ts b/backend/src/db/schemas/external-migration-configs.ts new file mode 100644 index 000000000..c781d92b3 --- /dev/null +++ b/backend/src/db/schemas/external-migration-configs.ts @@ -0,0 +1,23 @@ +// 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 ExternalMigrationConfigsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + platform: z.string(), + connectionId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TExternalMigrationConfigs = z.infer; +export type TExternalMigrationConfigsInsert = Omit, TImmutableDBKeys>; +export type TExternalMigrationConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 2a10e0f1b..19eac5882 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -25,6 +25,7 @@ export * from "./dynamic-secrets"; export * from "./external-certificate-authorities"; export * from "./external-group-org-role-mappings"; export * from "./external-kms"; +export * from "./external-migration-configs"; export * from "./folder-checkpoint-resources"; export * from "./folder-checkpoints"; export * from "./folder-commit-changes"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index d3537b028..2e8ddf5c2 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -203,7 +203,9 @@ export enum TableName { PamFolder = "pam_folders", PamResource = "pam_resources", PamAccount = "pam_accounts", - PamSession = "pam_sessions" + PamSession = "pam_sessions", + + ExternalMigrationConfig = "external_migration_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a718dd745..c22024380 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -174,6 +174,7 @@ import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; +import { externalMigrationConfigDALFactory } from "@app/services/external-migration/external-migration-config-dal"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; @@ -533,6 +534,8 @@ export const registerRoutes = async ( const membershipRoleDAL = membershipRoleDALFactory(db); const roleDAL = roleDALFactory(db); + const externalMigrationConfigDAL = externalMigrationConfigDALFactory(db); + const eventBusService = eventBusFactory(server.redis); const sseService = sseServiceFactory(eventBusService, server.redis); @@ -1873,13 +1876,6 @@ export const registerRoutes = async ( notificationService }); - const migrationService = externalMigrationServiceFactory({ - externalMigrationQueue, - userDAL, - permissionService, - gatewayService - }); - const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ permissionService, licenseService, @@ -2196,6 +2192,16 @@ export const registerRoutes = async ( kmsService }); + const migrationService = externalMigrationServiceFactory({ + externalMigrationQueue, + userDAL, + permissionService, + gatewayService, + kmsService, + appConnectionService, + externalMigrationConfigDAL + }); + // setup the communication with license key server await licenseService.init(); diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index a3b744485..7b436f43f 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -113,4 +113,151 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider return { enabled }; } }); + + server.route({ + method: "GET", + url: "/config", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + platform: z.nativeEnum(ExternalMigrationProviders) + }), + response: { + 200: z.object({ + config: z + .object({ + id: z.string(), + orgId: z.string(), + platform: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + .nullable() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const config = await server.services.migration.getExternalMigrationConfig({ + platform: req.query.platform, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "PUT", + url: "/config", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + connectionId: z.string().nullable(), + platform: z.nativeEnum(ExternalMigrationProviders) + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + platform: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const config = await server.services.migration.configureExternalMigration({ + ...req.body, + 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, AuthMode.IDENTITY_ACCESS_TOKEN]), + 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().optional() + }), + response: { + 200: z.object({ + policies: z.array(z.object({ name: z.string(), rules: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + 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().optional() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string(), version: z.string().nullish() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultMounts({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { mounts }; + } + }); }; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 3a79e2f8e..cda8c4166 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -13,7 +13,12 @@ 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 { + THCVaultConnection, + THCVaultConnectionConfig, + THCVaultMount, + THCVaultMountResponse +} from "./hc-vault-connection-types"; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -181,29 +186,179 @@ export const validateHCVaultConnectionCredentials = async ( } }; -export const listHCVaultMounts = async ( +export const listHCVaultPolicies = async ( + connection: THCVaultConnection, + gatewayService: Pick, + namespace?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + if (namespace && connection.credentials.namespace) { + throw new BadRequestError({ + message: "Namespace cannot be specified when namespace is already set in the connection credentials" + }); + } + + const targetNamespace = namespace || connection.credentials.namespace; + + try { + const { data: listData } = await requestWithHCVaultGateway<{ + policies: string[]; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + ...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {}) + } + }); + + const policyNames = listData.policies || []; + + const policies = await Promise.all( + policyNames.map(async (policyName) => { + try { + const { data: policyData } = await requestWithHCVaultGateway<{ + name: string; + rules: string; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy/${policyName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + ...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {}) + } + }); + + return { + name: policyData.name, + rules: policyData.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 ) => { const instanceUrl = await getHCVaultInstanceUrl(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService); + try { + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + key_info?: { + [key: string]: { + id: string; + path: string; + custom_metadata?: Record; + }; + }; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/namespaces`, + method: "LIST", + headers: { + "X-Vault-Token": accessToken, + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + } + }); + + // Transform using key_info if available, otherwise fall back to keys array + const namespaces = (data.data.keys || []).map((namespaceKey) => { + const keyInfo = data.data.key_info?.[namespaceKey]; + return { + id: keyInfo?.id || namespaceKey.replace(/\/$/, ""), // Use Vault's ID if available, otherwise use the key + name: namespaceKey.replace(/\/$/, "") // Remove trailing slash for display + }; + }); + + return namespaces; + } catch (error: unknown) { + // 404 means namespaces endpoint doesn't exist (Vault Community Edition) + // Return empty array to gracefully degrade + if (error instanceof AxiosError && error.response?.status === 404) { + logger.info("Namespaces endpoint not available (likely Vault Community Edition). Returning empty list."); + return [ + { + id: "default", + name: "default" + } + ]; + } + + 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, + namespace?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + if (namespace && connection.credentials.namespace) { + throw new BadRequestError({ + message: "Namespace cannot be specified when namespace is already set in the connection credentials" + }); + } + + const targetNamespace = namespace || connection.credentials.namespace; + const { data } = await requestWithHCVaultGateway(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; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index 589c7c1bd..037964da4 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -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 []; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index 6f254eda0..7cd861c60 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -33,3 +33,9 @@ export type THCVaultMountResponse = { }; }; }; + +export type THCVaultMount = { + path: string; + type: string; + version?: string | null; +}; diff --git a/backend/src/services/external-migration/external-migration-config-dal.ts b/backend/src/services/external-migration/external-migration-config-dal.ts new file mode 100644 index 000000000..64874a25e --- /dev/null +++ b/backend/src/services/external-migration/external-migration-config-dal.ts @@ -0,0 +1,80 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TExternalMigrationConfigsInsert } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +export type TExternalMigrationConfigDALFactory = ReturnType; + +export const externalMigrationConfigDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ExternalMigrationConfig); + + const upsert = async (data: TExternalMigrationConfigsInsert, tx?: Knex) => { + try { + const [doc] = await (tx || db)(TableName.ExternalMigrationConfig) + .insert(data) + .onConflict(["orgId", "platform"]) + .merge() + .returning("*"); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "UpsertExternalMigrationConfig" }); + } + }; + + const findOne = async (filter: { orgId: string; platform: string }, tx?: Knex) => { + try { + const result = await (tx || db?.replicaNode?.() || db)(TableName.ExternalMigrationConfig) + .leftJoin( + TableName.AppConnection, + `${TableName.AppConnection}.id`, + `${TableName.ExternalMigrationConfig}.connectionId` + ) + /* eslint-disable @typescript-eslint/no-misused-promises */ + .where(buildFindFilter(prependTableNameToFindFilter(TableName.ExternalMigrationConfig, filter))) + .select(selectAllTableCols(TableName.ExternalMigrationConfig)) + .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, upsert, findOne }; +}; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 14885b1d6..e70d36694 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -2,9 +2,21 @@ import { OrgMembershipRole } from "@app/db/schemas"; 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 { BadRequestError, 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 { + listHCVaultMounts, + listHCVaultNamespaces, + listHCVaultPolicies, + THCVaultConnection +} from "../app-connection/hc-vault"; +import { TKmsServiceFactory } from "../kms/kms-service"; import { TUserDALFactory } from "../user/user-dal"; +import { TExternalMigrationConfigDALFactory } from "./external-migration-config-dal"; import { decryptEnvKeyDataFn, importVaultDataFn, @@ -15,6 +27,7 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue"; import { ExternalMigrationProviders, ExternalPlatforms, + TConfigureExternalMigrationDTO, THasCustomVaultMigrationDTO, TImportEnvKeyDataDTO, TImportVaultDataDTO @@ -23,8 +36,11 @@ import { type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; externalMigrationQueue: TExternalMigrationQueueFactory; + appConnectionService: Pick; + externalMigrationConfigDAL: Pick; userDAL: Pick; gatewayService: Pick; + kmsService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -33,7 +49,10 @@ export const externalMigrationServiceFactory = ({ permissionService, externalMigrationQueue, userDAL, - gatewayService + gatewayService, + appConnectionService, + externalMigrationConfigDAL, + kmsService }: TExternalMigrationServiceFactoryDep) => { const importEnvKeyData = async ({ decryptionKey, @@ -171,9 +190,195 @@ export const externalMigrationServiceFactory = ({ return actorOrgId in vaultMigrationTransformMappings; }; + const configureExternalMigration = async ({ platform, connectionId, actor }: TConfigureExternalMigrationDTO) => { + 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 external migration" }); + } + + if (connectionId) { + if (platform === ExternalMigrationProviders.Vault) { + await appConnectionService.connectAppConnectionById(AppConnection.HCVault, connectionId, actor); + } else { + throw new BadRequestError({ message: "Invalid platform" }); + } + } + + const config = await externalMigrationConfigDAL.upsert({ + platform, + connectionId, + orgId: actor.orgId + }); + + return config; + }; + + const getExternalMigrationConfig = async ({ platform, actor }: { platform: string; 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 external migration config" }); + } + + const config = await externalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + platform + }); + + if (!config) { + throw new NotFoundError({ message: "External migration config not found" }); + } + + return config; + }; + + 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" }); + } + + const vaultConfig = await externalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + platform: ExternalMigrationProviders.Vault + }); + + if (!vaultConfig) { + throw new BadRequestError({ message: "Vault migration config not found" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const namespaces = await listHCVaultNamespaces(connection, gatewayService); + 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 externalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + platform: ExternalMigrationProviders.Vault + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const policies = await listHCVaultPolicies(connection, gatewayService, namespace); + 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 externalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + platform: ExternalMigrationProviders.Vault + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + return mounts; + }; + return { importEnvKeyData, importVaultData, - hasCustomVaultMigration + hasCustomVaultMigration, + configureExternalMigration, + getExternalMigrationConfig, + getVaultNamespaces, + getVaultPolicies, + getVaultMounts }; }; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 804444172..565db6e71 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -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,9 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export type TConfigureExternalMigrationDTO = { + platform: ExternalMigrationProviders; + connectionId: string | null; + actor: OrgServiceActor; +}; diff --git a/frontend/src/hooks/api/migration/index.ts b/frontend/src/hooks/api/migration/index.ts index 0c2adeab0..177955438 100644 --- a/frontend/src/hooks/api/migration/index.ts +++ b/frontend/src/hooks/api/migration/index.ts @@ -1,2 +1,3 @@ export * from "./mutations"; export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index b2694b458..f43ffeb2f 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -3,6 +3,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { projectKeys } from "../projects"; +import { externalMigrationQueryKeys } from "./queries"; +import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types"; export const useImportEnvKey = () => { const queryClient = useQueryClient(); @@ -65,3 +67,26 @@ export const useImportVault = () => { } }); }; + +export const useUpdateExternalMigrationConfig = (platform: ExternalMigrationProviders) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ connectionId }: { connectionId: string | null }) => { + const { data } = await apiRequest.put<{ config: TExternalMigrationConfig }>( + "/api/v3/external-migration/config", + { + connectionId, + platform + } + ); + + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.config(platform) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index e96533b09..ff08eeca3 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -2,13 +2,17 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ExternalMigrationProviders } from "./types"; +import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types"; -const externalMigrationQueryKeys = { +export const externalMigrationQueryKeys = { customMigrationAvailable: (provider: ExternalMigrationProviders) => [ "custom-migration-available", provider - ] + ], + config: (platform: string) => ["external-migration-config", { platform }], + vaultNamespaces: () => ["vault-namespaces"], + vaultPolicies: () => ["vault-policies"], + vaultMounts: () => ["vault-mounts"] }; export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => { @@ -20,3 +24,67 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid ) }); }; + +export const useGetExternalMigrationConfig = (platform: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.config(platform), + queryFn: async () => { + const { data } = await apiRequest.get<{ config: TExternalMigrationConfig | null }>( + "/api/v3/external-migration/config", + { + params: { platform } + } + ); + return data.config; + }, + enabled: Boolean(platform) + }); +}; + +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(), + 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(), + 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 + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts index 945f18d8e..c8e00fb71 100644 --- a/frontend/src/hooks/api/migration/types.ts +++ b/frontend/src/hooks/api/migration/types.ts @@ -2,3 +2,12 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export type TExternalMigrationConfig = { + id: string; + orgId: string; + platform: string; + connectionId: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx index 4857e8fe3..4562ea007 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx @@ -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,72 @@ export const ExternalMigrationsTab = () => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const); return ( -
-
-
-

Import from external source

+
+ {/* In-Platform Migration Tooling Section */} +
+
+

+ In-Platform Migration Tooling +

+

+ Configure platform connections to enable migration features throughout Infisical, such + as importing policies and resources directly within the UI. +

+
+ +
- + {/* Bulk Data Import Section */} +
+
+

Bulk Data Import

+

+ Perform one-time bulk imports of data from external platforms. +

- -
-

Import data from another platform to Infisical.

+
+
+
+

+ Import from external source +

+ +
+ + Docs + +
+
+
+

+ Import data from another platform to Infisical. +

+
- handlePopUpToggle("selectImportPlatform", state)} - /> + +
+ + handlePopUpToggle("selectImportPlatform", state)} + /> +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx new file mode 100644 index 000000000..c375b03b3 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx @@ -0,0 +1,100 @@ +import { useMemo } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { useListAppConnections } from "@app/hooks/api/appConnections/queries"; +import { + useGetExternalMigrationConfig, + useUpdateExternalMigrationConfig +} from "@app/hooks/api/migration"; +import { ExternalMigrationProviders } from "@app/hooks/api/migration/types"; + +export const VaultConnectionSection = () => { + const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections(); + + const vaultConnections = useMemo( + () => appConnections.filter((conn) => conn.app === AppConnection.HCVault), + [appConnections] + ); + + const { data: currentConfig, isPending: isLoadingConfig } = useGetExternalMigrationConfig( + ExternalMigrationProviders.Vault + ); + + const { mutateAsync: updateConfig, isPending: isUpdating } = useUpdateExternalMigrationConfig( + ExternalMigrationProviders.Vault + ); + + const handleConnectionChange = async ( + selectedConnection: { id: string; name: string } | null + ) => { + try { + await updateConfig({ + connectionId: selectedConnection?.id || null + }); + + createNotification({ + type: "success", + text: "Vault connection updated successfully" + }); + } catch (error) { + console.error("Failed to update vault connection:", error); + createNotification({ + type: "error", + text: "Failed to update vault connection" + }); + } + }; + + const selectedConnection = useMemo(() => { + if (!currentConfig?.connectionId) return null; + return vaultConnections.find((conn) => conn.id === currentConfig.connectionId) || null; + }, [currentConfig?.connectionId, vaultConnections]); + + const isLoading = isLoadingConnections || isLoadingConfig; + + return ( +
+
+ HashiCorp Vault logo +
+

HashiCorp Vault

+

+ Enable in-platform migration tooling for policy imports and secret engine migrations +

+
+
+ +
+ + { + handleConnectionChange(newValue as { id: string; name: string } | null); + }} + isLoading={isLoading} + isDisabled={isUpdating} + options={vaultConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + isClearable + /> + +
+ +

+ Select an existing App Connection to enable in-platform migration features. Manage + connections in the App Connections section. +

+
+ ); +}; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx index 37f6b3868..c7932fc97 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton.tsx @@ -6,12 +6,18 @@ 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 { useGetExternalMigrationConfig } from "@app/hooks/api/migration"; +import { ExternalMigrationProviders } from "@app/hooks/api/migration/types"; 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 +28,17 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addPolicy", "addPolicyOptions", - "applyTemplate" + "applyTemplate", + "importFromVault" ] as const); + const { hasOrgRole } = useOrgPermission(); + const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault); + + const hasVaultConnection = Boolean(vaultConfig?.connectionId); + const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin); + const isVaultImportDisabled = isDisabled || !isOrgAdmin; + return (
+ {hasVaultConnection && ( + + + + )}
@@ -77,6 +120,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => { isOpen={popUp.applyTemplate.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)} /> + handlePopUpToggle("importFromVault", isOpen)} + />
); }; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx new file mode 100644 index 000000000..7d9fe13be --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal.tsx @@ -0,0 +1,517 @@ +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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { + PermissionConditionOperators, + ProjectPermissionSecretActions +} from "@app/context/ProjectPermissionContext/types"; +import { + useGetVaultMounts, + useGetVaultNamespaces, + useGetVaultPolicies +} from "@app/hooks/api/migration/queries"; + +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onClose: () => void; +}; + +type VaultMount = { path: string; type: string; version: string | null }; + +// Extract array element type helper +type ArrayElement = T extends (infer U)[] ? U : never; + +// Extract permission rule types from the form schema +type SecretPermissionRule = ArrayElement< + NonNullable[ProjectPermissionSub.Secrets] +>; +type FolderPermissionRule = ArrayElement< + NonNullable[ProjectPermissionSub.SecretFolders] +>; + +// Helper to parse Vault path and extract environment and secret path +const parseVaultPath = ( + vaultPath: string, + mounts: VaultMount[] +): { environment: string | null; secretPath: string | null; mount: VaultMount | null } => { + // Find the matching mount for this path + // Sort by path length (longest first) to match most specific mount + 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 }; + } + + // Remove mount prefix and any trailing slash + let remainingPath = vaultPath.slice(mount.path.length); + if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1); + + // For KV v2, paths have format: data/{environment}/{path} or metadata/{environment}/{path} + // For KV v1, paths have format: {environment}/{path} + const isKvV2 = mount.version === "2" || mount.type === "kv"; + + let environment: string | null = null; + let secretPath: string | null = null; + + if (isKvV2) { + // Remove data/ or metadata/ prefix for KV v2 + if (remainingPath.startsWith("data/")) { + remainingPath = remainingPath.slice(5); + } else if (remainingPath.startsWith("metadata/")) { + remainingPath = remainingPath.slice(9); + } + } + + // Split remaining path into segments + const segments = remainingPath.split("/").filter(Boolean); + + if (segments.length > 0) { + // Special case: if the only segment is a wildcard, treat it as a path wildcard + if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) { + environment = null; // No specific environment + secretPath = "/*"; // Match all paths + } else { + // First segment is treated as the environment + // (wildcards in environment will be handled with $GLOB operator later) + [environment] = segments; + + // Remaining segments form the secret path + if (segments.length > 1) { + secretPath = `/${segments.slice(1).join("/")}`; + } else { + secretPath = "/"; + } + } + } + + return { environment, secretPath, mount }; +}; + +// HCL parser for Vault policies - converts Vault HCL to Infisical permissions +const parseVaultPolicyToInfisical = ( + hclPolicy: string, + mounts: VaultMount[] +): Partial => { + const permissions: Partial = {}; + const secretsPermissions: SecretPermissionRule[] = []; + const foldersPermissions: FolderPermissionRule[] = []; + + try { + // Remove comments from HCL before parsing + const cleanedPolicy = hclPolicy + .split("\n") + .map((line) => line.replace(/#.*$/, "").trim()) // Remove # comments + .filter((line) => line.length > 0) // Remove empty lines + .join(" "); // Join into single line for easier parsing + + // Match path blocks with flexible whitespace handling + const pathRegex = /path\s+"([^"]+)"\s*\{[^}]*capabilities\s*=\s*\[([^\]]+)\][^}]*\}/gi; + let match = pathRegex.exec(cleanedPolicy); + + while (match !== null) { + const [, path, capabilitiesStr] = match; + // Split by comma and clean up each capability (handles newlines, extra spaces, quotes) + const capabilities = capabilitiesStr + .split(",") + .map((c) => c.trim().replace(/["'\s]/g, "")) // Remove quotes, spaces, newlines + .filter((c) => c.length > 0); // Filter out empty strings + + // Parse the Vault path using mount information + const { environment, secretPath, mount } = parseVaultPath(path, mounts); + + // Only process KV (Key-Value) mounts + if (mount && (mount.type === "kv" || mount.type === "generic")) { + const isKvV2 = mount.version === "2"; + const isDataPath = isKvV2 ? path.includes("/data/") : true; // KV v1 = all paths are data paths + const isMetadataPath = isKvV2 ? path.includes("/metadata/") : false; // KV v1 has no metadata endpoint + + if (isDataPath && !isMetadataPath) { + // Data paths map to secret permissions + const actions: { [key: string]: boolean } = {}; + + if (capabilities.includes("create")) + actions[ProjectPermissionSecretActions.Create] = true; + if (capabilities.includes("read")) { + actions[ProjectPermissionSecretActions.DescribeSecret] = true; + actions[ProjectPermissionSecretActions.ReadValue] = true; + } + if (capabilities.includes("update")) actions[ProjectPermissionSecretActions.Edit] = true; + if (capabilities.includes("delete")) + actions[ProjectPermissionSecretActions.Delete] = true; + + if (Object.keys(actions).length > 0) { + const conditions: Array<{ lhs: string; operator: string; rhs: string }> = []; + + // Add environment condition with glob support if it contains wildcards + if (environment) { + // Convert Vault '+' to glob '*' for environment matching + const globEnv = environment.replace(/\+/g, "*"); + // Skip condition if it's just '*' (matches everything = no restriction) + if (globEnv !== "*") { + const hasWildcard = globEnv.includes("*"); + conditions.push({ + lhs: "environment", + operator: hasWildcard + ? PermissionConditionOperators.$GLOB + : PermissionConditionOperators.$EQ, + rhs: globEnv + }); + } + } + + // Add secret path condition with glob support + if (secretPath) { + // Convert Vault wildcards to picomatch glob patterns + // Vault '*' = match within segment, picomatch '**' = match across segments + // Vault '+' = single segment, convert to '*' (note: slightly more permissive) + const globPath = secretPath.replace(/\+/g, "*"); + // Check if we need glob operator + const hasWildcard = globPath.includes("*"); + conditions.push({ + lhs: "secretPath", + operator: hasWildcard + ? PermissionConditionOperators.$GLOB + : PermissionConditionOperators.$EQ, + rhs: globPath + }); + } + + secretsPermissions.push({ + ...actions, + conditions + }); + } + } else if (isMetadataPath) { + // Metadata paths map to folder permissions + const actions: { [key: string]: boolean } = {}; + + if (capabilities.includes("create")) actions[ProjectPermissionActions.Create] = true; + if (capabilities.includes("update")) actions[ProjectPermissionActions.Edit] = true; + if (capabilities.includes("delete")) actions[ProjectPermissionActions.Delete] = true; + + if (Object.keys(actions).length > 0) { + const conditions: Array<{ lhs: string; operator: string; rhs: string }> = []; + + // Add environment condition with glob support if it contains wildcards + if (environment) { + // Convert Vault '+' to glob '*' for environment matching + const globEnv = environment.replace(/\+/g, "*"); + // Skip condition if it's just '*' (matches everything = no restriction) + if (globEnv !== "*") { + const hasWildcard = globEnv.includes("*"); + conditions.push({ + lhs: "environment", + operator: hasWildcard + ? PermissionConditionOperators.$GLOB + : PermissionConditionOperators.$EQ, + rhs: globEnv + }); + } + } + + // Add secret path condition for folders with glob support + if (secretPath) { + // Convert Vault '+' wildcard to glob '*' + const globPath = secretPath.replace(/\+/g, "*"); + const hasWildcard = globPath.includes("*"); + conditions.push({ + lhs: "secretPath", + operator: hasWildcard + ? PermissionConditionOperators.$GLOB + : PermissionConditionOperators.$EQ, + rhs: globPath + }); + } + + foldersPermissions.push({ + ...actions, + conditions + }); + } + } + } + + match = pathRegex.exec(cleanedPolicy); + } + + if (secretsPermissions.length > 0) { + permissions[ProjectPermissionSub.Secrets] = secretsPermissions; + } + + if (foldersPermissions.length > 0) { + permissions[ProjectPermissionSub.SecretFolders] = foldersPermissions; + } + } catch (err) { + console.error("Error parsing HCL policy:", err); + } + + return permissions; +}; + +const Content = ({ onClose }: ContentProps) => { + const rootForm = useFormContext(); + const [selectedNamespace, setSelectedNamespace] = useState("default"); + const [selectedPolicy, setSelectedPolicy] = useState(null); + const [hclPolicy, setHclPolicy] = useState(""); + const [shouldFetchPolicies, setShouldFetchPolicies] = useState(false); + const [shouldFetchMounts, setShouldFetchMounts] = useState(false); + + const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); + const { + data: policies, + isLoading: isLoadingPolicies, + refetch: refetchPolicies + } = useGetVaultPolicies(shouldFetchPolicies, selectedNamespace); + const { + data: mounts, + isLoading: isLoadingMounts, + refetch: refetchMounts + } = useGetVaultMounts(shouldFetchMounts, selectedNamespace); + + // 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.entries(parsedPermissions).forEach(([subject, value]) => { + if (!value) return; + + const subjectKey = subject as ProjectPermissionSub; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const existingValue = rootForm.getValues(`permissions.${subjectKey}`) as any; + + if (Array.isArray(existingValue) && existingValue.length > 0) { + // Merge with existing permissions + rootForm.setValue( + `permissions.${subjectKey}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error + [...existingValue, ...value], + { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true + } + ); + } else { + rootForm.setValue( + `permissions.${subjectKey}`, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error + value, + { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true + } + ); + } + }); + + createNotification({ + type: "success", + text: "Policy translated and applied successfully" + }); + + onClose(); + } catch (err) { + console.error("Translation error:", err); + createNotification({ + type: "error", + text: "Failed to translate policy. Please check the HCL format." + }); + } + }; + + return ( + <> +
+
+ +
+
+ How Policy Translation Works +
+
+

+ Policies are translated by identifying KV secret engine mounts and parsing path + structures to extract environments and secret paths. +

+

+ Key assumptions: The first path segment after the mount is treated + as the environment (e.g., secret/data/prod/app → + env: prod, path:{" "} + /app). Vault capabilities and wildcards are + automatically mapped to equivalent Infisical permissions and glob patterns. +

+
+
+
+
+ + + <> + ns.name === selectedNamespace)} + onChange={(value) => { + if (value && !Array.isArray(value)) { + const namespace = value as { id: string; name: string }; + setSelectedNamespace(namespace.name); + // Refetch policies and mounts when namespace changes + refetchPolicies(); + refetchMounts(); + } + }} + options={namespaces || []} + getOptionValue={(option) => option.name} + getOptionLabel={(option) => option.name} + isDisabled={isLoadingNamespaces} + placeholder="Select namespace..." + className="w-full" + /> +

+ Select the Vault namespace to fetch policies and mount information +

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

+ Select a policy to auto-populate the HCL editor below, or skip to paste your own +

+ +
+ + + <> +