mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 15:27:27 +00:00
feat(external-migrations): vault migrations
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { readLimit } from "@app/server/config/rateLimiter";
|
import { readLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
import { VaultMappingType } from "@app/services/external-migration/external-migration-types";
|
||||||
|
|
||||||
const MB25_IN_BYTES = 26214400;
|
const MB25_IN_BYTES = 26214400;
|
||||||
|
|
||||||
@@ -52,4 +54,30 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/vault",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
vaultAccessToken: z.string(),
|
||||||
|
vaultNamespace: z.string(),
|
||||||
|
vaultUrl: z.string(),
|
||||||
|
mappingType: z.nativeEnum(VaultMappingType)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
await server.services.migration.importVaultData({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
|
|||||||
await server.register(registerUserRouter, { prefix: "/users" });
|
await server.register(registerUserRouter, { prefix: "/users" });
|
||||||
await server.register(registerSecretRouter, { prefix: "/secrets" });
|
await server.register(registerSecretRouter, { prefix: "/secrets" });
|
||||||
await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" });
|
await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" });
|
||||||
await server.register(registerExternalMigrationRouter, { prefix: "/migrate" });
|
await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" });
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-345
@@ -1,32 +1,26 @@
|
|||||||
import slugify from "@sindresorhus/slugify";
|
|
||||||
import sjcl from "sjcl";
|
import sjcl from "sjcl";
|
||||||
import tweetnacl from "tweetnacl";
|
import tweetnacl from "tweetnacl";
|
||||||
import tweetnaclUtil from "tweetnacl-util";
|
import tweetnaclUtil from "tweetnacl-util";
|
||||||
|
|
||||||
import { SecretType, TSecretFolders } from "@app/db/schemas";
|
|
||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
import { crypto } from "@app/lib/crypto/cryptography";
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { chunkArray } from "@app/lib/fn";
|
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
|
||||||
|
|
||||||
import { CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
import { TFolderCommitServiceFactory } from "../../folder-commit/folder-commit-service";
|
||||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
import { TKmsServiceFactory } from "../../kms/kms-service";
|
||||||
import { KmsDataKey } from "../kms/kms-types";
|
import { TProjectDALFactory } from "../../project/project-dal";
|
||||||
import { TProjectDALFactory } from "../project/project-dal";
|
import { TProjectServiceFactory } from "../../project/project-service";
|
||||||
import { TProjectServiceFactory } from "../project/project-service";
|
import { TProjectEnvDALFactory } from "../../project-env/project-env-dal";
|
||||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
import { TProjectEnvServiceFactory } from "../../project-env/project-env-service";
|
||||||
import { TProjectEnvServiceFactory } from "../project-env/project-env-service";
|
import { TResourceMetadataDALFactory } from "../../resource-metadata/resource-metadata-dal";
|
||||||
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
|
import { TSecretFolderDALFactory } from "../../secret-folder/secret-folder-dal";
|
||||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
import { TSecretFolderVersionDALFactory } from "../../secret-folder/secret-folder-version-dal";
|
||||||
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
|
import { TSecretTagDALFactory } from "../../secret-tag/secret-tag-dal";
|
||||||
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
|
import { TSecretV2BridgeDALFactory } from "../../secret-v2-bridge/secret-v2-bridge-dal";
|
||||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
import type { TSecretV2BridgeServiceFactory } from "../../secret-v2-bridge/secret-v2-bridge-service";
|
||||||
import { fnSecretBulkInsert, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns";
|
import { TSecretVersionV2DALFactory } from "../../secret-v2-bridge/secret-version-dal";
|
||||||
import type { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service";
|
import { TSecretVersionV2TagDALFactory } from "../../secret-v2-bridge/secret-version-tag-dal";
|
||||||
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
|
import { InfisicalImportData, TEnvKeyExportJSON, TImportInfisicalDataCreate } from "../external-migration-types";
|
||||||
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
|
|
||||||
import { InfisicalImportData, TEnvKeyExportJSON, TImportInfisicalDataCreate } from "./external-migration-types";
|
|
||||||
|
|
||||||
export type TImportDataIntoInfisicalDTO = {
|
export type TImportDataIntoInfisicalDTO = {
|
||||||
projectDAL: Pick<TProjectDALFactory, "transaction">;
|
projectDAL: Pick<TProjectDALFactory, "transaction">;
|
||||||
@@ -499,326 +493,3 @@ export const parseEnvKeyDataFn = async (decryptedJson: string): Promise<Infisica
|
|||||||
|
|
||||||
return infisicalImportData;
|
return infisicalImportData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const importDataIntoInfisicalFn = async ({
|
|
||||||
projectService,
|
|
||||||
projectEnvDAL,
|
|
||||||
projectDAL,
|
|
||||||
secretDAL,
|
|
||||||
kmsService,
|
|
||||||
secretVersionDAL,
|
|
||||||
secretTagDAL,
|
|
||||||
secretVersionTagDAL,
|
|
||||||
folderDAL,
|
|
||||||
resourceMetadataDAL,
|
|
||||||
folderVersionDAL,
|
|
||||||
folderCommitService,
|
|
||||||
input: { data, actor, actorId, actorOrgId, actorAuthMethod }
|
|
||||||
}: TImportDataIntoInfisicalDTO) => {
|
|
||||||
// Import data to infisical
|
|
||||||
if (!data || !data.projects) {
|
|
||||||
throw new BadRequestError({ message: "No projects found in data" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalToNewProjectId = new Map<string, string>();
|
|
||||||
const originalToNewEnvironmentId = new Map<
|
|
||||||
string,
|
|
||||||
{ envId: string; envSlug: string; rootFolderId: string; projectId: string }
|
|
||||||
>();
|
|
||||||
const originalToNewFolderId = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
folderId: string;
|
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
const projectsNotImported: string[] = [];
|
|
||||||
|
|
||||||
await projectDAL.transaction(async (tx) => {
|
|
||||||
for await (const project of data.projects) {
|
|
||||||
const newProject = await projectService
|
|
||||||
.createProject({
|
|
||||||
actor,
|
|
||||||
actorId,
|
|
||||||
actorOrgId,
|
|
||||||
actorAuthMethod,
|
|
||||||
workspaceName: project.name,
|
|
||||||
createDefaultEnvs: false,
|
|
||||||
tx
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(e, `Failed to import to project [name:${project.name}]`);
|
|
||||||
throw new BadRequestError({ message: `Failed to import to project [name:${project.name}]` });
|
|
||||||
});
|
|
||||||
originalToNewProjectId.set(project.id, newProject.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import environments
|
|
||||||
if (data.environments) {
|
|
||||||
for await (const environment of data.environments) {
|
|
||||||
const projectId = originalToNewProjectId.get(environment.projectId);
|
|
||||||
const slug = slugify(`${environment.name}-${alphaNumericNanoId(4)}`);
|
|
||||||
|
|
||||||
if (!projectId) {
|
|
||||||
projectsNotImported.push(environment.projectId);
|
|
||||||
// eslint-disable-next-line no-continue
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingEnv = await projectEnvDAL.findOne({ projectId, slug }, tx);
|
|
||||||
|
|
||||||
if (existingEnv) {
|
|
||||||
throw new BadRequestError({
|
|
||||||
message: `Environment with slug '${slug}' already exist`,
|
|
||||||
name: "CreateEnvironment"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastPos = await projectEnvDAL.findLastEnvPosition(projectId, tx);
|
|
||||||
const doc = await projectEnvDAL.create({ slug, name: environment.name, projectId, position: lastPos + 1 }, tx);
|
|
||||||
const folder = await folderDAL.create({ name: "root", parentId: null, envId: doc.id, version: 1 }, tx);
|
|
||||||
|
|
||||||
originalToNewEnvironmentId.set(environment.id, {
|
|
||||||
envSlug: doc.slug,
|
|
||||||
envId: doc.id,
|
|
||||||
rootFolderId: folder.id,
|
|
||||||
projectId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.folders) {
|
|
||||||
for await (const folder of data.folders) {
|
|
||||||
const parentEnv = originalToNewEnvironmentId.get(folder.parentFolderId as string);
|
|
||||||
|
|
||||||
if (!parentEnv) {
|
|
||||||
// eslint-disable-next-line no-continue
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newFolder = await folderDAL.create(
|
|
||||||
{
|
|
||||||
name: folder.name,
|
|
||||||
envId: parentEnv.envId,
|
|
||||||
parentId: parentEnv.rootFolderId
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
|
|
||||||
const newFolderVersion = await folderVersionDAL.create(
|
|
||||||
{
|
|
||||||
name: newFolder.name,
|
|
||||||
envId: newFolder.envId,
|
|
||||||
version: newFolder.version,
|
|
||||||
folderId: newFolder.id
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
|
|
||||||
await folderCommitService.createCommit(
|
|
||||||
{
|
|
||||||
actor: {
|
|
||||||
type: actor,
|
|
||||||
metadata: {
|
|
||||||
id: actorId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
message: "Changed by external migration",
|
|
||||||
folderId: parentEnv.rootFolderId,
|
|
||||||
changes: [
|
|
||||||
{
|
|
||||||
type: CommitType.ADD,
|
|
||||||
folderVersionId: newFolderVersion.id
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
|
|
||||||
originalToNewFolderId.set(folder.id, {
|
|
||||||
folderId: newFolder.id,
|
|
||||||
projectId: parentEnv.projectId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Useful for debugging:
|
|
||||||
// console.log("data.secrets", data.secrets);
|
|
||||||
// console.log("data.folders", data.folders);
|
|
||||||
// console.log("data.environment", data.environments);
|
|
||||||
|
|
||||||
if (data.secrets && data.secrets.length > 0) {
|
|
||||||
const mappedToEnvironmentId = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
secretKey: string;
|
|
||||||
secretValue: string;
|
|
||||||
folderId?: string;
|
|
||||||
isFromBlock?: boolean;
|
|
||||||
}[]
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const secret of data.secrets) {
|
|
||||||
const targetId = secret.folderId || secret.environmentId;
|
|
||||||
|
|
||||||
// Skip if we can't find either an environment or folder mapping for this secret
|
|
||||||
if (!originalToNewEnvironmentId.get(secret.environmentId) && !originalToNewFolderId.get(targetId)) {
|
|
||||||
logger.info({ secret }, "[importDataIntoInfisicalFn]: Could not find environment or folder for secret");
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-continue
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mappedToEnvironmentId.has(targetId)) {
|
|
||||||
mappedToEnvironmentId.set(targetId, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyHasSecret = mappedToEnvironmentId
|
|
||||||
.get(targetId)!
|
|
||||||
.find((el) => el.secretKey === secret.name && el.folderId === secret.folderId);
|
|
||||||
|
|
||||||
if (alreadyHasSecret && alreadyHasSecret.isFromBlock) {
|
|
||||||
// remove the existing secret if any
|
|
||||||
mappedToEnvironmentId
|
|
||||||
.get(targetId)!
|
|
||||||
.splice(mappedToEnvironmentId.get(targetId)!.indexOf(alreadyHasSecret), 1);
|
|
||||||
}
|
|
||||||
mappedToEnvironmentId.get(targetId)!.push({
|
|
||||||
secretKey: secret.name,
|
|
||||||
secretValue: secret.value || "",
|
|
||||||
folderId: secret.folderId,
|
|
||||||
isFromBlock: secret.appBlockOrderIndex !== undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// for each of the mappedEnvironmentId
|
|
||||||
for await (const [targetId, secrets] of mappedToEnvironmentId) {
|
|
||||||
logger.info("[importDataIntoInfisicalFn]: Processing secrets for targetId", targetId);
|
|
||||||
|
|
||||||
let selectedFolder: TSecretFolders | undefined;
|
|
||||||
let selectedProjectId: string | undefined;
|
|
||||||
|
|
||||||
// Case 1: Secret belongs to a folder / branch / branch of a block
|
|
||||||
const foundFolder = originalToNewFolderId.get(targetId);
|
|
||||||
if (foundFolder) {
|
|
||||||
logger.info("[importDataIntoInfisicalFn]: Processing secrets for folder");
|
|
||||||
selectedFolder = await folderDAL.findById(foundFolder.folderId, tx);
|
|
||||||
selectedProjectId = foundFolder.projectId;
|
|
||||||
} else {
|
|
||||||
logger.info("[importDataIntoInfisicalFn]: Processing secrets for normal environment");
|
|
||||||
const environment = data.environments.find((env) => env.id === targetId);
|
|
||||||
if (!environment) {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
targetId
|
|
||||||
},
|
|
||||||
"[importDataIntoInfisicalFn]: Could not find environment for secret"
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line no-continue
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = originalToNewProjectId.get(environment.projectId)!;
|
|
||||||
|
|
||||||
if (!projectId) {
|
|
||||||
throw new BadRequestError({ message: `Failed to import secret, project not found` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = originalToNewEnvironmentId.get(targetId);
|
|
||||||
if (!env) {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
targetId
|
|
||||||
},
|
|
||||||
"[importDataIntoInfisicalFn]: Could not find environment for secret"
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-continue
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, env.envSlug, "/", tx);
|
|
||||||
|
|
||||||
if (!folder) {
|
|
||||||
throw new NotFoundError({
|
|
||||||
message: `Folder not found for the given environment slug (${env.envSlug}) & secret path (/)`,
|
|
||||||
name: "Create secret"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFolder = folder;
|
|
||||||
selectedProjectId = projectId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedFolder) {
|
|
||||||
throw new NotFoundError({
|
|
||||||
message: `Folder not found for the given environment slug & secret path`,
|
|
||||||
name: "CreateSecret"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedProjectId) {
|
|
||||||
throw new NotFoundError({
|
|
||||||
message: `Project not found for the given environment slug & secret path`,
|
|
||||||
name: "CreateSecret"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { encryptor: secretManagerEncrypt } = await kmsService.createCipherPairWithDataKey(
|
|
||||||
{
|
|
||||||
type: KmsDataKey.SecretManager,
|
|
||||||
projectId: selectedProjectId
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
|
|
||||||
const secretBatches = chunkArray(secrets, 2500);
|
|
||||||
for await (const secretBatch of secretBatches) {
|
|
||||||
const secretsByKeys = await secretDAL.findBySecretKeys(
|
|
||||||
selectedFolder.id,
|
|
||||||
secretBatch.map((el) => ({
|
|
||||||
key: el.secretKey,
|
|
||||||
type: SecretType.Shared
|
|
||||||
})),
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
if (secretsByKeys.length) {
|
|
||||||
throw new BadRequestError({
|
|
||||||
message: `Secret already exist: ${secretsByKeys.map((el) => el.key).join(",")}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await fnSecretBulkInsert({
|
|
||||||
inputSecrets: secretBatch.map((el) => {
|
|
||||||
const references = getAllSecretReferences(el.secretValue).nestedReferences;
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
encryptedValue: el.secretValue
|
|
||||||
? secretManagerEncrypt({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob
|
|
||||||
: undefined,
|
|
||||||
key: el.secretKey,
|
|
||||||
references,
|
|
||||||
type: SecretType.Shared
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
folderId: selectedFolder.id,
|
|
||||||
orgId: actorOrgId,
|
|
||||||
resourceMetadataDAL,
|
|
||||||
secretDAL,
|
|
||||||
secretVersionDAL,
|
|
||||||
secretTagDAL,
|
|
||||||
secretVersionTagDAL,
|
|
||||||
folderCommitService,
|
|
||||||
actor: {
|
|
||||||
type: actor,
|
|
||||||
actorId
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { projectsNotImported };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
|
||||||
|
import { SecretType, TSecretFolders } from "@app/db/schemas";
|
||||||
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
|
import { chunkArray } from "@app/lib/fn";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
import { CommitType } from "@app/services/folder-commit/folder-commit-service";
|
||||||
|
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||||
|
import { fnSecretBulkInsert, getAllSecretReferences } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
|
||||||
|
|
||||||
|
import { TImportDataIntoInfisicalDTO } from "./envkey";
|
||||||
|
|
||||||
|
export const importDataIntoInfisicalFn = async ({
|
||||||
|
projectService,
|
||||||
|
projectEnvDAL,
|
||||||
|
projectDAL,
|
||||||
|
secretDAL,
|
||||||
|
kmsService,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
folderDAL,
|
||||||
|
resourceMetadataDAL,
|
||||||
|
folderVersionDAL,
|
||||||
|
folderCommitService,
|
||||||
|
input: { data, actor, actorId, actorOrgId, actorAuthMethod }
|
||||||
|
}: TImportDataIntoInfisicalDTO) => {
|
||||||
|
// Import data to infisical
|
||||||
|
if (!data || !data.projects) {
|
||||||
|
throw new BadRequestError({ message: "No projects found in data" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalToNewProjectId = new Map<string, string>();
|
||||||
|
const originalToNewEnvironmentId = new Map<
|
||||||
|
string,
|
||||||
|
{ envId: string; envSlug: string; rootFolderId?: string; projectId: string }
|
||||||
|
>();
|
||||||
|
const originalToNewFolderId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
envId: string;
|
||||||
|
envSlug: string;
|
||||||
|
folderId: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const projectsNotImported: string[] = [];
|
||||||
|
|
||||||
|
await projectDAL.transaction(async (tx) => {
|
||||||
|
for await (const project of data.projects) {
|
||||||
|
const newProject = await projectService
|
||||||
|
.createProject({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
workspaceName: project.name,
|
||||||
|
createDefaultEnvs: false,
|
||||||
|
tx
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error(e, `Failed to import to project [name:${project.name}]`);
|
||||||
|
throw new BadRequestError({ message: `Failed to import to project [name:${project.name}]` });
|
||||||
|
});
|
||||||
|
originalToNewProjectId.set(project.id, newProject.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import environments
|
||||||
|
if (data.environments) {
|
||||||
|
for await (const environment of data.environments) {
|
||||||
|
const projectId = originalToNewProjectId.get(environment.projectId);
|
||||||
|
const slug = slugify(`${environment.name}-${alphaNumericNanoId(4)}`);
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
projectsNotImported.push(environment.projectId);
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEnv = await projectEnvDAL.findOne({ projectId, slug }, tx);
|
||||||
|
|
||||||
|
if (existingEnv) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Environment with slug '${slug}' already exist`,
|
||||||
|
name: "CreateEnvironment"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPos = await projectEnvDAL.findLastEnvPosition(projectId, tx);
|
||||||
|
const doc = await projectEnvDAL.create({ slug, name: environment.name, projectId, position: lastPos + 1 }, tx);
|
||||||
|
const folder = await folderDAL.create({ name: "root", parentId: null, envId: doc.id, version: 1 }, tx);
|
||||||
|
|
||||||
|
originalToNewEnvironmentId.set(environment.id, {
|
||||||
|
envSlug: doc.slug,
|
||||||
|
envId: doc.id,
|
||||||
|
rootFolderId: folder.id,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.folders) {
|
||||||
|
for await (const folder of data.folders) {
|
||||||
|
const parentEnv = originalToNewEnvironmentId.get(folder.parentFolderId as string);
|
||||||
|
const parentFolder = originalToNewFolderId.get(folder.parentFolderId as string);
|
||||||
|
|
||||||
|
let newFolder: TSecretFolders;
|
||||||
|
|
||||||
|
if (parentEnv?.rootFolderId) {
|
||||||
|
newFolder = await folderDAL.create(
|
||||||
|
{
|
||||||
|
name: folder.name,
|
||||||
|
envId: parentEnv.envId,
|
||||||
|
parentId: parentEnv.rootFolderId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
} else if (parentFolder) {
|
||||||
|
newFolder = await folderDAL.create(
|
||||||
|
{
|
||||||
|
name: folder.name,
|
||||||
|
envId: parentFolder.envId,
|
||||||
|
parentId: parentFolder.folderId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info({ folder }, "No parent environment found for folder");
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFolderVersion = await folderVersionDAL.create(
|
||||||
|
{
|
||||||
|
name: newFolder.name,
|
||||||
|
envId: newFolder.envId,
|
||||||
|
version: newFolder.version,
|
||||||
|
folderId: newFolder.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await folderCommitService.createCommit(
|
||||||
|
{
|
||||||
|
actor: {
|
||||||
|
type: actor,
|
||||||
|
metadata: {
|
||||||
|
id: actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Changed by external migration",
|
||||||
|
folderId: parentEnv?.rootFolderId || parentFolder?.folderId || "",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: CommitType.ADD,
|
||||||
|
folderVersionId: newFolderVersion.id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
originalToNewFolderId.set(folder.id, {
|
||||||
|
folderId: newFolder.id,
|
||||||
|
envId: parentEnv?.envId || parentFolder?.envId || "",
|
||||||
|
envSlug: parentEnv?.envSlug || parentFolder?.envSlug || "",
|
||||||
|
projectId: parentEnv?.projectId || parentFolder?.projectId || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Useful for debugging:
|
||||||
|
// console.log("data.secrets", data.secrets);
|
||||||
|
// console.log("data.folders", data.folders);
|
||||||
|
// console.log("data.environment", data.environments);
|
||||||
|
|
||||||
|
if (data.secrets && data.secrets.length > 0) {
|
||||||
|
const mappedToEnvironmentId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
secretKey: string;
|
||||||
|
secretValue: string;
|
||||||
|
folderId?: string;
|
||||||
|
isFromBlock?: boolean;
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const secret of data.secrets) {
|
||||||
|
const targetId = secret.folderId || secret.environmentId;
|
||||||
|
|
||||||
|
// Skip if we can't find either an environment or folder mapping for this secret
|
||||||
|
if (!originalToNewEnvironmentId.get(secret.environmentId) && !originalToNewFolderId.get(targetId)) {
|
||||||
|
logger.info({ secret }, "[importDataIntoInfisicalFn]: Could not find environment or folder for secret");
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mappedToEnvironmentId.has(targetId)) {
|
||||||
|
mappedToEnvironmentId.set(targetId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyHasSecret = mappedToEnvironmentId
|
||||||
|
.get(targetId)!
|
||||||
|
.find((el) => el.secretKey === secret.name && el.folderId === secret.folderId);
|
||||||
|
|
||||||
|
if (alreadyHasSecret && alreadyHasSecret.isFromBlock) {
|
||||||
|
// remove the existing secret if any
|
||||||
|
mappedToEnvironmentId
|
||||||
|
.get(targetId)!
|
||||||
|
.splice(mappedToEnvironmentId.get(targetId)!.indexOf(alreadyHasSecret), 1);
|
||||||
|
}
|
||||||
|
mappedToEnvironmentId.get(targetId)!.push({
|
||||||
|
secretKey: secret.name,
|
||||||
|
secretValue: secret.value || "",
|
||||||
|
folderId: secret.folderId,
|
||||||
|
isFromBlock: secret.appBlockOrderIndex !== undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// for each of the mappedEnvironmentId
|
||||||
|
for await (const [targetId, secrets] of mappedToEnvironmentId) {
|
||||||
|
logger.info("[importDataIntoInfisicalFn]: Processing secrets for targetId", targetId);
|
||||||
|
|
||||||
|
let selectedFolder: TSecretFolders | undefined;
|
||||||
|
let selectedProjectId: string | undefined;
|
||||||
|
|
||||||
|
// Case 1: Secret belongs to a folder / branch / branch of a block
|
||||||
|
const foundFolder = originalToNewFolderId.get(targetId);
|
||||||
|
if (foundFolder) {
|
||||||
|
logger.info("[importDataIntoInfisicalFn]: Processing secrets for folder");
|
||||||
|
selectedFolder = await folderDAL.findById(foundFolder.folderId, tx);
|
||||||
|
selectedProjectId = foundFolder.projectId;
|
||||||
|
} else {
|
||||||
|
logger.info("[importDataIntoInfisicalFn]: Processing secrets for normal environment");
|
||||||
|
const environment = data.environments.find((env) => env.id === targetId);
|
||||||
|
if (!environment) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
targetId
|
||||||
|
},
|
||||||
|
"[importDataIntoInfisicalFn]: Could not find environment for secret"
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = originalToNewProjectId.get(environment.projectId)!;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
throw new BadRequestError({ message: `Failed to import secret, project not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = originalToNewEnvironmentId.get(targetId);
|
||||||
|
if (!env) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
targetId
|
||||||
|
},
|
||||||
|
"[importDataIntoInfisicalFn]: Could not find environment for secret"
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = await folderDAL.findBySecretPath(projectId, env.envSlug, "/", tx);
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Folder not found for the given environment slug (${env.envSlug}) & secret path (/)`,
|
||||||
|
name: "Create secret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFolder = folder;
|
||||||
|
selectedProjectId = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedFolder) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Folder not found for the given environment slug & secret path`,
|
||||||
|
name: "CreateSecret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedProjectId) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Project not found for the given environment slug & secret path`,
|
||||||
|
name: "CreateSecret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { encryptor: secretManagerEncrypt } = await kmsService.createCipherPairWithDataKey(
|
||||||
|
{
|
||||||
|
type: KmsDataKey.SecretManager,
|
||||||
|
projectId: selectedProjectId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const secretBatches = chunkArray(secrets, 2500);
|
||||||
|
for await (const secretBatch of secretBatches) {
|
||||||
|
const secretsByKeys = await secretDAL.findBySecretKeys(
|
||||||
|
selectedFolder.id,
|
||||||
|
secretBatch.map((el) => ({
|
||||||
|
key: el.secretKey,
|
||||||
|
type: SecretType.Shared
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
if (secretsByKeys.length) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret already exist: ${secretsByKeys.map((el) => el.key).join(",")}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await fnSecretBulkInsert({
|
||||||
|
inputSecrets: secretBatch.map((el) => {
|
||||||
|
const references = getAllSecretReferences(el.secretValue).nestedReferences;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
encryptedValue: el.secretValue
|
||||||
|
? secretManagerEncrypt({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob
|
||||||
|
: undefined,
|
||||||
|
key: el.secretKey,
|
||||||
|
references,
|
||||||
|
type: SecretType.Shared
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
folderId: selectedFolder.id,
|
||||||
|
orgId: actorOrgId,
|
||||||
|
resourceMetadataDAL,
|
||||||
|
secretDAL,
|
||||||
|
secretVersionDAL,
|
||||||
|
secretTagDAL,
|
||||||
|
secretVersionTagDAL,
|
||||||
|
folderCommitService,
|
||||||
|
actor: {
|
||||||
|
type: actor,
|
||||||
|
actorId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { projectsNotImported };
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./envkey";
|
||||||
|
export * from "./import";
|
||||||
|
export * from "./vault";
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import { InfisicalImportData, VaultMappingType } from "../external-migration-types";
|
||||||
|
|
||||||
|
type VaultData = {
|
||||||
|
namespace: string;
|
||||||
|
mount: string;
|
||||||
|
path: string;
|
||||||
|
secretData: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const vaultFactory = () => {
|
||||||
|
const getMounts = async (request: AxiosInstance) => {
|
||||||
|
const response = await request.get<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
accessor: string;
|
||||||
|
options: {
|
||||||
|
version?: string;
|
||||||
|
} | null;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>("/v1/sys/mounts");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaths = async (
|
||||||
|
request: AxiosInstance,
|
||||||
|
{ mountPath, secretPath = "" }: { mountPath: string; secretPath?: string }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// For KV v2: /v1/{mount}/metadata/{path}?list=true
|
||||||
|
const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
|
||||||
|
const response = await request.get<{
|
||||||
|
data: {
|
||||||
|
keys: string[];
|
||||||
|
};
|
||||||
|
}>(`/v1/${path}?list=true`);
|
||||||
|
|
||||||
|
return response.data.data.keys;
|
||||||
|
} catch (err) {
|
||||||
|
if (axios.isAxiosError(err) && err.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecrets = async (
|
||||||
|
request: AxiosInstance,
|
||||||
|
{ mountPath, secretPath }: { mountPath: string; secretPath: string }
|
||||||
|
) => {
|
||||||
|
// For KV v2: /v1/{mount}/data/{path}
|
||||||
|
const response = await request.get<{
|
||||||
|
data: {
|
||||||
|
data: Record<string, string>; // KV v2 has nested data structure
|
||||||
|
metadata: {
|
||||||
|
created_time: string;
|
||||||
|
deletion_time: string;
|
||||||
|
destroyed: boolean;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>(`/v1/${mountPath}/data/${secretPath}`);
|
||||||
|
|
||||||
|
return response.data.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper function to check if a mount is KV v2 (will be useful if we add support for Vault KV v1)
|
||||||
|
// const isKvV2Mount = (mountInfo: { type: string; options?: { version?: string } | null }) => {
|
||||||
|
// return mountInfo.type === "kv" && mountInfo.options?.version === "2";
|
||||||
|
// };
|
||||||
|
|
||||||
|
const recursivelyGetAllPaths = async (
|
||||||
|
request: AxiosInstance,
|
||||||
|
mountPath: string,
|
||||||
|
currentPath: string = ""
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const paths = await getPaths(request, { mountPath, secretPath: currentPath });
|
||||||
|
|
||||||
|
if (paths === null || paths.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSecrets: string[] = [];
|
||||||
|
|
||||||
|
for await (const path of paths) {
|
||||||
|
const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path;
|
||||||
|
const fullItemPath = currentPath ? `${currentPath}/${cleanPath}` : cleanPath;
|
||||||
|
|
||||||
|
if (path.endsWith("/")) {
|
||||||
|
// it's a folder so we recurse into it
|
||||||
|
const subSecrets = await recursivelyGetAllPaths(request, mountPath, fullItemPath);
|
||||||
|
allSecrets.push(...subSecrets);
|
||||||
|
} else {
|
||||||
|
// it's a secret so we add it to our results
|
||||||
|
allSecrets.push(`${mountPath}/${fullItemPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSecrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function collectVaultData({
|
||||||
|
baseUrl,
|
||||||
|
namespace,
|
||||||
|
accessToken
|
||||||
|
}: {
|
||||||
|
baseUrl: string;
|
||||||
|
namespace: string;
|
||||||
|
accessToken: string;
|
||||||
|
}): Promise<VaultData[]> {
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
headers: {
|
||||||
|
"X-Vault-Namespace": namespace,
|
||||||
|
"X-Vault-Token": accessToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allData: VaultData[] = [];
|
||||||
|
|
||||||
|
// Get all mounts in this namespace
|
||||||
|
const mounts = await getMounts(request);
|
||||||
|
|
||||||
|
for (const mount of Object.keys(mounts)) {
|
||||||
|
if (!mount.endsWith("/")) {
|
||||||
|
delete mounts[mount];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const [mountPath, mountInfo] of Object.entries(mounts)) {
|
||||||
|
// skip non-KV mounts
|
||||||
|
if (!mountInfo.type.startsWith("kv")) {
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all paths in this mount
|
||||||
|
const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`);
|
||||||
|
|
||||||
|
const cleanMountPath = mountPath.replace(/\/$/, "");
|
||||||
|
|
||||||
|
for await (const secretPath of paths) {
|
||||||
|
// get the actual secret data
|
||||||
|
const secretData = await getSecrets(request, {
|
||||||
|
mountPath: cleanMountPath,
|
||||||
|
secretPath: secretPath.replace(`${cleanMountPath}/`, "")
|
||||||
|
});
|
||||||
|
|
||||||
|
allData.push({
|
||||||
|
namespace,
|
||||||
|
mount: mountPath.replace(/\/$/, ""),
|
||||||
|
path: secretPath.replace(`${cleanMountPath}/`, ""),
|
||||||
|
secretData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
collectVaultData,
|
||||||
|
getMounts,
|
||||||
|
getPaths,
|
||||||
|
getSecrets,
|
||||||
|
recursivelyGetAllPaths
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformToInfisicalFormatNamespaceToProjects = (
|
||||||
|
vaultData: VaultData[],
|
||||||
|
mappingType: VaultMappingType
|
||||||
|
): InfisicalImportData => {
|
||||||
|
const projects: Array<{ name: string; id: string }> = [];
|
||||||
|
const environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }> = [];
|
||||||
|
const folders: Array<{ id: string; name: string; environmentId: string; parentFolderId?: string }> = [];
|
||||||
|
const secrets: Array<{ id: string; name: string; environmentId: string; value: string; folderId?: string }> = [];
|
||||||
|
|
||||||
|
// track created entities to avoid duplicates
|
||||||
|
const projectMap = new Map<string, string>(); // namespace -> projectId
|
||||||
|
const environmentMap = new Map<string, string>(); // namespace:mount -> environmentId
|
||||||
|
const folderMap = new Map<string, string>(); // namespace:mount:folderPath -> folderId
|
||||||
|
|
||||||
|
let environmentId: string = "";
|
||||||
|
for (const data of vaultData) {
|
||||||
|
const { namespace, mount, path, secretData } = data;
|
||||||
|
|
||||||
|
if (mappingType === "namespace") {
|
||||||
|
// create project (namespace)
|
||||||
|
if (!projectMap.has(namespace)) {
|
||||||
|
const projectId = uuidv4();
|
||||||
|
projectMap.set(namespace, projectId);
|
||||||
|
projects.push({
|
||||||
|
name: namespace,
|
||||||
|
id: projectId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const projectId = projectMap.get(namespace)!;
|
||||||
|
|
||||||
|
// create environment (mount)
|
||||||
|
const envKey = `${namespace}:${mount}`;
|
||||||
|
if (!environmentMap.has(envKey)) {
|
||||||
|
environmentId = uuidv4();
|
||||||
|
environmentMap.set(envKey, environmentId);
|
||||||
|
environments.push({
|
||||||
|
name: mount,
|
||||||
|
id: environmentId,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
environmentId = environmentMap.get(envKey)!;
|
||||||
|
} else if (mappingType === "key-vault") {
|
||||||
|
if (!projectMap.has(mount)) {
|
||||||
|
const projectId = uuidv4();
|
||||||
|
projectMap.set(mount, projectId);
|
||||||
|
projects.push({
|
||||||
|
name: mount,
|
||||||
|
id: projectId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const projectId = projectMap.get(mount)!;
|
||||||
|
|
||||||
|
// create single "Production" environment per project, because we have no good way of determining environments from vault
|
||||||
|
if (!environmentMap.has(mount)) {
|
||||||
|
environmentId = uuidv4();
|
||||||
|
environmentMap.set(mount, environmentId);
|
||||||
|
environments.push({
|
||||||
|
name: "Production",
|
||||||
|
id: environmentId,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
environmentId = environmentMap.get(mount)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create folder structure
|
||||||
|
let currentFolderId: string | undefined;
|
||||||
|
let currentPath = "";
|
||||||
|
|
||||||
|
if (path.includes("/")) {
|
||||||
|
const pathParts = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
const folderParts = pathParts;
|
||||||
|
|
||||||
|
// create nested folder structure for the entire path
|
||||||
|
for (const folderName of folderParts) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||||
|
const folderKey = `${namespace}:${mount}:${currentPath}`;
|
||||||
|
|
||||||
|
if (!folderMap.has(folderKey)) {
|
||||||
|
const folderId = uuidv4();
|
||||||
|
folderMap.set(folderKey, folderId);
|
||||||
|
folders.push({
|
||||||
|
id: folderId,
|
||||||
|
name: folderName,
|
||||||
|
environmentId,
|
||||||
|
parentFolderId: currentFolderId || environmentId
|
||||||
|
});
|
||||||
|
currentFolderId = folderId;
|
||||||
|
} else {
|
||||||
|
currentFolderId = folderMap.get(folderKey)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(secretData)) {
|
||||||
|
secrets.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: key,
|
||||||
|
environmentId,
|
||||||
|
value: String(value),
|
||||||
|
folderId: currentFolderId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
environments,
|
||||||
|
folders,
|
||||||
|
secrets
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importVaultDataFn = async ({
|
||||||
|
vaultAccessToken,
|
||||||
|
vaultNamespace,
|
||||||
|
vaultUrl,
|
||||||
|
mappingType
|
||||||
|
}: {
|
||||||
|
vaultAccessToken: string;
|
||||||
|
vaultNamespace: string;
|
||||||
|
vaultUrl: string;
|
||||||
|
mappingType: VaultMappingType;
|
||||||
|
}) => {
|
||||||
|
const vaultApi = vaultFactory();
|
||||||
|
|
||||||
|
const vaultData = await vaultApi.collectVaultData({
|
||||||
|
accessToken: vaultAccessToken,
|
||||||
|
baseUrl: vaultUrl,
|
||||||
|
namespace: vaultNamespace
|
||||||
|
});
|
||||||
|
|
||||||
|
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType);
|
||||||
|
|
||||||
|
return infisicalData;
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-d
|
|||||||
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
|
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
|
||||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||||
import { importDataIntoInfisicalFn } from "./external-migration-fns";
|
import { importDataIntoInfisicalFn } from "./external-migration-fns";
|
||||||
import { ExternalPlatforms, TImportInfisicalDataCreate } from "./external-migration-types";
|
import { ExternalPlatforms, ImportType, TImportInfisicalDataCreate } from "./external-migration-types";
|
||||||
|
|
||||||
export type TExternalMigrationQueueFactoryDep = {
|
export type TExternalMigrationQueueFactoryDep = {
|
||||||
smtpService: TSmtpService;
|
smtpService: TSmtpService;
|
||||||
@@ -67,6 +67,7 @@ export const externalMigrationQueueFactory = ({
|
|||||||
const startImport = async (dto: {
|
const startImport = async (dto: {
|
||||||
actorEmail: string;
|
actorEmail: string;
|
||||||
data: {
|
data: {
|
||||||
|
importType: ImportType;
|
||||||
iv: string;
|
iv: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { crypto } from "@app/lib/crypto/cryptography";
|
|||||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
import { TUserDALFactory } from "../user/user-dal";
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { decryptEnvKeyDataFn, parseEnvKeyDataFn } from "./external-migration-fns";
|
import { decryptEnvKeyDataFn, importVaultDataFn, parseEnvKeyDataFn } from "./external-migration-fns";
|
||||||
import { TExternalMigrationQueueFactory } from "./external-migration-queue";
|
import { TExternalMigrationQueueFactory } from "./external-migration-queue";
|
||||||
import { TImportEnvKeyDataCreate } from "./external-migration-types";
|
import { ImportType, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types";
|
||||||
|
|
||||||
type TExternalMigrationServiceFactoryDep = {
|
type TExternalMigrationServiceFactoryDep = {
|
||||||
permissionService: TPermissionServiceFactory;
|
permissionService: TPermissionServiceFactory;
|
||||||
@@ -28,7 +28,7 @@ export const externalMigrationServiceFactory = ({
|
|||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod
|
actorAuthMethod
|
||||||
}: TImportEnvKeyDataCreate) => {
|
}: TImportEnvKeyDataDTO) => {
|
||||||
if (crypto.isFipsModeEnabled()) {
|
if (crypto.isFipsModeEnabled()) {
|
||||||
throw new BadRequestError({ message: "EnvKey migration is not supported when running in FIPS mode." });
|
throw new BadRequestError({ message: "EnvKey migration is not supported when running in FIPS mode." });
|
||||||
}
|
}
|
||||||
@@ -60,11 +60,65 @@ export const externalMigrationServiceFactory = ({
|
|||||||
|
|
||||||
await externalMigrationQueue.startImport({
|
await externalMigrationQueue.startImport({
|
||||||
actorEmail: user.email!,
|
actorEmail: user.email!,
|
||||||
data: encrypted
|
data: {
|
||||||
|
importType: ImportType.EnvKey,
|
||||||
|
...encrypted
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const importVaultData = async ({
|
||||||
|
vaultAccessToken,
|
||||||
|
vaultNamespace,
|
||||||
|
mappingType,
|
||||||
|
vaultUrl,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod
|
||||||
|
}: TImportVaultDataDTO) => {
|
||||||
|
const { membership } = await permissionService.getOrgPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (membership.role !== OrgMembershipRole.Admin) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Only admins can import data" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userDAL.findById(actorId);
|
||||||
|
|
||||||
|
const vaultData = await importVaultDataFn({
|
||||||
|
vaultAccessToken,
|
||||||
|
vaultNamespace,
|
||||||
|
vaultUrl,
|
||||||
|
mappingType
|
||||||
|
});
|
||||||
|
|
||||||
|
const stringifiedJson = JSON.stringify({
|
||||||
|
data: vaultData,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod
|
||||||
|
});
|
||||||
|
|
||||||
|
const encrypted = crypto.encryption().symmetric().encryptWithRootEncryptionKey(stringifiedJson);
|
||||||
|
|
||||||
|
await externalMigrationQueue.startImport({
|
||||||
|
actorEmail: user.email!,
|
||||||
|
data: {
|
||||||
|
importType: ImportType.Vault,
|
||||||
|
...encrypted
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
importEnvKeyData
|
importEnvKeyData,
|
||||||
|
importVaultData
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
|
import { TOrgPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||||
|
|
||||||
|
export enum ImportType {
|
||||||
|
EnvKey = "envkey",
|
||||||
|
Vault = "vault"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VaultMappingType {
|
||||||
|
Namespace = "namespace",
|
||||||
|
KeyVault = "key-vault"
|
||||||
|
}
|
||||||
|
|
||||||
export type InfisicalImportData = {
|
export type InfisicalImportData = {
|
||||||
projects: Array<{ name: string; id: string }>;
|
projects: Array<{ name: string; id: string }>;
|
||||||
environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }>;
|
environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }>;
|
||||||
@@ -14,14 +26,17 @@ export type InfisicalImportData = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TImportEnvKeyDataCreate = {
|
export type TImportEnvKeyDataDTO = {
|
||||||
decryptionKey: string;
|
decryptionKey: string;
|
||||||
encryptedJson: { nonce: string; data: string };
|
encryptedJson: { nonce: string; data: string };
|
||||||
actor: ActorType;
|
} & Omit<TOrgPermission, "orgId">;
|
||||||
actorId: string;
|
|
||||||
actorOrgId: string;
|
export type TImportVaultDataDTO = {
|
||||||
actorAuthMethod: ActorAuthMethod;
|
vaultAccessToken: string;
|
||||||
};
|
vaultNamespace: string;
|
||||||
|
mappingType: VaultMappingType;
|
||||||
|
vaultUrl: string;
|
||||||
|
} & Omit<TOrgPermission, "orgId">;
|
||||||
|
|
||||||
export type TImportInfisicalDataCreate = {
|
export type TImportInfisicalDataCreate = {
|
||||||
data: InfisicalImportData;
|
data: InfisicalImportData;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./mutations";
|
||||||
@@ -15,7 +15,7 @@ export const useImportEnvKey = () => {
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiRequest.post("/api/v3/migrate/env-key/", formData, {
|
const response = await apiRequest.post("/api/v3/external-migration/env-key/", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data"
|
"Content-Type": "multipart/form-data"
|
||||||
},
|
},
|
||||||
@@ -39,3 +39,23 @@ export const useImportEnvKey = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useImportVault = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
vaultAccessToken,
|
||||||
|
vaultNamespace,
|
||||||
|
vaultUrl
|
||||||
|
}: {
|
||||||
|
vaultAccessToken: string;
|
||||||
|
vaultNamespace: string;
|
||||||
|
vaultUrl: string;
|
||||||
|
}) => {
|
||||||
|
await apiRequest.post("/api/v3/external-migration/vault/", {
|
||||||
|
vaultAccessToken,
|
||||||
|
vaultNamespace,
|
||||||
|
vaultUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
+33
-13
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
import { faKey, faVault } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
import { Modal, ModalContent } from "@app/components/v2";
|
import { Modal, ModalContent } from "@app/components/v2";
|
||||||
|
|
||||||
import { EnvKeyPlatformModal } from "./EnvKeyPlatformModal";
|
import { EnvKeyPlatformModal } from "./EnvKeyPlatformModal";
|
||||||
|
import { VaultPlatformModal } from "./VaultPlatformModal copy";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
@@ -22,6 +23,11 @@ const PLATFORM_LIST = [
|
|||||||
icon: faKey,
|
icon: faKey,
|
||||||
platform: "env-key",
|
platform: "env-key",
|
||||||
title: "Env Key"
|
title: "Env Key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: faVault,
|
||||||
|
platform: "vault",
|
||||||
|
title: "Vault"
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -82,18 +88,32 @@ export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{wizardStep === WizardSteps.PlatformInputs &&
|
{wizardStep === WizardSteps.PlatformInputs && (
|
||||||
selectedPlatform?.platform === "env-key" && (
|
<>
|
||||||
<motion.div
|
{selectedPlatform?.platform === "env-key" && (
|
||||||
key="env-key-step"
|
<motion.div
|
||||||
transition={{ duration: 0.1 }}
|
key="env-key-step"
|
||||||
initial={{ opacity: 0, translateX: 30 }}
|
transition={{ duration: 0.1 }}
|
||||||
animate={{ opacity: 1, translateX: 0 }}
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
exit={{ opacity: 0, translateX: -30 }}
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
>
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
<EnvKeyPlatformModal onClose={() => handleFormReset(false)} />
|
>
|
||||||
</motion.div>
|
<EnvKeyPlatformModal onClose={() => handleFormReset(false)} />
|
||||||
)}
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{selectedPlatform?.platform === "vault" && (
|
||||||
|
<motion.div
|
||||||
|
key="vault-step"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<VaultPlatformModal onClose={() => handleFormReset(false)} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, FormControl, Input, Tooltip } from "@app/components/v2";
|
||||||
|
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
|
||||||
|
import { useImportVault } from "@app/hooks/api/migration/mutations";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum VaultMappingType {
|
||||||
|
Namespace = "namespace",
|
||||||
|
KeyVault = "key-vault"
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAPPING_TYPE_MENU_ITEMS = [
|
||||||
|
{
|
||||||
|
value: VaultMappingType.Namespace,
|
||||||
|
label: "Namespaces",
|
||||||
|
tooltip: (
|
||||||
|
<div>
|
||||||
|
When using namespaces for mapping, each namespace within Vault will be created in Infisical
|
||||||
|
as a project. Each key vault (KV) inside the namespace, will be created as an environment
|
||||||
|
inside the corresponding project.
|
||||||
|
<div className="mt-4 flex flex-col gap-1 text-sm">
|
||||||
|
<div>Namespace → Project</div>
|
||||||
|
<div>Key Vault → Project Environment</div>
|
||||||
|
<div>Secret Path → Secret Folder</div>
|
||||||
|
<div>Secret data → Secrets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: VaultMappingType.KeyVault,
|
||||||
|
label: "Key Vaults",
|
||||||
|
tooltip: (
|
||||||
|
<div>
|
||||||
|
When using key vaults for mapping, each key vault within Vault will be created in Infisical
|
||||||
|
as a project. Each secret path inside the key vault, will be created as an environment
|
||||||
|
inside the corresponding project. When using Key Vaults as the mapping type, a default
|
||||||
|
environment called "Production" will be created for each project, which will
|
||||||
|
contain the secrets from the key vault.
|
||||||
|
<div className="mt-4 flex flex-col gap-1 text-sm">
|
||||||
|
<div>Key Vault → Project</div>
|
||||||
|
<div>Default Environment (Production)</div>
|
||||||
|
<div>Secret Path → Secret Folder</div>
|
||||||
|
<div>Secret data → Secrets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||||
|
const formSchema = z.object({
|
||||||
|
vaultUrl: z.string().min(1),
|
||||||
|
vaultNamespace: z.string().min(1),
|
||||||
|
vaultAccessToken: z.string().min(1),
|
||||||
|
mappingType: z.nativeEnum(VaultMappingType)
|
||||||
|
});
|
||||||
|
type TFormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const fileUploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: importVault } = useImportVault();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isLoading, isDirty, isSubmitting, isValid }
|
||||||
|
} = useForm<TFormData>({
|
||||||
|
resolver: zodResolver(formSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TFormData) => {
|
||||||
|
try {
|
||||||
|
await importVault({
|
||||||
|
vaultAccessToken: data.vaultAccessToken,
|
||||||
|
vaultNamespace: data.vaultNamespace,
|
||||||
|
vaultUrl: data.vaultUrl
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
title: "Import started",
|
||||||
|
text: "Your data is being imported. You will receive an email when the import is complete or if the import fails. This may take up to 10 minutes.",
|
||||||
|
type: "info"
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
reset();
|
||||||
|
|
||||||
|
if (fileUploadRef.current) {
|
||||||
|
fileUploadRef.current.value = "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<NoticeBannerV2 title="Vault KV Secret Engine Import" className="mb-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
The Vault migration currently supports importing static secrets from Vault
|
||||||
|
Dedicated/Self-Hosted. Namespaces are treated as projects, Secret Engines are treated as
|
||||||
|
environments, and secret paths are treated as folders.
|
||||||
|
<div className="mt-2 text-xs opacity-80">
|
||||||
|
Currently only KV Secret Engine V2 is supported for Vault migrations.
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
</NoticeBannerV2>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="vaultUrl"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Vault URL"
|
||||||
|
isRequired
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Input type="password" placeholder="" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="vaultNamespace"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Vault Namespace"
|
||||||
|
isRequired
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Input type="text" placeholder="" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="vaultAccessToken"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Vault Access Token"
|
||||||
|
isRequired
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Input type="password" placeholder="" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="mappingType"
|
||||||
|
defaultValue={VaultMappingType.Namespace}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Mapping Type"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<div className="mt-2 grid h-full w-full grid-cols-2 gap-4">
|
||||||
|
{MAPPING_TYPE_MENU_ITEMS.map((el) => (
|
||||||
|
<div
|
||||||
|
key={el.value}
|
||||||
|
className={twMerge(
|
||||||
|
"flex w-full cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all",
|
||||||
|
field.value === el.value
|
||||||
|
? "border-primary-700 border-opacity-70 bg-mineshaft-600 opacity-100"
|
||||||
|
: "hover:border-primary-700 hover:bg-mineshaft-600"
|
||||||
|
)}
|
||||||
|
onClick={() => field.onChange(el.value)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
field.onChange(el.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="text-center text-sm">{el.label}</div>
|
||||||
|
{el.tooltip && (
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
<Tooltip content={el.tooltip} className="max-w-96">
|
||||||
|
<FontAwesomeIcon className="opacity-60" icon={faQuestionCircle} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={!isDirty || isSubmitting || isLoading || !isValid}
|
||||||
|
>
|
||||||
|
Import data
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user