From 4802a36473f23ae294e076a8680b9cc8150eb4ed Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 13 Feb 2024 01:03:59 +0100 Subject: [PATCH] Finished migration --- .../migrations/20240202093209_ghost_user.ts | 2 + backend/src/db/schemas/models.ts | 18 + backend/src/db/schemas/projects.ts | 3 +- .../saml-config/saml-config-service.ts | 3 +- backend/src/queue/queue-service.ts | 21 +- backend/src/server/routes/index.ts | 25 +- .../src/server/routes/v2/project-router.ts | 27 +- .../src/services/auth/auth-signup-service.ts | 2 +- backend/src/services/project/project-dal.ts | 15 +- backend/src/services/project/project-queue.ts | 410 ++++++++++++++++++ .../src/services/project/project-service.ts | 348 ++------------- .../super-admin/super-admin-service.ts | 1 + .../UpgradeProjectAlert.tsx | 90 ++++ .../v2/UpgradeProjectAlert/index.tsx | 1 + frontend/src/hooks/api/workspace/index.tsx | 1 + frontend/src/hooks/api/workspace/queries.tsx | 17 + frontend/src/hooks/api/workspace/types.ts | 1 + .../SecretOverviewPage/SecretOverviewPage.tsx | 44 +- 18 files changed, 660 insertions(+), 369 deletions(-) create mode 100644 backend/src/services/project/project-queue.ts create mode 100644 frontend/src/components/v2/UpgradeProjectAlert/UpgradeProjectAlert.tsx create mode 100644 frontend/src/components/v2/UpgradeProjectAlert/index.tsx diff --git a/backend/src/db/migrations/20240202093209_ghost_user.ts b/backend/src/db/migrations/20240202093209_ghost_user.ts index 72181cd83..ddfdbe812 100644 --- a/backend/src/db/migrations/20240202093209_ghost_user.ts +++ b/backend/src/db/migrations/20240202093209_ghost_user.ts @@ -15,6 +15,7 @@ export async function up(knex: Knex): Promise { if (!hasProjectVersionColumn) { await knex.schema.alterTable(TableName.Project, (t) => { t.string("version").defaultTo(ProjectVersion.V1).notNullable(); + t.text("upgradeStatus").nullable(); }); } } @@ -32,6 +33,7 @@ export async function down(knex: Knex): Promise { if (hasProjectVersionColumn) { await knex.schema.alterTable(TableName.Project, (t) => { t.dropColumn("version"); + t.dropColumn("upgradeStatus"); }); } } diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 4109c516c..426d3af3f 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -117,6 +117,24 @@ export enum ProjectVersion { V2 = "v2" } +// Case study: +/* + +Q: If the upgrade status is null and the project version is V1: +A: It would mean that the project has not been attempted to be upgraded + +Q: If the upgrade status is not null (FAILED or IN_PROGRESS), we a status + +Q: if the project status is null, and the project version is v2, we should display nothing cuz its upgraded! + +*/ + +export enum ProjectUpgradeStatus { + InProgress = "IN_PROGRESS", + Completed = "Project upgrade completed.", // Will be null if completed. So a completed status is not needed + Failed = "FAILED" +} + export enum IdentityAuthMethod { Univeral = "universal-auth" } diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index fceb18388..92be7a651 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -15,7 +15,8 @@ export const ProjectsSchema = z.object({ orgId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - version: z.string().default("v1") + version: z.string().default("v1"), + upgradeStatus: z.string().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index ad33051fb..824db8251 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -338,7 +338,8 @@ export const samlConfigServiceFactory = ({ email, firstName, lastName, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + ghost: false }, tx ); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 58c829549..e11e0b590 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -1,6 +1,7 @@ import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq"; import Redis from "ioredis"; +import { SecretKeyEncoding } from "@app/db/schemas"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TScanFullRepoEventPayload, @@ -15,7 +16,8 @@ export enum QueueName { IntegrationSync = "sync-integrations", SecretWebhook = "secret-webhook", SecretFullRepoScan = "secret-full-repo-scan", - SecretPushEventScan = "secret-push-event-scan" + SecretPushEventScan = "secret-push-event-scan", + UpgradeProjectToGhost = "upgrade-project-to-ghost" } export enum QueueJobs { @@ -25,7 +27,8 @@ export enum QueueJobs { AuditLogPrune = "audit-log-prune-job", SecWebhook = "secret-webhook-trigger", IntegrationSync = "secret-integration-pull", - SecretScan = "secret-scan" + SecretScan = "secret-scan", + UpgradeProjectToGhost = "upgrade-project-to-ghost-job" } export type TQueueJobTypes = { @@ -64,6 +67,20 @@ export type TQueueJobTypes = { payload: TScanFullRepoEventPayload; }; [QueueName.SecretPushEventScan]: { name: QueueJobs.SecretScan; payload: TScanPushEventPayload }; + + [QueueName.UpgradeProjectToGhost]: { + name: QueueJobs.UpgradeProjectToGhost; + payload: { + projectId: string; + startedByUserId: string; + encryptedPrivateKey: { + encryptedKey: string; + encryptedKeyIv: string; + encryptedKeyTag: string; + keyEncoding: SecretKeyEncoding; + }; + }; + }; }; export type TQueueServiceFactory = ReturnType; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 068142ab8..3952ea980 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -65,6 +65,7 @@ import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; import { projectDALFactory } from "@app/services/project/project-dal"; +import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -299,19 +300,33 @@ export const registerRoutes = async ( projectKeyDAL, projectMembershipDAL }); + + const projectQueueService = projectQueueFactory({ + queueService, + secretDAL, + folderDAL, + projectDAL, + orgDAL, + orgService, + projectEnvDAL, + userDAL, + secretVersionDAL, + projectKeyDAL, + projectBotDAL, + projectMembershipDAL, + secretApprovalRequestDAL, + secretApprovalSecretDAL: sarSecretDAL + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, + projectQueue: projectQueueService, secretBlindIndexDAL, identityProjectDAL, identityOrgMembershipDAL, projectBotDAL, - secretDAL, - orgDAL, - secretApprovalRequestDAL, - secretApprovalSecretDAL: sarSecretDAL, projectKeyDAL, - secretVersionDAL, userDAL, projectEnvDAL, orgService, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index bc0282850..fd7573c65 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -74,7 +74,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { userPrivateKey: z.string().trim() }), response: { - 200: z.object({}) + 200: z.void() } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), @@ -88,6 +88,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/:projectId/upgrade/status", + method: "GET", + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + status: z.string().nullable() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const status = await server.services.project.getProjectUpgradeStatus({ + projectId: req.params.projectId, + actor: req.permission.type, + actorId: req.permission.id + }); + + return { status }; + } + }); + /* Create new project */ server.route({ method: "POST", diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 021ca7070..0c2f53e5f 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -50,7 +50,7 @@ export const authSignupServiceFactory = ({ throw new Error("Failed to send verification code for complete account"); } if (!user) { - user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email }); + user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email, ghost: false }); } if (!user) throw new Error("Failed to create user"); diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 4d7c6072e..a9906f859 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -1,5 +1,5 @@ import { TDbClient } from "@app/db"; -import { ProjectsSchema, TableName } from "@app/db/schemas"; +import { ProjectsSchema, ProjectUpgradeStatus, TableName, TProjectsUpdate } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; @@ -66,6 +66,18 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const setProjectUpgradeStatus = async (projectId: string, status: ProjectUpgradeStatus) => { + try { + const data: TProjectsUpdate = { + upgradeStatus: status + } as const; + + await db(TableName.Project).where({ id: projectId }).update(data); + } catch (error) { + throw new DatabaseError({ error, name: "Set project upgrade status" }); + } + }; + const findAllProjectsByIdentity = async (identityId: string) => { try { const workspaces = await db(TableName.IdentityProjectMembership) @@ -149,6 +161,7 @@ export const projectDALFactory = (db: TDbClient) => { return { ...projectOrm, findAllProjects, + setProjectUpgradeStatus, findAllProjectsByIdentity, findProjectGhostUser, findProjectById diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts new file mode 100644 index 000000000..e8b15ddf9 --- /dev/null +++ b/backend/src/services/project/project-queue.ts @@ -0,0 +1,410 @@ +/* eslint-disable no-await-in-loop */ +import { + ProjectMembershipRole, + ProjectUpgradeStatus, + ProjectVersion, + SecretKeyEncoding, + TSecrets +} from "@app/db/schemas"; +import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; +import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; +import { RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; +import { + decryptAsymmetric, + encryptSymmetric128BitHexKeyUTF8, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { createProjectKey, createWsMembers } from "@app/lib/project"; +import { decryptSecrets, SecretDocType, TPartialSecret } from "@app/lib/secret"; +import { QueueJobs, QueueName, TQueueJobTypes, TQueueServiceFactory } from "@app/queue"; + +import { TOrgDALFactory } from "../org/org-dal"; +import { TOrgServiceFactory } from "../org/org-service"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TSecretDALFactory } from "../secret/secret-dal"; +import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TProjectDALFactory } from "./project-dal"; + +export type TProjectQueueFactory = ReturnType; + +type TProjectQueueFactoryDep = { + queueService: TQueueServiceFactory; + secretVersionDAL: Pick; + folderDAL: Pick; + secretDAL: Pick; + projectKeyDAL: Pick; + secretApprovalRequestDAL: Pick; + secretApprovalSecretDAL: Pick; + projectBotDAL: Pick; + orgService: Pick; + projectMembershipDAL: Pick; + userDAL: Pick; + + projectEnvDAL: Pick; + projectDAL: Pick; + orgDAL: Pick; +}; + +export const projectQueueFactory = ({ + queueService, + secretDAL, + folderDAL, + userDAL, + secretVersionDAL, + secretApprovalRequestDAL, + secretApprovalSecretDAL, + projectKeyDAL, + projectBotDAL, + projectEnvDAL, + orgDAL, + projectDAL, + orgService, + projectMembershipDAL +}: TProjectQueueFactoryDep) => { + const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => { + await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, { + attempts: 1, + removeOnComplete: true, + removeOnFail: { + count: 5 // keep the most recent jobs + } + }); + }; + + queueService.start(QueueName.UpgradeProjectToGhost, async ({ data }) => { + try { + const [project] = await projectDAL.find({ + id: data.projectId, + version: ProjectVersion.V1, + $in: { + upgradeStatus: [ProjectUpgradeStatus.Failed, null] + } + }); + + const oldProjectKey = await projectKeyDAL.findLatestProjectKey(data.startedByUserId, data.projectId); + + if (!project || !oldProjectKey) { + throw new BadRequestError({ + message: "Project or project key not found" + }); + } + + await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.InProgress); + const userPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: data.encryptedPrivateKey.keyEncoding, + ciphertext: data.encryptedPrivateKey.encryptedKey, + iv: data.encryptedPrivateKey.encryptedKeyIv, + tag: data.encryptedPrivateKey.encryptedKeyTag + }); + + const projectEnvs = await projectEnvDAL.find({ + projectId: project.id + }); + + const projectFolders = await folderDAL.find({ + $in: { + envId: projectEnvs.map((env) => env.id) + } + }); + + // Get all the secrets within the project (as encrypted) + const secrets: TPartialSecret[] = []; + for (const folder of projectFolders) { + const folderSecrets = await secretDAL.find({ folderId: folder.id }); + + const folderSecretVersions = await secretVersionDAL.find( + { + folderId: folder.id + }, + // Only get the latest 100 secret versions for each folder. + { + limit: 100 + } + ); + const approvalRequests = await secretApprovalRequestDAL.find({ + status: RequestState.Open, + folderId: folder.id + }); + const approvalSecrets = await secretApprovalSecretDAL.find({ + $in: { + requestId: approvalRequests.map((el) => el.id) + } + }); + + secrets.push(...folderSecrets.map((el) => ({ ...el, docType: SecretDocType.Secret }))); + secrets.push(...folderSecretVersions.map((el) => ({ ...el, docType: SecretDocType.SecretVersion }))); + secrets.push(...approvalSecrets.map((el) => ({ ...el, docType: SecretDocType.ApprovalSecret }))); + } + + const decryptedSecrets = decryptSecrets(secrets, userPrivateKey, oldProjectKey); + + if (secrets.length !== decryptedSecrets.length) { + throw new Error("Failed to decrypt some secret versions"); + } + + // Get the existing bot and the existing project keys for the members of the project + const existingBot = await projectBotDAL.findOne({ projectId: project.id }).catch(() => null); + const existingProjectKeys = await projectKeyDAL.find({ projectId: project.id }); + + // TRANSACTION START + await projectDAL.transaction(async (tx) => { + await projectDAL.updateById(project.id, { version: ProjectVersion.V2 }, tx); + + // Create a ghost user + const ghostUser = await orgService.addGhostUser(project.orgId, tx); + + // Create a project key + const { key: newEncryptedProjectKey, iv: newEncryptedProjectKeyIv } = createProjectKey({ + publicKey: ghostUser.keys.publicKey, + privateKey: ghostUser.keys.plainPrivateKey + }); + + // Create a new project key for the GHOST + await projectKeyDAL.create( + { + projectId: project.id, + receiverId: ghostUser.user.id, + encryptedKey: newEncryptedProjectKey, + nonce: newEncryptedProjectKeyIv, + senderId: ghostUser.user.id + }, + tx + ); + + // Create a membership for the ghost user + await projectMembershipDAL.create( + { + projectId: project.id, + userId: ghostUser.user.id, + role: ProjectMembershipRole.Admin + }, + tx + ); + + // If a bot already exists, delete it + if (existingBot) { + await projectBotDAL.delete({ id: existingBot.id }, tx); + } + + // Delete all the existing project keys + await projectKeyDAL.delete( + { + projectId: project.id, + $in: { + id: existingProjectKeys.map((key) => key.id) + } + }, + tx + ); + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx); + + if (!ghostUserLatestKey) { + throw new Error("User latest key not found (V2 Upgrade)"); + } + + const newProjectMembers: { + encryptedKey: string; + nonce: string; + senderId: string; + receiverId: string; + projectId: string; + }[] = []; + + for (const key of existingProjectKeys) { + const user = await userDAL.findUserEncKeyByUserId(key.receiverId); + const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId }); + + if (!user || !orgMembership) { + throw new Error(`User with ID ${key.receiverId} was not found during upgrade, or user is not in org.`); + } + + const [newMember] = createWsMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: ghostUser.keys.plainPrivateKey, + members: [ + { + userPublicKey: user.publicKey, + orgMembershipId: orgMembership.id, + projectMembershipRole: ProjectMembershipRole.Admin + } + ] + }); + + newProjectMembers.push({ + encryptedKey: newMember.workspaceEncryptedKey, + nonce: newMember.workspaceEncryptedNonce, + senderId: ghostUser.user.id, + receiverId: user.id, + projectId: project.id + }); + } + + // Create project keys for all the old members + await projectKeyDAL.insertMany(newProjectMembers, tx); + + // Encrypt the bot private key (which is the same as the ghost user) + const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey); + + // 5. Create a bot for the project + const newBot = await projectBotDAL.create( + { + name: "Infisical Bot (Ghost)", + projectId: project.id, + tag, + iv, + encryptedPrivateKey: ciphertext, + isActive: true, + publicKey: ghostUser.keys.publicKey, + senderId: ghostUser.user.id, + encryptedProjectKey: newEncryptedProjectKey, + encryptedProjectKeyNonce: newEncryptedProjectKeyIv, + algorithm, + keyEncoding: encoding + }, + tx + ); + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: newBot.keyEncoding as SecretKeyEncoding, + iv: newBot.iv, + tag: newBot.tag, + ciphertext: newBot.encryptedPrivateKey + }); + + const botKey = decryptAsymmetric({ + ciphertext: newBot.encryptedProjectKey!, + privateKey: botPrivateKey, + nonce: newBot.encryptedProjectKeyNonce!, + publicKey: ghostUser.keys.publicKey + }); + + type TPartialSecret = Pick< + TSecrets, + | "id" + | "secretKeyCiphertext" + | "secretKeyIV" + | "secretKeyTag" + | "secretValueCiphertext" + | "secretValueIV" + | "secretValueTag" + | "secretCommentCiphertext" + | "secretCommentIV" + | "secretCommentTag" + >; + + const updatedSecrets: TPartialSecret[] = []; + const updatedSecretVersions: TPartialSecret[] = []; + const updatedSecretApprovals: TPartialSecret[] = []; + for (const rawSecret of decryptedSecrets) { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.secretComment || "", botKey); + + const payload = { + id: rawSecret.id, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag + } as const; + + if (rawSecret.docType === SecretDocType.Secret) { + updatedSecrets.push(payload); + } else if (rawSecret.docType === SecretDocType.SecretVersion) { + updatedSecretVersions.push(payload); + } else if (rawSecret.docType === SecretDocType.ApprovalSecret) { + updatedSecretApprovals.push(payload); + } else { + throw new Error("Unknown secret type"); + } + } + + const secretUpdates = await secretDAL.bulkUpdateNoVersionIncrement( + [ + ...updatedSecrets.map((secret) => ({ + filter: { id: secret.id }, + data: { + ...secret, + id: undefined + } + })) + ], + tx + ); + + const secretVersionUpdates = await secretVersionDAL.bulkUpdateNoVersionIncrement( + [ + ...updatedSecretVersions.map((version) => ({ + filter: { id: version.id }, + data: { + ...version, + id: undefined + } + })) + ], + tx + ); + + const secretApprovalUpdates = await secretApprovalSecretDAL.bulkUpdateNoVersionIncrement( + [ + ...updatedSecretApprovals.map((approval) => ({ + filter: { + id: approval.id + }, + data: { + ...approval, + id: undefined + } + })) + ], + tx + ); + + if (secretUpdates.length !== updatedSecrets.length) { + throw new Error("Failed to update some secrets"); + } + if (secretVersionUpdates.length !== updatedSecretVersions.length) { + throw new Error("Failed to update some secret versions"); + } + if (secretApprovalUpdates.length !== updatedSecretApprovals.length) { + throw new Error("Failed to update some secret approvals"); + } + + throw new Error("Transaction was successful!"); + }); + } catch (err) { + const project = await projectDAL.findOne({ id: data.projectId, version: ProjectVersion.V1 }).catch(() => null); + + if (!project) { + logger.error("Failed to upgrade project, because no project was found", data); + } else { + await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.Failed); + logger.error("Failed to upgrade project", data, err); + } + + throw err; + } + }); + + queueService.listen(QueueName.UpgradeProjectToGhost, "failed", (job, err) => { + logger.error("Upgrade project failed", job?.data, err); + }); + + return { + upgradeProject + }; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9b079576b..8c763d0db 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,45 +1,36 @@ +/* eslint-disable @typescript-eslint/no-redeclare */ /* eslint-disable no-console */ /* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { ProjectMembershipRole, ProjectVersion, SecretKeyEncoding, TSecrets } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectVersion } 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"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; -import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; -import { RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; import { createSecretBlindIndex } from "@app/lib/crypto"; -import { - decryptAsymmetric, - encryptSymmetric128BitHexKeyUTF8, - infisicalSymmetricDecrypt, - infisicalSymmetricEncypt -} from "@app/lib/crypto/encryption"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { createProjectKey, createWsMembers } from "@app/lib/project"; -import { decryptSecrets, SecretDocType, TPartialSecret } from "@app/lib/secret"; +import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; -import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { TSecretDALFactory } from "../secret/secret-dal"; -import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; +import { TProjectQueueFactory } from "./project-queue"; import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO, TUpgradeProjectDTO } from "./project-types"; export const DEFAULT_PROJECT_ENVS = [ @@ -50,22 +41,18 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; + projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; folderDAL: TSecretFolderDALFactory; projectEnvDAL: Pick; - secretVersionDAL: TSecretVersionDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: TIdentityProjectDALFactory; projectKeyDAL: Pick; projectBotDAL: Pick; projectMembershipDAL: Pick; - orgDAL: TOrgDALFactory; - secretApprovalRequestDAL: TSecretApprovalRequestDALFactory; - secretApprovalSecretDAL: TSecretApprovalRequestSecretDALFactory; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; - secretDAL: TSecretDALFactory; licenseService: Pick; }; @@ -73,19 +60,15 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + projectQueue, projectKeyDAL, - secretApprovalRequestDAL, - secretApprovalSecretDAL, permissionService, userDAL, folderDAL, orgService, - orgDAL, identityProjectDAL, - secretVersionDAL, projectBotDAL, identityOrgMembershipDAL, - secretDAL, secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, @@ -396,303 +379,33 @@ export const projectServiceFactory = ({ 25. API route returns 200 OK. */ - const project = await projectDAL.findOne({ id: projectId, version: ProjectVersion.V1 }); - const oldProjectKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); - if (!project || !oldProjectKey) { + const encryptedPrivateKey = infisicalSymmetricEncypt(userPrivateKey); + + await projectQueue.upgradeProject({ + projectId, + startedByUserId: actorId, + encryptedPrivateKey: { + encryptedKey: encryptedPrivateKey.ciphertext, + encryptedKeyIv: encryptedPrivateKey.iv, + encryptedKeyTag: encryptedPrivateKey.tag, + keyEncoding: encryptedPrivateKey.encoding + } + }); + }; + + const getProjectUpgradeStatus = async ({ projectId, actor, actorId }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + + const project = await projectDAL.findProjectById(projectId); + + if (!project) { throw new BadRequestError({ - message: "Project or project key not found" + message: `Project with id ${projectId} not found` }); } - const projectEnvs = await projectEnvDAL.find({ - projectId: project.id - }); - - console.log( - "projectEnvs", - projectEnvs.map((e) => e.name) - ); - - const projectFolders = await folderDAL.find({ - $in: { - envId: projectEnvs.map((env) => env.id) - } - }); - - // Get all the secrets within the project (as encrypted) - const secrets: TPartialSecret[] = []; - for (const folder of projectFolders) { - const folderSecrets = await secretDAL.find({ folderId: folder.id }); - - const folderSecretVersions = await secretVersionDAL.find({ - folderId: folder.id - }); - const approvalRequests = await secretApprovalRequestDAL.find({ - status: RequestState.Open, - folderId: folder.id - }); - const approvalSecrets = await secretApprovalSecretDAL.find({ - $in: { - requestId: approvalRequests.map((el) => el.id) - } - }); - - secrets.push(...folderSecrets.map((el) => ({ ...el, docType: SecretDocType.Secret }))); - secrets.push(...folderSecretVersions.map((el) => ({ ...el, docType: SecretDocType.SecretVersion }))); - secrets.push(...approvalSecrets.map((el) => ({ ...el, docType: SecretDocType.ApprovalSecret }))); - } - - const decryptedSecrets = decryptSecrets(secrets, userPrivateKey, oldProjectKey); - - if (secrets.length !== decryptedSecrets.length) { - throw new Error("Failed to decrypt some secret versions"); - } - - // Get the existing bot and the existing project keys for the members of the project - const existingBot = await projectBotDAL.findOne({ projectId: project.id }).catch(() => null); - const existingProjectKeys = await projectKeyDAL.find({ projectId: project.id }); - - // TRANSACTION START - await projectDAL.transaction(async (tx) => { - await projectDAL.updateById(project.id, { version: ProjectVersion.V2 }, tx); - - // Create a ghost user - const ghostUser = await orgService.addGhostUser(project.orgId, tx); - - // Create a project key - const { key: newEncryptedProjectKey, iv: newEncryptedProjectKeyIv } = createProjectKey({ - publicKey: ghostUser.keys.publicKey, - privateKey: ghostUser.keys.plainPrivateKey - }); - - console.log("Creating new project key for ghost user"); - // Create a new project key for the GHOST - await projectKeyDAL.create( - { - projectId: project.id, - receiverId: ghostUser.user.id, - encryptedKey: newEncryptedProjectKey, - nonce: newEncryptedProjectKeyIv, - senderId: ghostUser.user.id - }, - tx - ); - - // Create a membership for the ghost user - await projectMembershipDAL.create( - { - projectId: project.id, - userId: ghostUser.user.id, - role: ProjectMembershipRole.Admin - }, - tx - ); - - // If a bot already exists, delete it - if (existingBot) { - console.log("Deleting existing bot"); - await projectBotDAL.delete({ id: existingBot.id }, tx); - } - - console.log("Deleting old project keys"); - // Delete all the existing project keys - await projectKeyDAL.delete( - { - projectId: project.id, - $in: { - id: existingProjectKeys.map((key) => key.id) - } - }, - tx - ); - - console.log("Finding latest key for ghost user"); - const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx); - - if (!ghostUserLatestKey) { - throw new Error("User latest key not found (V2 Upgrade)"); - } - - console.log("Creating new project keys for old members"); - - const newProjectMembers: { - encryptedKey: string; - nonce: string; - senderId: string; - receiverId: string; - projectId: string; - }[] = []; - - for (const key of existingProjectKeys) { - const user = await userDAL.findUserEncKeyByUserId(key.receiverId); - const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId }); - - if (!user || !orgMembership) { - throw new Error(`User with ID ${key.receiverId} was not found during upgrade, or user is not in org.`); - } - - const [newMember] = createWsMembers({ - decryptKey: ghostUserLatestKey, - userPrivateKey: ghostUser.keys.plainPrivateKey, - members: [ - { - userPublicKey: user.publicKey, - orgMembershipId: orgMembership.id, - projectMembershipRole: ProjectMembershipRole.Admin - } - ] - }); - - newProjectMembers.push({ - encryptedKey: newMember.workspaceEncryptedKey, - nonce: newMember.workspaceEncryptedNonce, - senderId: ghostUser.user.id, - receiverId: user.id, - projectId: project.id - }); - } - - // Create project keys for all the old members - await projectKeyDAL.insertMany(newProjectMembers, tx); - - // Encrypt the bot private key (which is the same as the ghost user) - const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey); - - // 5. Create a bot for the project - const newBot = await projectBotDAL.create( - { - name: "Infisical Bot (Ghost)", - projectId: project.id, - tag, - iv, - encryptedPrivateKey: ciphertext, - isActive: true, - publicKey: ghostUser.keys.publicKey, - senderId: ghostUser.user.id, - encryptedProjectKey: newEncryptedProjectKey, - encryptedProjectKeyNonce: newEncryptedProjectKeyIv, - algorithm, - keyEncoding: encoding - }, - tx - ); - - console.log("Updating secrets with new project key"); - console.log("Got decrypted secrets"); - - const botPrivateKey = infisicalSymmetricDecrypt({ - keyEncoding: newBot.keyEncoding as SecretKeyEncoding, - iv: newBot.iv, - tag: newBot.tag, - ciphertext: newBot.encryptedPrivateKey - }); - - const botKey = decryptAsymmetric({ - ciphertext: newBot.encryptedProjectKey!, - privateKey: botPrivateKey, - nonce: newBot.encryptedProjectKeyNonce!, - publicKey: ghostUser.keys.publicKey - }); - - type TPartialSecret = Pick< - TSecrets, - | "id" - | "secretKeyCiphertext" - | "secretKeyIV" - | "secretKeyTag" - | "secretValueCiphertext" - | "secretValueIV" - | "secretValueTag" - | "secretCommentCiphertext" - | "secretCommentIV" - | "secretCommentTag" - >; - - const updatedSecrets: TPartialSecret[] = []; - const updatedSecretVersions: TPartialSecret[] = []; - const updatedSecretApprovals: TPartialSecret[] = []; - for (const rawSecret of decryptedSecrets) { - const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.secretKey, botKey); - const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.secretValue || "", botKey); - const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.secretComment || "", botKey); - - const payload = { - id: rawSecret.id, - secretKeyCiphertext: secretKeyEncrypted.ciphertext, - secretKeyIV: secretKeyEncrypted.iv, - secretKeyTag: secretKeyEncrypted.tag, - secretValueCiphertext: secretValueEncrypted.ciphertext, - secretValueIV: secretValueEncrypted.iv, - secretValueTag: secretValueEncrypted.tag, - secretCommentCiphertext: secretCommentEncrypted.ciphertext, - secretCommentIV: secretCommentEncrypted.iv, - secretCommentTag: secretCommentEncrypted.tag - } as const; - - if (rawSecret.docType === SecretDocType.Secret) { - updatedSecrets.push(payload); - } else if (rawSecret.docType === SecretDocType.SecretVersion) { - updatedSecretVersions.push(payload); - } else if (rawSecret.docType === SecretDocType.ApprovalSecret) { - updatedSecretApprovals.push(payload); - } else { - throw new Error("Unknown secret type"); - } - } - - const secretUpdates = await secretDAL.bulkUpdateNoVersionIncrement( - [ - ...updatedSecrets.map((secret) => ({ - filter: { id: secret.id }, - data: { - ...secret, - id: undefined - } - })) - ], - tx - ); - - const secretVersionUpdates = await secretVersionDAL.bulkUpdateNoVersionIncrement( - [ - ...updatedSecretVersions.map((version) => ({ - filter: { id: version.id }, - data: { - ...version, - id: undefined - } - })) - ], - tx - ); - - const secretApprovalUpdates = await secretApprovalSecretDAL.bulkUpdateNoVersionIncrement( - [ - ...updatedSecretApprovals.map((approval) => ({ - filter: { - id: approval.id - }, - data: { - ...approval, - id: undefined - } - })) - ], - tx - ); - - if (secretUpdates.length !== updatedSecrets.length) { - throw new Error("Failed to update some secrets"); - } - if (secretVersionUpdates.length !== updatedSecretVersions.length) { - throw new Error("Failed to update some secret versions"); - } - if (secretApprovalUpdates.length !== updatedSecretApprovals.length) { - throw new Error("Failed to update some secret approvals"); - } - - throw new Error("Transaction was successful"); - }); + return project.upgradeStatus || null; }; return { @@ -700,6 +413,7 @@ export const projectServiceFactory = ({ deleteProject, getProjects, findProjectGhostUser, + getProjectUpgradeStatus, getAProject, toggleAutoCapitalization, updateName, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 9cdc55c8a..faaef42d4 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -70,6 +70,7 @@ export const superAdminServiceFactory = ({ lastName, email, superAdmin: true, + ghost: false, isAccepted: true, authMethods: [AuthMethod.EMAIL] }, diff --git a/frontend/src/components/v2/UpgradeProjectAlert/UpgradeProjectAlert.tsx b/frontend/src/components/v2/UpgradeProjectAlert/UpgradeProjectAlert.tsx new file mode 100644 index 000000000..c0d3b4d59 --- /dev/null +++ b/frontend/src/components/v2/UpgradeProjectAlert/UpgradeProjectAlert.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useState } from "react"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { useGetUpgradeProjectStatus, useUpgradeProject } from "@app/hooks/api"; +import { Workspace } from "@app/hooks/api/types"; + +import { Alert } from "../Alert"; +import { Button } from "../Button"; + +export type UpgradeProjectAlertProps = { + project: Workspace; +}; + +export const UpgradeProjectAlert = ({ project }: UpgradeProjectAlertProps): JSX.Element | null => { + const { createNotification } = useNotificationContext(); + const upgradeProject = useUpgradeProject(); + const [currentStatus, setCurrentStatus] = useState(null); + const { + data: projectStatus, + refetch: getLatestProjectStatus, + isLoading: statusIsLoading + } = useGetUpgradeProjectStatus(project.id); + + const onUpgradeProject = useCallback(async () => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); + + if (!PRIVATE_KEY) { + createNotification({ + type: "error", + text: "Private key not found" + }); + return; + } + + await upgradeProject.mutateAsync({ + projectId: project.id, + privateKey: PRIVATE_KEY + }); + + await getLatestProjectStatus(); + }, []); + + useEffect(() => { + if (project.version === "v1") { + getLatestProjectStatus(); + } + + if (projectStatus && projectStatus?.status !== null) { + if (projectStatus.status === "IN_PROGRESS") { + setCurrentStatus("Your upgrade is being processed."); + } else if (projectStatus.status === "FAILED") { + setCurrentStatus("Upgrade failed, please try again."); + } + } + + const interval = setInterval(() => { + if (project.version === "v1") { + getLatestProjectStatus(); + } + }, 5_000); + + return () => { + clearInterval(interval); + }; + }, [projectStatus]); + + const isLoading = + (upgradeProject.isLoading || + currentStatus !== null || + (currentStatus === null && statusIsLoading)) && + projectStatus?.status !== "FAILED"; + + if (project.version !== "v1") return null; + + return ( +
+ +
+ Upgrade your project version to continue receiving the latest improvements and patches. + {currentStatus &&

Status: {currentStatus}

} +
+
+ +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/UpgradeProjectAlert/index.tsx b/frontend/src/components/v2/UpgradeProjectAlert/index.tsx new file mode 100644 index 000000000..ab67f86cf --- /dev/null +++ b/frontend/src/components/v2/UpgradeProjectAlert/index.tsx @@ -0,0 +1 @@ +export { UpgradeProjectAlert } from "./UpgradeProjectAlert"; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 1ad051bc0..e66af93a1 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -7,6 +7,7 @@ export { useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, + useGetUpgradeProjectStatus, useGetUserWorkspaceMemberships, useGetUserWorkspaces, useGetWorkspaceAuthorizations, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7ec85c442..443cf45e9 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -24,6 +24,7 @@ export const workspaceKeys = { getWorkspaceSecrets: (workspaceId: string) => [{ workspaceId }, "workspace-secrets"] as const, getWorkspaceIndexStatus: (workspaceId: string) => [{ workspaceId }, "workspace-index-status"] as const, + getProjectUpgradeStatus: (workspaceId: string) => [{ workspaceId }, "workspace-upgrade-status"], getWorkspaceMemberships: (orgId: string) => [{ orgId }, "workspace-memberships"], getWorkspaceAuthorization: (workspaceId: string) => [{ workspaceId }, "workspace-authorizations"], getWorkspaceIntegrations: (workspaceId: string) => [{ workspaceId }, "workspace-integrations"], @@ -51,6 +52,14 @@ const fetchWorkspaceIndexStatus = async (workspaceId: string) => { return data; }; +const fetchProjectUpgradeStatus = async (projectId: string) => { + const { data } = await apiRequest.get<{ status: string }>( + `/api/v2/workspace/${projectId}/upgrade/status` + ); + + return data; +}; + export const fetchWorkspaceSecrets = async (workspaceId: string) => { const { data: { secrets } @@ -76,6 +85,14 @@ export const useUpgradeProject = () => { }); }; +export const useGetUpgradeProjectStatus = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getProjectUpgradeStatus(projectId), + queryFn: () => fetchProjectUpgradeStatus(projectId), + enabled: true + }); +}; + const fetchUserWorkspaces = async () => { const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace"); return data.workspaces; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 5fd3898f8..a888b7c34 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -4,6 +4,7 @@ export type Workspace = { name: string; orgId: string; version: "v1" | "v2"; + upgradeStatus: string | null; autoCapitalization: boolean; environments: WorkspaceEnv[]; slug: string; diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index b7af0e3a4..7ea00831c 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -14,8 +14,6 @@ import { useNotificationContext } from "@app/components/context/Notifications/No import NavHeader from "@app/components/navigation/NavHeader"; import { PermissionDeniedBanner } from "@app/components/permissions"; import { - Alert, - AlertDescription, Button, EmptyState, IconButton, @@ -31,6 +29,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { UpgradeProjectAlert } from "@app/components/v2/UpgradeProjectAlert"; import { useOrganization, useWorkspace } from "@app/context"; import { useCreateFolder, @@ -39,8 +38,7 @@ import { useGetFoldersByEnv, useGetProjectSecretsAllEnv, useGetUserWsKey, - useUpdateSecretV3, - useUpgradeProject + useUpdateSecretV3 } from "@app/hooks/api"; import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; @@ -107,7 +105,6 @@ export const SecretOverviewPage = () => { environments: userAvailableEnvs.map(({ slug }) => slug) }); - const upgradeProject = useUpgradeProject(); const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); @@ -201,24 +198,6 @@ export const SecretOverviewPage = () => { } }; - const onUpgradeProject = useCallback(async () => { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); - - if (!PRIVATE_KEY) { - createNotification({ - type: "error", - text: "Private key not found" - }); - - return; - } - - await upgradeProject.mutateAsync({ - projectId: workspaceId, - privateKey: PRIVATE_KEY - }); - }, []); - const handleResetSearch = () => setSearchFilter(""); const handleFolderClick = (path: string) => { @@ -338,22 +317,7 @@ export const SecretOverviewPage = () => {

- {currentWorkspace?.version === "v1" && ( -
- - - Upgrade your project. More filler text More filler text More filler text More filler - text More filler text More filler text More filler text More filler text More filler - text More filler text More filler text More filler text{" "} - -
- -
-
-
- )} + {currentWorkspace?.version === "v1" && }