diff --git a/backend/package-lock.json b/backend/package-lock.json index 6ea34f692..6ec28ed63 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -85,6 +85,7 @@ "safe-regex": "^2.1.1", "scim-patch": "^0.8.3", "scim2-parse-filter": "^0.2.10", + "sjcl": "^1.0.8", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", @@ -117,6 +118,7 @@ "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", + "@types/sjcl": "^1.0.34", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -7296,6 +7298,13 @@ "@types/node": "*" } }, + "node_modules/@types/sjcl": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@types/sjcl/-/sjcl-1.0.34.tgz", + "integrity": "sha512-bQHEeK5DTQRunIfQeUMgtpPsNNCcZyQ9MJuAfW1I7iN0LDunTc78Fu17STbLMd7KiEY/g2zHVApippa70h6HoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", @@ -16397,6 +16406,15 @@ "node": ">=10" } }, + "node_modules/sjcl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz", + "integrity": "sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ==", + "license": "(BSD-2-Clause OR GPL-2.0-only)", + "engines": { + "node": "*" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -17874,12 +17892,14 @@ "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" }, "node_modules/tweetnacl-util": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", - "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", + "license": "Unlicense" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/backend/package.json b/backend/package.json index 21f841da7..217817edf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -80,6 +80,7 @@ "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", + "@types/sjcl": "^1.0.34", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -182,6 +183,7 @@ "safe-regex": "^2.1.1", "scim-patch": "^0.8.3", "scim2-parse-filter": "^0.2.10", + "sjcl": "^1.0.8", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 1b487ac1f..10be1c4c9 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -38,6 +38,7 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { TExternalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; @@ -181,6 +182,7 @@ declare module "fastify" { orgAdmin: TOrgAdminServiceFactory; slack: TSlackServiceFactory; workflowIntegration: TWorkflowIntegrationServiceFactory; + migration: TExternalMigrationServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5d9632215..eb2b00120 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -96,6 +96,7 @@ import { certificateAuthorityServiceFactory } from "@app/services/certificate-au import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; @@ -1185,6 +1186,14 @@ export const registerRoutes = async ( workflowIntegrationDAL }); + const migrationService = externalMigrationServiceFactory({ + projectService, + orgService, + projectEnvService, + permissionService, + secretService + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -1268,7 +1277,8 @@ export const registerRoutes = async ( externalKms: externalKmsService, orgAdmin: orgAdminService, slack: slackService, - workflowIntegration: workflowIntegrationService + workflowIntegration: workflowIntegrationService, + migration: migrationService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts new file mode 100644 index 000000000..71861bf4c --- /dev/null +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerExternalMigrationRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/env-key", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + decryptionKey: z.string().trim().min(1), + encryptedJson: z.object({ + nonce: z.string().trim().min(1), + data: z.string().trim().min(1) + }) + }) + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + await server.services.migration.importEnvKeyData({ + decryptionKey: req.body.decryptionKey, + encryptedJson: req.body.encryptedJson, + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + } + }); +}; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index 6a061a635..ed8401560 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,3 +1,4 @@ +import { registerExternalMigrationRouter } from "./external-migration-router"; import { registerLoginRouter } from "./login-router"; import { registerSecretBlindIndexRouter } from "./secret-blind-index-router"; import { registerSecretRouter } from "./secret-router"; @@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" }); + await server.register(registerExternalMigrationRouter, { prefix: "/migrate" }); }; diff --git a/backend/src/services/external-migration/external-migration-fns.ts b/backend/src/services/external-migration/external-migration-fns.ts new file mode 100644 index 000000000..0e3cc576f --- /dev/null +++ b/backend/src/services/external-migration/external-migration-fns.ts @@ -0,0 +1,197 @@ +import slugify from "@sindresorhus/slugify"; +import { randomUUID } from "crypto"; +import sjcl from "sjcl"; +import tweetnacl from "tweetnacl"; +import tweetnaclUtil from "tweetnacl-util"; + +import { OrgMembershipRole, ProjectMembershipRole, SecretType } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { TOrgServiceFactory } from "../org/org-service"; +import { TProjectServiceFactory } from "../project/project-service"; +import { TProjectEnvServiceFactory } from "../project-env/project-env-service"; +import { TSecretServiceFactory } from "../secret/secret-service"; +import { InfisicalImportData, TEnvKeyExportJSON, TImportInfisicalDataCreate } from "./external-migration-types"; + +export type TImportDataIntoInfisicalDTO = { + projectService: TProjectServiceFactory; + orgService: TOrgServiceFactory; + projectEnvService: TProjectEnvServiceFactory; + secretService: TSecretServiceFactory; + + input: TImportInfisicalDataCreate; +}; + +const { codec, hash } = sjcl; +const { secretbox } = tweetnacl; + +export const decryptEnvKeyDataFn = async (decryptionKey: string, encryptedJson: { nonce: string; data: string }) => { + const key = tweetnaclUtil.decodeBase64(codec.base64.fromBits(hash.sha256.hash(decryptionKey))); + const nonce = tweetnaclUtil.decodeBase64(encryptedJson.nonce); + const encryptedData = tweetnaclUtil.decodeBase64(encryptedJson.data); + + const decrypted = secretbox.open(encryptedData, nonce, key); + + if (!decrypted) { + throw new BadRequestError({ message: "Decryption failed, please check the entered encryption key" }); + } + + const decryptedJson = tweetnaclUtil.encodeUTF8(decrypted); + return decryptedJson; +}; + +export const parseEnvKeyDataFn = async (decryptedJson: string): Promise => { + const parsedJson: TEnvKeyExportJSON = JSON.parse(decryptedJson) as TEnvKeyExportJSON; + + const infisicalImportData: InfisicalImportData = { + projects: new Map(), + environments: new Map(), + secrets: new Map() + }; + + parsedJson.apps.forEach((app: { name: string; id: string }) => { + infisicalImportData.projects.set(app.id, { name: app.name, id: app.id }); + }); + + // string to string map for env templates + const envTemplates = new Map(); + for (const env of parsedJson.defaultEnvironmentRoles) { + envTemplates.set(env.id, env.defaultName); + } + + // environments + for (const env of parsedJson.baseEnvironments) { + infisicalImportData.environments?.set(env.id, { + id: env.id, + name: envTemplates.get(env.environmentRoleId)!, + projectId: env.envParentId + }); + } + + // secrets + for (const env of Object.keys(parsedJson.envs)) { + if (!env.includes("|")) { + const envData = parsedJson.envs[env]; + for (const secret of Object.keys(envData.variables)) { + const id = randomUUID(); + infisicalImportData.secrets?.set(id, { + id, + name: secret, + environmentId: env, + value: envData.variables[secret].val + }); + } + } + } + + return infisicalImportData; +}; + +export const importDataIntoInfisicalFn = async ({ + projectService, + orgService, + projectEnvService, + secretService, + 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(); + const originalToNewEnvironmentId = new Map(); + + for await (const [id, project] of data.projects) { + const newProject = await projectService + .createProject({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + workspaceName: project.name, + createDefaultEnvs: false + }) + .catch(() => { + throw new BadRequestError({ message: `Failed to import to project [name:${project.name}] [id:${id}]` }); + }); + + originalToNewProjectId.set(project.id, newProject.id); + } + + // Invite user importing projects + const invites = await orgService.inviteUserToOrganization({ + actorAuthMethod, + actorId, + actorOrgId, + actor, + inviteeEmails: [], + orgId: actorOrgId, + organizationRoleSlug: OrgMembershipRole.NoAccess, + projects: Array.from(originalToNewProjectId.values()).map((project) => ({ + id: project, + projectRoleSlug: [ProjectMembershipRole.Member] + })) + }); + if (!invites) { + throw new BadRequestError({ message: `Failed to invite user to projects: [userId:${actorId}]` }); + } + + // Import environments + if (data.environments) { + for await (const [id, environment] of data.environments) { + try { + const newEnvironment = await projectEnvService.createEnvironment({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + name: environment.name, + projectId: originalToNewProjectId.get(environment.projectId)!, + slug: slugify(`${environment.name}-${alphaNumericNanoId(4)}`) + }); + + if (!newEnvironment) { + logger.error(`Failed to import environment: [name:${environment.name}] [id:${id}]`); + throw new BadRequestError({ + message: `Failed to import environment: [name:${environment.name}] [id:${id}]` + }); + } + originalToNewEnvironmentId.set(id, newEnvironment.slug); + } catch (error) { + throw new BadRequestError({ + message: `Failed to import environment: ${environment.name}]`, + name: "EnvKeyMigrationImportEnvironment" + }); + } + } + } + + // Import secrets + if (data.secrets) { + for await (const [id, secret] of data.secrets) { + const dataProjectId = data.environments?.get(secret.environmentId)?.projectId; + if (!dataProjectId) { + throw new BadRequestError({ message: `Failed to import secret "${secret.name}", project not found` }); + } + const projectId = originalToNewProjectId.get(dataProjectId); + const newSecret = await secretService.createSecretRaw({ + actorId, + actor, + actorOrgId, + environment: originalToNewEnvironmentId.get(secret.environmentId)!, + actorAuthMethod, + projectId: projectId!, + secretPath: "/", + secretName: secret.name, + type: SecretType.Shared, + secretValue: secret.value + }); + if (!newSecret) { + throw new BadRequestError({ message: `Failed to import secret: [name:${secret.name}] [id:${id}]` }); + } + } + } +}; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts new file mode 100644 index 000000000..a65a278f5 --- /dev/null +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -0,0 +1,64 @@ +import { OrgMembershipRole } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ForbiddenRequestError } from "@app/lib/errors"; + +import { TOrgServiceFactory } from "../org/org-service"; +import { TProjectServiceFactory } from "../project/project-service"; +import { TProjectEnvServiceFactory } from "../project-env/project-env-service"; +import { TSecretServiceFactory } from "../secret/secret-service"; +import { decryptEnvKeyDataFn, importDataIntoInfisicalFn, parseEnvKeyDataFn } from "./external-migration-fns"; +import { TImportEnvKeyDataCreate } from "./external-migration-types"; + +type TExternalMigrationServiceFactoryDep = { + projectService: TProjectServiceFactory; + orgService: TOrgServiceFactory; + projectEnvService: TProjectEnvServiceFactory; + secretService: TSecretServiceFactory; + permissionService: TPermissionServiceFactory; +}; + +export type TExternalMigrationServiceFactory = ReturnType; + +export const externalMigrationServiceFactory = ({ + projectService, + orgService, + projectEnvService, + permissionService, + secretService +}: TExternalMigrationServiceFactoryDep) => { + const importEnvKeyData = async ({ + decryptionKey, + encryptedJson, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TImportEnvKeyDataCreate) => { + 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 json = await decryptEnvKeyDataFn(decryptionKey, encryptedJson); + const envKeyData = await parseEnvKeyDataFn(json); + const response = await importDataIntoInfisicalFn({ + input: { data: envKeyData, actor, actorId, actorOrgId, actorAuthMethod }, + projectService, + orgService, + projectEnvService, + secretService + }); + return response; + }; + + return { + importEnvKeyData + }; +}; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts new file mode 100644 index 000000000..cb58ad9a2 --- /dev/null +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -0,0 +1,106 @@ +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + +export type InfisicalImportData = { + projects: Map; + + environments?: Map< + string, + { + name: string; + id: string; + projectId: string; + } + >; + + secrets?: Map< + string, + { + name: string; + id: string; + environmentId: string; + value: string; + } + >; +}; + +export type TImportEnvKeyDataCreate = { + decryptionKey: string; + encryptedJson: { nonce: string; data: string }; + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; +}; + +export type TImportInfisicalDataCreate = { + data: InfisicalImportData; + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; +}; + +export type TEnvKeyExportJSON = { + schemaVersion: string; + org: { + id: string; + name: string; + settings: { + auth: { + inviteExpirationMs: number; + deviceGrantExpirationMs: number; + tokenExpirationMs: number; + }; + crypto: { + requiresPassphrase: boolean; + requiresLockout: boolean; + }; + envs: { + autoCaps: boolean; + autoCommitLocals: boolean; + }; + }; + }; + apps: { + id: string; + name: string; + settings: Record; + }[]; + defaultOrgRoles: { + id: string; + defaultName: string; + }[]; + defaultAppRoles: { + id: string; + defaultName: string; + }[]; + defaultEnvironmentRoles: { + id: string; + defaultName: string; + settings: { + autoCommit: boolean; + }; + }[]; + baseEnvironments: { + id: string; + envParentId: string; + environmentRoleId: string; + settings: Record; + }[]; + orgUsers: { + id: string; + firstName: string; + lastName: string; + email: string; + provider: string; + orgRoleId: string; + uid: string; + }[]; + envs: Record< + string, + { + variables: Record; + inherits: Record; + } + >; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 863cf24c2..ec6c54dcc 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { OrgMembershipRole, ProjectMembershipRole, ProjectVersion } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -146,7 +146,8 @@ export const projectServiceFactory = ({ actorAuthMethod, workspaceName, slug: projectSlug, - kmsKeyId + kmsKeyId, + createDefaultEnvs = true }: TCreateProjectDTO) => { const organization = await orgDAL.findOne({ id: actorOrgId }); @@ -207,14 +208,17 @@ export const projectServiceFactory = ({ ); // set default environments and root folder for provided environments - const envs = await projectEnvDAL.insertMany( - DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })), - tx - ); - await folderDAL.insertMany( - envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), - tx - ); + let envs: TProjectEnvironments[] = []; + if (createDefaultEnvs) { + envs = await projectEnvDAL.insertMany( + DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })), + tx + ); + await folderDAL.insertMany( + envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), + tx + ); + } // 3. Create a random key that we'll use as the project key. const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({ diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 3bfdae709..7193f1121 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -29,6 +29,7 @@ export type TCreateProjectDTO = { workspaceName: string; slug?: string; kmsKeyId?: string; + createDefaultEnvs?: boolean; }; export type TDeleteProjectBySlugDTO = { diff --git a/docs/documentation/guides/migrating-from-envkey.mdx b/docs/documentation/guides/migrating-from-envkey.mdx index 3c24c69cd..e1d75bab0 100644 --- a/docs/documentation/guides/migrating-from-envkey.mdx +++ b/docs/documentation/guides/migrating-from-envkey.mdx @@ -12,11 +12,29 @@ Infisical is used by 10,000+ organizations across all industries including First ## Migrating from EnvKey -To facilitate customer transition from EnvKey to Infisical, we have been working closely with the EnvKey team to provide a simple migration path for all EnvKey customers. + + +Open the EnvKey dashboard and go to My Org. +![EnvKey Dashboard](../../images/guides/import-envkey/envkey-dashboard.png) + + +Go to Import/Export on the top right corner, Click on Export Org and save the exported file. +![Export organization](../../images/guides/import-envkey/envkey-export.png) + + +Click on copy to copy the encryption key and save it. +![Copy encryption key](../../images/guides/import-envkey/copy-encryption-key.png) + + +Open the Infisical dashboard and go to Organization Settings > Import. +![Infisical Organization settings](../../images/guides/import-envkey/infisical-import-dashboard.png) + + +Upload the exported file from EnvKey, paste the encryption key and click Import. +![Infisical Import EnvKey](../../images/guides/import-envkey/infisical-import-envkey.png) + + -## Automated migration - -Our team is currently working on creating an automated migration process that would include secrets, policies, and other important resources. If you are interested in that, please [reach out to our team](mailto:support@infisical.com) with any questions. ## Talk to our team diff --git a/docs/images/guides/import-envkey/copy-encryption-key.png b/docs/images/guides/import-envkey/copy-encryption-key.png new file mode 100644 index 000000000..df0c7b459 Binary files /dev/null and b/docs/images/guides/import-envkey/copy-encryption-key.png differ diff --git a/docs/images/guides/import-envkey/envkey-dashboard.png b/docs/images/guides/import-envkey/envkey-dashboard.png new file mode 100644 index 000000000..0170e00fb Binary files /dev/null and b/docs/images/guides/import-envkey/envkey-dashboard.png differ diff --git a/docs/images/guides/import-envkey/envkey-export.png b/docs/images/guides/import-envkey/envkey-export.png new file mode 100644 index 000000000..68d55ed14 Binary files /dev/null and b/docs/images/guides/import-envkey/envkey-export.png differ diff --git a/docs/images/guides/import-envkey/infisical-import-dashboard.png b/docs/images/guides/import-envkey/infisical-import-dashboard.png new file mode 100644 index 000000000..ab197f126 Binary files /dev/null and b/docs/images/guides/import-envkey/infisical-import-dashboard.png differ diff --git a/docs/images/guides/import-envkey/infisical-import-envkey.png b/docs/images/guides/import-envkey/infisical-import-envkey.png new file mode 100644 index 000000000..c504c9824 Binary files /dev/null and b/docs/images/guides/import-envkey/infisical-import-envkey.png differ diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx new file mode 100644 index 000000000..fb9bebeff --- /dev/null +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace"; + +export const useImportEnvKey = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + encryptedJson, + decryptionKey + }: { + encryptedJson: { + nonce: string; + data: string; + }; + decryptionKey: string; + }) => { + const { data } = await apiRequest.post("/api/v3/migrate/env-key/", { + encryptedJson, + decryptionKey + }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/GenericDropzone.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/GenericDropzone.tsx new file mode 100644 index 000000000..f4b732b8f --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/GenericDropzone.tsx @@ -0,0 +1,117 @@ +import React, { + ChangeEvent, + DragEvent, + forwardRef, + useImperativeHandle, + useRef, + useState +} from "react"; +import { faUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { useToggle } from "@app/hooks"; + +type Props = { + accept?: string; + onData: (file: File) => void; + isSmaller: boolean; + text?: string; + isDisabled?: boolean; +}; + +export const GenericDropzone = forwardRef( + ({ onData, isSmaller, text, isDisabled, accept }: Props, ref): JSX.Element => { + const [isDragActive, setDragActive] = useToggle(); + const [selectedFileName, setSelectedFileName] = useState(null); + const inputRef = useRef(null); + + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); + + const updateSelectedFileName = () => { + if (inputRef.current?.files?.[0]) { + setSelectedFileName(inputRef.current.files[0].name); + } else { + setSelectedFileName(null); + } + }; + + const handleDrag = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive.on(); + } else if (e.type === "dragleave") { + setDragActive.off(); + } + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!e.dataTransfer) { + return; + } + + e.dataTransfer.dropEffect = "copy"; + setDragActive.off(); + const file = e.dataTransfer.files[0]; + onData(file); + setSelectedFileName(file.name); + }; + + const handleFileUpload = (e: ChangeEvent) => { + e.preventDefault(); + + if (!e.target?.files?.[0]) { + return; + } + onData(e.target.files[0]); + updateSelectedFileName(); + }; + + React.useEffect(() => { + updateSelectedFileName(); + }, []); + + return ( +
+
+ {selectedFileName ? ( +

{selectedFileName}

+ ) : ( +
+
+ +
+
+

{text}

+
+ +
+ )} +
+
+ ); + } +); + +GenericDropzone.displayName = "GenericDropzone"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/ImportTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/ImportTab.tsx new file mode 100644 index 000000000..41020d730 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/ImportTab.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; +import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button } from "@app/components/v2"; +import { useOrgPermission } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; + +import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal"; + +export const ImportTab = () => { + const { membership } = useOrgPermission(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const); + + return ( +
+
+
+

Import from external source

+ + +
+ + +
+

Import data from another platform to Infisical.

+ + handlePopUpToggle("selectImportPlatform", state)} + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/EnvKeyPlatformModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/EnvKeyPlatformModal.tsx new file mode 100644 index 000000000..1bfabf434 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/EnvKeyPlatformModal.tsx @@ -0,0 +1,155 @@ +import { useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useImportEnvKey } from "@app/hooks/api/migration/mutations"; +import { GenericDropzone } from "@app/views/SecretMainPage/components/SecretDropzone/GenericDropzone"; + +type Props = { + id?: string; + onClose: () => void; +}; + +const formSchema = z.object({ + encryptionKey: z.string().min(1), + encryptedJson: z.object({ + nonce: z.string().min(1), + data: z.string().min(1) + }) +}); + +type TFormData = z.infer; + +export const EnvKeyPlatformModal = ({ onClose }: Props) => { + const fileUploadRef = useRef(null); + + const { mutateAsync: importEnvKey } = useImportEnvKey(); + + const { + control, + handleSubmit, + reset, + setValue, + setError, + formState: { isLoading, isDirty, isSubmitting, isValid } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const onSubmit = async (data: TFormData) => { + if (!data.encryptedJson) { + setError("encryptedJson", { + type: "required", + message: "File is required" + }); + return; + } + + try { + await importEnvKey({ + encryptedJson: data.encryptedJson, + decryptionKey: data.encryptionKey + }); + createNotification({ + text: "Data imported successfully.", + type: "success" + }); + + onClose(); + reset(); + + if (fileUploadRef.current) { + fileUploadRef.current.value = ""; + } + } catch { + reset(); + } + }; + + const onImportFileDrop = (file?: File) => { + const reader = new FileReader(); + if (!file) { + createNotification({ + text: "No file selected.", + type: "error" + }); + return; + } + reader.onload = (event) => { + if (!event?.target?.result) return; + + const droppedFile = event.target.result.toString(); + const formattedData: Record = JSON.parse(droppedFile); + if ( + Object.keys(formattedData).includes("nonce") && + Object.keys(formattedData).includes("data") + ) { + const data = { + nonce: formattedData.nonce, + data: formattedData.data + }; + setValue("encryptedJson", data, { shouldDirty: true, shouldValidate: true }); + } else { + setValue( + "encryptedJson", + { + nonce: "", + data: "" + }, + { shouldDirty: true, shouldValidate: true } + ); + + if (fileUploadRef.current) { + fileUploadRef.current.value = ""; + } + createNotification({ + text: "Improper file format, please upload the EnvKey export.", + type: "error" + }); + } + }; + reader.readAsText(file); + }; + + return ( +
+ ( + + + + )} + /> + + + +
+ + +
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/SelectImportFromPlatformModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/SelectImportFromPlatformModal.tsx new file mode 100644 index 000000000..3dfbd9b7b --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/components/SelectImportFromPlatformModal.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; + +import { Modal, ModalContent } from "@app/components/v2"; + +import { EnvKeyPlatformModal } from "./EnvKeyPlatformModal"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; +}; + +enum WizardSteps { + SelectPlatform = "select-platform", + PlatformInputs = "platform-inputs" +} + +const PLATFORM_LIST = [ + { + icon: faKey, + platform: "env-key", + title: "Env Key" + } +] as const; + +export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => { + const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform); + const [selectedPlatform, setSelectedPlatform] = useState<(typeof PLATFORM_LIST)[number] | null>( + null + ); + + const handleFormReset = (state: boolean = false) => { + onToggle(state); + setWizardStep(WizardSteps.SelectPlatform); + setSelectedPlatform(null); + }; + + return ( + handleFormReset(state)}> + + + {wizardStep === WizardSteps.SelectPlatform && ( + +
+ Select a platform to import from +
+
+ {PLATFORM_LIST.map((platform, idx) => ( +
{ + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + } + }} + > + +
{platform.title}
+
+ ))} +
+
+ )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform?.platform === "env-key" && ( + + handleFormReset(false)} /> + + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/index.tsx new file mode 100644 index 000000000..0679ab33f --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ImportTab/index.tsx @@ -0,0 +1 @@ +export { ImportTab } from "./ImportTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 090fd63e0..875f8cdf4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -2,7 +2,11 @@ import { Fragment, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { Tab } from "@headlessui/react"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; + import { AuditLogStreamsTab } from "../AuditLogStreamTab"; +import { ImportTab } from "../ImportTab"; import { OrgAuthTab } from "../OrgAuthTab"; import { OrgEncryptionTab } from "../OrgEncryptionTab"; import { OrgGeneralTab } from "../OrgGeneralTab"; @@ -13,7 +17,8 @@ const tabs = [ { name: "Security", key: "tab-org-security" }, { name: "Encryption", key: "tab-org-encryption" }, { name: "Workflow Integrations", key: "workflow-integrations" }, - { name: "Audit Log Streams", key: "tag-audit-log-streams" } + { name: "Audit Log Streams", key: "tag-audit-log-streams" }, + { name: "Import", key: "tab-import" } ]; export const OrgTabGroup = () => { const { query } = useRouter(); @@ -63,6 +68,11 @@ export const OrgTabGroup = () => { + + + + + );