mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 16:27:40 +00:00
feat: in-platform migration tooling for Vault policies + scaffolding
This commit is contained in:
Vendored
+8
@@ -83,6 +83,9 @@ import {
|
|||||||
TExternalKms,
|
TExternalKms,
|
||||||
TExternalKmsInsert,
|
TExternalKmsInsert,
|
||||||
TExternalKmsUpdate,
|
TExternalKmsUpdate,
|
||||||
|
TExternalMigrationConfigs,
|
||||||
|
TExternalMigrationConfigsInsert,
|
||||||
|
TExternalMigrationConfigsUpdate,
|
||||||
TFolderCheckpointResources,
|
TFolderCheckpointResources,
|
||||||
TFolderCheckpointResourcesInsert,
|
TFolderCheckpointResourcesInsert,
|
||||||
TFolderCheckpointResourcesUpdate,
|
TFolderCheckpointResourcesUpdate,
|
||||||
@@ -1345,5 +1348,10 @@ declare module "knex/types/tables" {
|
|||||||
TAdditionalPrivilegesInsert,
|
TAdditionalPrivilegesInsert,
|
||||||
TAdditionalPrivilegesUpdate
|
TAdditionalPrivilegesUpdate
|
||||||
>;
|
>;
|
||||||
|
[TableName.ExternalMigrationConfig]: KnexOriginal.CompositeTableType<
|
||||||
|
TExternalMigrationConfigs,
|
||||||
|
TExternalMigrationConfigsInsert,
|
||||||
|
TExternalMigrationConfigsUpdate
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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.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<void> {
|
||||||
|
await knex.schema.dropTableIfExists(TableName.ExternalMigrationConfig);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.ExternalMigrationConfig);
|
||||||
|
}
|
||||||
@@ -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<typeof ExternalMigrationConfigsSchema>;
|
||||||
|
export type TExternalMigrationConfigsInsert = Omit<z.input<typeof ExternalMigrationConfigsSchema>, TImmutableDBKeys>;
|
||||||
|
export type TExternalMigrationConfigsUpdate = Partial<
|
||||||
|
Omit<z.input<typeof ExternalMigrationConfigsSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -25,6 +25,7 @@ export * from "./dynamic-secrets";
|
|||||||
export * from "./external-certificate-authorities";
|
export * from "./external-certificate-authorities";
|
||||||
export * from "./external-group-org-role-mappings";
|
export * from "./external-group-org-role-mappings";
|
||||||
export * from "./external-kms";
|
export * from "./external-kms";
|
||||||
|
export * from "./external-migration-configs";
|
||||||
export * from "./folder-checkpoint-resources";
|
export * from "./folder-checkpoint-resources";
|
||||||
export * from "./folder-checkpoints";
|
export * from "./folder-checkpoints";
|
||||||
export * from "./folder-commit-changes";
|
export * from "./folder-commit-changes";
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ export enum TableName {
|
|||||||
PamFolder = "pam_folders",
|
PamFolder = "pam_folders",
|
||||||
PamResource = "pam_resources",
|
PamResource = "pam_resources",
|
||||||
PamAccount = "pam_accounts",
|
PamAccount = "pam_accounts",
|
||||||
PamSession = "pam_sessions"
|
PamSession = "pam_sessions",
|
||||||
|
|
||||||
|
ExternalMigrationConfig = "external_migration_configs"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
|
|||||||
import { convertorServiceFactory } from "@app/services/convertor/convertor-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 { 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 { 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 { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue";
|
||||||
import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service";
|
import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service";
|
||||||
import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal";
|
import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal";
|
||||||
@@ -533,6 +534,8 @@ export const registerRoutes = async (
|
|||||||
const membershipRoleDAL = membershipRoleDALFactory(db);
|
const membershipRoleDAL = membershipRoleDALFactory(db);
|
||||||
const roleDAL = roleDALFactory(db);
|
const roleDAL = roleDALFactory(db);
|
||||||
|
|
||||||
|
const externalMigrationConfigDAL = externalMigrationConfigDALFactory(db);
|
||||||
|
|
||||||
const eventBusService = eventBusFactory(server.redis);
|
const eventBusService = eventBusFactory(server.redis);
|
||||||
const sseService = sseServiceFactory(eventBusService, server.redis);
|
const sseService = sseServiceFactory(eventBusService, server.redis);
|
||||||
|
|
||||||
@@ -1873,13 +1876,6 @@ export const registerRoutes = async (
|
|||||||
notificationService
|
notificationService
|
||||||
});
|
});
|
||||||
|
|
||||||
const migrationService = externalMigrationServiceFactory({
|
|
||||||
externalMigrationQueue,
|
|
||||||
userDAL,
|
|
||||||
permissionService,
|
|
||||||
gatewayService
|
|
||||||
});
|
|
||||||
|
|
||||||
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
|
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
|
||||||
permissionService,
|
permissionService,
|
||||||
licenseService,
|
licenseService,
|
||||||
@@ -2196,6 +2192,16 @@ export const registerRoutes = async (
|
|||||||
kmsService
|
kmsService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const migrationService = externalMigrationServiceFactory({
|
||||||
|
externalMigrationQueue,
|
||||||
|
userDAL,
|
||||||
|
permissionService,
|
||||||
|
gatewayService,
|
||||||
|
kmsService,
|
||||||
|
appConnectionService,
|
||||||
|
externalMigrationConfigDAL
|
||||||
|
});
|
||||||
|
|
||||||
// setup the communication with license key server
|
// setup the communication with license key server
|
||||||
await licenseService.init();
|
await licenseService.init();
|
||||||
|
|
||||||
|
|||||||
@@ -113,4 +113,151 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
|
|||||||
return { enabled };
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
|||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
|
|
||||||
import { HCVaultConnectionMethod } from "./hc-vault-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) => {
|
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
|
||||||
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
|
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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
|
||||||
|
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,
|
connection: THCVaultConnection,
|
||||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
) => {
|
) => {
|
||||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>(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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
|
||||||
|
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<THCVaultMountResponse>(connection, gatewayService, {
|
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
|
||||||
url: `${instanceUrl}/v1/sys/mounts`,
|
url: `${instanceUrl}/v1/sys/mounts`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Vault-Token": accessToken,
|
"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]) => {
|
Object.entries(data.data).forEach(([path, mount]) => {
|
||||||
if (mount.type === "kv" && mount.options?.version === "2") {
|
mounts.push({
|
||||||
mounts.push(path);
|
path,
|
||||||
}
|
type: mount.type,
|
||||||
|
version: mount.options?.version
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return mounts;
|
return mounts;
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export const hcVaultConnectionService = (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const mounts = await listHCVaultMounts(appConnection, gatewayService);
|
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) {
|
} catch (error) {
|
||||||
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -33,3 +33,9 @@ export type THCVaultMountResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type THCVaultMount = {
|
||||||
|
path: string;
|
||||||
|
type: string;
|
||||||
|
version?: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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<typeof externalMigrationConfigDALFactory>;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -2,9 +2,21 @@ import { OrgMembershipRole } from "@app/db/schemas";
|
|||||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
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 { TUserDALFactory } from "../user/user-dal";
|
||||||
|
import { TExternalMigrationConfigDALFactory } from "./external-migration-config-dal";
|
||||||
import {
|
import {
|
||||||
decryptEnvKeyDataFn,
|
decryptEnvKeyDataFn,
|
||||||
importVaultDataFn,
|
importVaultDataFn,
|
||||||
@@ -15,6 +27,7 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue";
|
|||||||
import {
|
import {
|
||||||
ExternalMigrationProviders,
|
ExternalMigrationProviders,
|
||||||
ExternalPlatforms,
|
ExternalPlatforms,
|
||||||
|
TConfigureExternalMigrationDTO,
|
||||||
THasCustomVaultMigrationDTO,
|
THasCustomVaultMigrationDTO,
|
||||||
TImportEnvKeyDataDTO,
|
TImportEnvKeyDataDTO,
|
||||||
TImportVaultDataDTO
|
TImportVaultDataDTO
|
||||||
@@ -23,8 +36,11 @@ import {
|
|||||||
type TExternalMigrationServiceFactoryDep = {
|
type TExternalMigrationServiceFactoryDep = {
|
||||||
permissionService: TPermissionServiceFactory;
|
permissionService: TPermissionServiceFactory;
|
||||||
externalMigrationQueue: TExternalMigrationQueueFactory;
|
externalMigrationQueue: TExternalMigrationQueueFactory;
|
||||||
|
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||||
|
externalMigrationConfigDAL: Pick<TExternalMigrationConfigDALFactory, "create" | "upsert" | "findOne" | "transaction">;
|
||||||
userDAL: Pick<TUserDALFactory, "findById">;
|
userDAL: Pick<TUserDALFactory, "findById">;
|
||||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||||
|
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
|
||||||
@@ -33,7 +49,10 @@ export const externalMigrationServiceFactory = ({
|
|||||||
permissionService,
|
permissionService,
|
||||||
externalMigrationQueue,
|
externalMigrationQueue,
|
||||||
userDAL,
|
userDAL,
|
||||||
gatewayService
|
gatewayService,
|
||||||
|
appConnectionService,
|
||||||
|
externalMigrationConfigDAL,
|
||||||
|
kmsService
|
||||||
}: TExternalMigrationServiceFactoryDep) => {
|
}: TExternalMigrationServiceFactoryDep) => {
|
||||||
const importEnvKeyData = async ({
|
const importEnvKeyData = async ({
|
||||||
decryptionKey,
|
decryptionKey,
|
||||||
@@ -171,9 +190,195 @@ export const externalMigrationServiceFactory = ({
|
|||||||
return actorOrgId in vaultMigrationTransformMappings;
|
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 {
|
return {
|
||||||
importEnvKeyData,
|
importEnvKeyData,
|
||||||
importVaultData,
|
importVaultData,
|
||||||
hasCustomVaultMigration
|
hasCustomVaultMigration,
|
||||||
|
configureExternalMigration,
|
||||||
|
getExternalMigrationConfig,
|
||||||
|
getVaultNamespaces,
|
||||||
|
getVaultPolicies,
|
||||||
|
getVaultMounts
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TOrgPermission } from "@app/lib/types";
|
import { OrgServiceActor, TOrgPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||||
|
|
||||||
@@ -121,3 +121,9 @@ export enum ExternalMigrationProviders {
|
|||||||
Vault = "vault",
|
Vault = "vault",
|
||||||
EnvKey = "env-key"
|
EnvKey = "env-key"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TConfigureExternalMigrationDTO = {
|
||||||
|
platform: ExternalMigrationProviders;
|
||||||
|
connectionId: string | null;
|
||||||
|
actor: OrgServiceActor;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./mutations";
|
export * from "./mutations";
|
||||||
export * from "./queries";
|
export * from "./queries";
|
||||||
|
export * from "./types";
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import { projectKeys } from "../projects";
|
import { projectKeys } from "../projects";
|
||||||
|
import { externalMigrationQueryKeys } from "./queries";
|
||||||
|
import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types";
|
||||||
|
|
||||||
export const useImportEnvKey = () => {
|
export const useImportEnvKey = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -65,3 +67,26 @@ export const useImportVault = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useUpdateExternalMigrationConfig = (platform: ExternalMigrationProviders) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<TExternalMigrationConfig, Error, { connectionId: string | null }>({
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import { ExternalMigrationProviders } from "./types";
|
import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types";
|
||||||
|
|
||||||
const externalMigrationQueryKeys = {
|
export const externalMigrationQueryKeys = {
|
||||||
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
|
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
|
||||||
"custom-migration-available",
|
"custom-migration-available",
|
||||||
provider
|
provider
|
||||||
]
|
],
|
||||||
|
config: (platform: string) => ["external-migration-config", { platform }],
|
||||||
|
vaultNamespaces: () => ["vault-namespaces"],
|
||||||
|
vaultPolicies: () => ["vault-policies"],
|
||||||
|
vaultMounts: () => ["vault-mounts"]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
|
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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,3 +2,12 @@ export enum ExternalMigrationProviders {
|
|||||||
Vault = "vault",
|
Vault = "vault",
|
||||||
EnvKey = "env-key"
|
EnvKey = "env-key"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TExternalMigrationConfig = {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
platform: string;
|
||||||
|
connectionId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|||||||
+63
-35
@@ -7,6 +7,7 @@ import { OrgMembershipRole } from "@app/helpers/roles";
|
|||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
|
|
||||||
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
|
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
|
||||||
|
import { VaultConnectionSection } from "./components/VaultConnectionSection";
|
||||||
|
|
||||||
export const ExternalMigrationsTab = () => {
|
export const ExternalMigrationsTab = () => {
|
||||||
const { hasOrgRole } = useOrgPermission();
|
const { hasOrgRole } = useOrgPermission();
|
||||||
@@ -14,45 +15,72 @@ export const ExternalMigrationsTab = () => {
|
|||||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex justify-between">
|
{/* In-Platform Migration Tooling Section */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="border-mineshaft-600 bg-mineshaft-900 rounded-lg border p-4">
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Import from external source</p>
|
<div className="mb-4">
|
||||||
|
<h2 className="text-mineshaft-100 text-xl font-semibold">
|
||||||
|
In-Platform Migration Tooling
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 mt-1 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>
|
{/* Bulk Data Import Section */}
|
||||||
<a
|
<div className="border-mineshaft-600 bg-mineshaft-900 rounded-lg border p-4">
|
||||||
target="_blank"
|
<div className="mb-4">
|
||||||
rel="noopener noreferrer"
|
<h2 className="text-mineshaft-100 text-xl font-semibold">Bulk Data Import</h2>
|
||||||
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
|
<p className="mb-6 mt-1 text-sm text-gray-400">
|
||||||
>
|
Perform one-time bulk imports of data from external platforms.
|
||||||
<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">
|
</p>
|
||||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
|
||||||
Docs
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faArrowUpRightFromSquare}
|
|
||||||
className="text-xxs mb-[0.07rem] ml-1.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-start justify-between">
|
||||||
onClick={() => {
|
<div>
|
||||||
handlePopUpOpen("selectImportPlatform");
|
<div className="flex items-center gap-2">
|
||||||
}}
|
<p className="text-mineshaft-100 text-base font-medium">
|
||||||
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
|
Import from external source
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
</p>
|
||||||
>
|
<a
|
||||||
Import
|
target="_blank"
|
||||||
</Button>
|
rel="noopener noreferrer"
|
||||||
</div>
|
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
|
||||||
<p className="mb-4 text-gray-400">Import data from another platform to Infisical.</p>
|
>
|
||||||
|
<div className="bg-yellow/20 text-yellow inline-block rounded-md px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm 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
|
<Button
|
||||||
isOpen={popUp.selectImportPlatform.isOpen}
|
onClick={() => {
|
||||||
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+100
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src="/images/integrations/Vault.png"
|
||||||
|
alt="HashiCorp Vault logo"
|
||||||
|
className="bg-bunker-500 h-10 w-10 rounded-md p-2"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-mineshaft-100 text-lg font-medium">HashiCorp Vault</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Enable in-platform migration tooling for policy imports and secret engine migrations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md">
|
||||||
|
<FormControl
|
||||||
|
label="HashiCorp Vault Connection"
|
||||||
|
tooltipText="Select an existing App Connection to enable in-platform migration features. Manage connections in the App Connections section."
|
||||||
|
>
|
||||||
|
<FilterableSelect
|
||||||
|
value={selectedConnection}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-mineshaft-400 mt-2 text-xs">
|
||||||
|
Select an existing App Connection to enable in-platform migration features. Manage
|
||||||
|
connections in the App Connections section.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,12 +6,18 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
IconButton
|
IconButton,
|
||||||
|
Tooltip
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
|
import { useOrgPermission } from "@app/context";
|
||||||
|
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||||
import { usePopUp } from "@app/hooks";
|
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 { ProjectType } from "@app/hooks/api/projects/types";
|
||||||
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
|
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
|
||||||
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
|
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
|
||||||
|
import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
@@ -22,9 +28,17 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
|||||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
"addPolicy",
|
"addPolicy",
|
||||||
"addPolicyOptions",
|
"addPolicyOptions",
|
||||||
"applyTemplate"
|
"applyTemplate",
|
||||||
|
"importFromVault"
|
||||||
] as const);
|
] 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
@@ -44,7 +58,7 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
ariaLabel="Open policy template options"
|
ariaLabel="Open policy template options"
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
className="bg-mineshaft-600 rounded-l-none p-3"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faAngleDown} />
|
<FontAwesomeIcon icon={faAngleDown} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -64,6 +78,35 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
|||||||
>
|
>
|
||||||
Add From Template
|
Add From Template
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -77,6 +120,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
|
|||||||
isOpen={popUp.applyTemplate.isOpen}
|
isOpen={popUp.applyTemplate.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
|
||||||
/>
|
/>
|
||||||
|
<VaultPolicyImportModal
|
||||||
|
isOpen={popUp.importFromVault.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+517
@@ -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> = T extends (infer U)[] ? U : never;
|
||||||
|
|
||||||
|
// Extract permission rule types from the form schema
|
||||||
|
type SecretPermissionRule = ArrayElement<
|
||||||
|
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.Secrets]
|
||||||
|
>;
|
||||||
|
type FolderPermissionRule = ArrayElement<
|
||||||
|
NonNullable<TFormSchema["permissions"]>[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<TFormSchema["permissions"]> => {
|
||||||
|
const permissions: Partial<TFormSchema["permissions"]> = {};
|
||||||
|
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<TFormSchema>();
|
||||||
|
const [selectedNamespace, setSelectedNamespace] = useState<string>("default");
|
||||||
|
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,
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="bg-primary/10 text-mineshaft-200 mb-4 rounded-md p-3 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FontAwesomeIcon icon={faInfoCircle} className="text-primary mt-0.5" />
|
||||||
|
<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 intelligently translated using your Vault's KV secret engine mounts to extract environments and secret paths."
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<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);
|
||||||
|
// 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"
|
||||||
|
/>
|
||||||
|
<p className="text-mineshaft-400 mt-1 text-xs">
|
||||||
|
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="text-mineshaft-400 mt-1 text-xs">
|
||||||
|
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="text-mineshaft-400 mt-1 text-xs">
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user