diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index 29275abd1..7fba6c321 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -72,4 +72,4 @@ jobs: run: | docker-compose -f "docker-compose.dev.yml" down docker stop infisical-api - docker remove infisical-api + docker remove infisical-api \ No newline at end of file diff --git a/.github/workflows/run-backend-tests.yml b/.github/workflows/run-backend-tests.yml index a283a538d..edb58f9a6 100644 --- a/.github/workflows/run-backend-tests.yml +++ b/.github/workflows/run-backend-tests.yml @@ -44,4 +44,4 @@ jobs: ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 - name: cleanup run: | - docker-compose -f "docker-compose.dev.yml" down + docker-compose -f "docker-compose.dev.yml" down \ No newline at end of file diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 7c58fb0af..afb1bbabe 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -56,6 +56,7 @@ export default { { expiresIn: cfg.JWT_AUTH_LIFETIME } ); } catch (error) { + console.log("[TEST] Error setting up environment", error); await db.destroy(); throw error; } diff --git a/backend/src/db/migrations/20240216154123_ghost_users.ts b/backend/src/db/migrations/20240216154123_ghost_users.ts new file mode 100644 index 000000000..d0a840ee2 --- /dev/null +++ b/backend/src/db/migrations/20240216154123_ghost_users.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { ProjectVersion, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGhostUserColumn = await knex.schema.hasColumn(TableName.Users, "isGhost"); + const hasProjectVersionColumn = await knex.schema.hasColumn(TableName.Project, "version"); + + if (!hasGhostUserColumn) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isGhost").defaultTo(false).notNullable(); + }); + } + + if (!hasProjectVersionColumn) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.integer("version").defaultTo(ProjectVersion.V1).notNullable(); + t.string("upgradeStatus").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGhostUserColumn = await knex.schema.hasColumn(TableName.Users, "isGhost"); + const hasProjectVersionColumn = await knex.schema.hasColumn(TableName.Project, "version"); + + if (hasGhostUserColumn) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isGhost"); + }); + } + + 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 e817d6ed0..abfb67949 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -112,6 +112,17 @@ export enum SecretType { Personal = "personal" } +export enum ProjectVersion { + V1 = 1, + V2 = 2 +} + +export enum ProjectUpgradeStatus { + InProgress = "IN_PROGRESS", + // 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 296fa421e..3834d6d58 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -14,7 +14,9 @@ export const ProjectsSchema = z.object({ autoCapitalization: z.boolean().default(true).nullable().optional(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + version: z.number().default(1), + upgradeStatus: z.string().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 4a29de510..cdbb5607a 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -19,7 +19,8 @@ export const UsersSchema = z.object({ mfaMethods: z.string().array().nullable().optional(), devices: z.unknown().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isGhost: z.boolean().default(false) }); export type TUsers = z.infer; diff --git a/backend/src/db/seed-data.ts b/backend/src/db/seed-data.ts index 63821e15e..6406e44ab 100644 --- a/backend/src/db/seed-data.ts +++ b/backend/src/db/seed-data.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-mutable-exports */ import crypto from "node:crypto"; import argon2, { argon2id } from "argon2"; @@ -15,9 +16,12 @@ import { import { TSecrets, TUserEncryptionKeys } from "./schemas"; +export let userPrivateKey: string | undefined; +export let userPublicKey: string | undefined; + export const seedData1 = { id: "3dafd81d-4388-432b-a4c5-f735616868c1", - email: "test@localhost.local", + email: process.env.TEST_USER_EMAIL || "test@localhost.local", password: process.env.TEST_USER_PASSWORD || "testInfisical@1", organization: { id: "180870b7-f464-4740-8ffe-9d11c9245ea7", @@ -42,6 +46,12 @@ export const seedData1 = { }, token: { id: "a9dfafba-a3b7-42e3-8618-91abb702fd36" + }, + + // We set these values during user creation, and later re-use them during project seeding. + encryptionKeys: { + publicKey: "", + privateKey: "" } }; diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 1c7868fc2..c4d4aabc0 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -58,6 +58,7 @@ export const auditLogServiceFactory = ({ if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) { if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must either project id or org id" }); } + return auditLogQueue.pushToLog(data); }; 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..b541e16be 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], + isGhost: false }, tx ); diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 657d95a4a..33b2f2c84 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -251,7 +251,8 @@ export const scimServiceFactory = ({ email, firstName, lastName, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isGhost: false }, tx ); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index 9b4742255..736cd253e 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -1,8 +1,13 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { SecretApprovalRequestsSecretsSchema, TableName, TSecretTags } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { + SecretApprovalRequestsSecretsSchema, + TableName, + TSecretApprovalRequestsSecrets, + TSecretTags +} from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TSecretApprovalRequestSecretDALFactory = ReturnType; @@ -11,6 +16,35 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { const secretApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret); const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag); + const bulkUpdateNoVersionIncrement = async (data: TSecretApprovalRequestsSecrets[], tx?: Knex) => { + try { + const existingApprovalSecrets = await secretApprovalRequestSecretOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingApprovalSecrets.length !== data.length) { + throw new BadRequestError({ message: "Some of the secret approvals do not exist" }); + } + + if (data.length === 0) return []; + + const updatedApprovalSecrets = await (tx || db)(TableName.SecretApprovalRequestSecret) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedApprovalSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + const findByRequestId = async (requestId: string, tx?: Knex) => { try { const doc = await (tx || db)({ @@ -190,6 +224,7 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { return { ...secretApprovalRequestSecretOrm, findByRequestId, + bulkUpdateNoVersionIncrement, insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany }; }; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index ef10db804..9a91b745d 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -11,6 +11,7 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { groupBy, pick, unique } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorType } from "@app/services/auth/auth-type"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; @@ -47,6 +48,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretBlindIndexDAL: Pick; snapshotService: Pick; secretVersionDAL: Pick; + projectDAL: Pick; secretService: Pick< TSecretServiceFactory, | "fnSecretBulkInsert" @@ -67,6 +69,7 @@ export const secretApprovalRequestServiceFactory = ({ secretApprovalRequestReviewerDAL, secretApprovalRequestSecretDAL, secretBlindIndexDAL, + projectDAL, permissionService, snapshotService, secretService, @@ -434,6 +437,8 @@ export const secretApprovalRequestServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "GenSecretApproval" }); const folderId = folder.id; diff --git a/backend/src/lib/crypto/encryption.ts b/backend/src/lib/crypto/encryption.ts index 2c947b75b..16a7f42e7 100644 --- a/backend/src/lib/crypto/encryption.ts +++ b/backend/src/lib/crypto/encryption.ts @@ -8,6 +8,9 @@ import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { getConfig } from "../config/env"; +export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s); +export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u); + export type TDecryptSymmetricInput = { ciphertext: string; iv: string; diff --git a/backend/src/lib/crypto/index.ts b/backend/src/lib/crypto/index.ts index 62278de1d..9d7f4e886 100644 --- a/backend/src/lib/crypto/index.ts +++ b/backend/src/lib/crypto/index.ts @@ -1,12 +1,20 @@ export { buildSecretBlindIndexFromName, createSecretBlindIndex, + decodeBase64, decryptAsymmetric, decryptSymmetric, decryptSymmetric128BitHexKeyUTF8, + encodeBase64, encryptAsymmetric, encryptSymmetric, encryptSymmetric128BitHexKeyUTF8, generateAsymmetricKeyPair } from "./encryption"; +export { + decryptIntegrationAuths, + decryptSecretApprovals, + decryptSecrets, + decryptSecretVersions +} from "./secret-encryption"; export { generateSrpServerKey, srpCheckClientProof } from "./srp"; diff --git a/backend/src/lib/crypto/secret-encryption.ts b/backend/src/lib/crypto/secret-encryption.ts new file mode 100644 index 000000000..2e0492560 --- /dev/null +++ b/backend/src/lib/crypto/secret-encryption.ts @@ -0,0 +1,293 @@ +import crypto from "crypto"; +import { z } from "zod"; + +import { + IntegrationAuthsSchema, + SecretApprovalRequestsSecretsSchema, + SecretsSchema, + SecretVersionsSchema, + TIntegrationAuths, + TProjectKeys, + TSecretApprovalRequestsSecrets, + TSecrets, + TSecretVersions +} from "../../db/schemas"; +import { decryptAsymmetric } from "./encryption"; + +const DecryptedValuesSchema = z.object({ + id: z.string(), + secretKey: z.string(), + secretValue: z.string(), + secretComment: z.string().optional() +}); + +const DecryptedSecretSchema = z.object({ + decrypted: DecryptedValuesSchema, + original: SecretsSchema +}); + +const DecryptedIntegrationAuthsSchema = z.object({ + decrypted: z.object({ + id: z.string(), + access: z.string(), + accessId: z.string(), + refresh: z.string() + }), + original: IntegrationAuthsSchema +}); + +const DecryptedSecretVersionsSchema = z.object({ + decrypted: DecryptedValuesSchema, + original: SecretVersionsSchema +}); + +const DecryptedSecretApprovalsSchema = z.object({ + decrypted: DecryptedValuesSchema, + original: SecretApprovalRequestsSecretsSchema +}); + +type DecryptedSecret = z.infer; +type DecryptedSecretVersions = z.infer; +type DecryptedSecretApprovals = z.infer; +type DecryptedIntegrationAuths = z.infer; + +type TLatestKey = TProjectKeys & { + sender: { + publicKey: string; + }; +}; + +const decryptCipher = ({ + ciphertext, + iv, + tag, + key +}: { + ciphertext: string; + iv: string; + tag: string; + key: string | Buffer; +}) => { + const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64")); + decipher.setAuthTag(Buffer.from(tag, "base64")); + + let cleartext = decipher.update(ciphertext, "base64", "utf8"); + cleartext += decipher.final("utf8"); + + return cleartext; +}; + +const getDecryptedValues = (data: Array<{ ciphertext: string; iv: string; tag: string }>, key: string | Buffer) => { + const results: string[] = []; + + for (const { ciphertext, iv, tag } of data) { + if (!ciphertext || !iv || !tag) { + results.push(""); + } else { + results.push(decryptCipher({ ciphertext, iv, tag, key })); + } + } + + return results; +}; +export const decryptSecrets = (encryptedSecrets: TSecrets[], privateKey: string, latestKey: TLatestKey) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedSecrets: DecryptedSecret[] = []; + + encryptedSecrets.forEach((encSecret) => { + const [secretKey, secretValue, secretComment] = getDecryptedValues( + [ + { + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag + }, + { + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag + }, + { + ciphertext: encSecret.secretCommentCiphertext || "", + iv: encSecret.secretCommentIV || "", + tag: encSecret.secretCommentTag || "" + } + ], + key + ); + + const decryptedSecret: DecryptedSecret = { + decrypted: { + secretKey, + secretValue, + secretComment, + id: encSecret.id + }, + original: encSecret + }; + + decryptedSecrets.push(DecryptedSecretSchema.parse(decryptedSecret)); + }); + + return decryptedSecrets; +}; + +export const decryptSecretVersions = ( + encryptedSecretVersions: TSecretVersions[], + privateKey: string, + latestKey: TLatestKey +) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedSecrets: DecryptedSecretVersions[] = []; + + encryptedSecretVersions.forEach((encSecret) => { + const [secretKey, secretValue, secretComment] = getDecryptedValues( + [ + { + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag + }, + { + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag + }, + { + ciphertext: encSecret.secretCommentCiphertext || "", + iv: encSecret.secretCommentIV || "", + tag: encSecret.secretCommentTag || "" + } + ], + key + ); + + const decryptedSecret: DecryptedSecretVersions = { + decrypted: { + secretKey, + secretValue, + secretComment, + id: encSecret.id + }, + original: encSecret + }; + + decryptedSecrets.push(DecryptedSecretVersionsSchema.parse(decryptedSecret)); + }); + + return decryptedSecrets; +}; + +export const decryptSecretApprovals = ( + encryptedSecretApprovals: TSecretApprovalRequestsSecrets[], + privateKey: string, + latestKey: TLatestKey +) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedSecrets: DecryptedSecretApprovals[] = []; + + encryptedSecretApprovals.forEach((encApproval) => { + const [secretKey, secretValue, secretComment] = getDecryptedValues( + [ + { + ciphertext: encApproval.secretKeyCiphertext, + iv: encApproval.secretKeyIV, + tag: encApproval.secretKeyTag + }, + { + ciphertext: encApproval.secretValueCiphertext, + iv: encApproval.secretValueIV, + tag: encApproval.secretValueTag + }, + { + ciphertext: encApproval.secretCommentCiphertext || "", + iv: encApproval.secretCommentIV || "", + tag: encApproval.secretCommentTag || "" + } + ], + key + ); + + const decryptedSecret: DecryptedSecretApprovals = { + decrypted: { + secretKey, + secretValue, + secretComment, + id: encApproval.id + }, + original: encApproval + }; + + decryptedSecrets.push(DecryptedSecretApprovalsSchema.parse(decryptedSecret)); + }); + + return decryptedSecrets; +}; + +export const decryptIntegrationAuths = ( + encryptedIntegrationAuths: TIntegrationAuths[], + privateKey: string, + latestKey: TLatestKey +) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedIntegrationAuths: DecryptedIntegrationAuths[] = []; + + encryptedIntegrationAuths.forEach((encAuth) => { + const [access, accessId, refresh] = getDecryptedValues( + [ + { + ciphertext: encAuth.accessCiphertext || "", + iv: encAuth.accessIV || "", + tag: encAuth.accessTag || "" + }, + { + ciphertext: encAuth.accessIdCiphertext || "", + iv: encAuth.accessIdIV || "", + tag: encAuth.accessIdTag || "" + }, + { + ciphertext: encAuth.refreshCiphertext || "", + iv: encAuth.refreshIV || "", + tag: encAuth.refreshTag || "" + } + ], + key + ); + + decryptedIntegrationAuths.push({ + decrypted: { + id: encAuth.id, + access, + accessId, + refresh + }, + original: encAuth + }); + }); + + return decryptedIntegrationAuths; +}; diff --git a/backend/src/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index b05f3734d..bc29cdb3f 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -1,4 +1,12 @@ +import argon2 from "argon2"; +import crypto from "crypto"; import jsrp from "jsrp"; +import nacl from "tweetnacl"; +import tweetnacl from "tweetnacl-util"; + +import { TUserEncryptionKeys } from "@app/db/schemas"; + +import { decryptSymmetric, encryptAsymmetric, encryptSymmetric } from "./encryption"; export const generateSrpServerKey = async (salt: string, verifier: string) => { // eslint-disable-next-line new-cap @@ -24,3 +32,99 @@ export const srpCheckClientProof = async ( server.setClientPublicKey(clientPublicKey); return server.checkClientProof(clientProof); }; + +// Ghost user related: +// This functionality is intended for ghost user logic. This happens on the frontend when a user is being created. +// We replicate the same functionality on the backend when creating a ghost user. +export const generateUserSrpKeys = async (email: string, password: string) => { + const pair = nacl.box.keyPair(); + const secretKeyUint8Array = pair.secretKey; + const publicKeyUint8Array = pair.publicKey; + const privateKey = tweetnacl.encodeBase64(secretKeyUint8Array); + const publicKey = tweetnacl.encodeBase64(publicKeyUint8Array); + + // eslint-disable-next-line + const client = new jsrp.client(); + await new Promise((resolve) => { + client.init({ username: email, password }, () => resolve(null)); + }); + const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>((resolve, reject) => { + client.createVerifier((err, res) => { + if (err) return reject(err); + return resolve(res); + }); + }); + const derivedKey = await argon2.hash(password, { + salt: Buffer.from(salt), + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + hashLength: 32, + type: argon2.argon2id, + raw: true + }); + if (!derivedKey) throw new Error("Failed to derive key from password"); + + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = encryptSymmetric(privateKey, key.toString("base64")); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = encryptSymmetric(key.toString("hex"), derivedKey.toString("base64")); + + return { + protectedKey, + plainPrivateKey: privateKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }; +}; + +export const getUserPrivateKey = async (password: string, user: TUserEncryptionKeys) => { + const derivedKey = await argon2.hash(password, { + salt: Buffer.from(user.salt), + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + hashLength: 32, + type: argon2.argon2id, + raw: true + }); + if (!derivedKey) throw new Error("Failed to derive key from password"); + const key = decryptSymmetric({ + ciphertext: user.protectedKey!, + iv: user.protectedKeyIV!, + tag: user.protectedKeyTag!, + key: derivedKey.toString("base64") + }); + const privateKey = decryptSymmetric({ + ciphertext: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag, + key + }); + return privateKey; +}; + +export const buildUserProjectKey = async (privateKey: string, publickey: string) => { + const randomBytes = crypto.randomBytes(16).toString("hex"); + const { nonce, ciphertext } = encryptAsymmetric(randomBytes, publickey, privateKey); + return { nonce, ciphertext }; +}; 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 741e2b703..9444aff16 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"; @@ -280,19 +281,13 @@ export const registerRoutes = async ( secretScanningDAL, secretScanningQueue }); - const projectService = projectServiceFactory({ - permissionService, - projectDAL, - secretBlindIndexDAL, - projectEnvDAL, - projectMembershipDAL, - folderDAL, - licenseService - }); + const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL }); + const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDAL, projectDAL, permissionService, + projectBotDAL, orgDAL, userDAL, smtpService, @@ -300,6 +295,46 @@ export const registerRoutes = async ( projectRoleDAL, licenseService }); + const projectKeyService = projectKeyServiceFactory({ + permissionService, + projectKeyDAL, + projectMembershipDAL + }); + + const projectQueueService = projectQueueFactory({ + queueService, + secretDAL, + folderDAL, + projectDAL, + orgDAL, + integrationAuthDAL, + orgService, + projectEnvDAL, + userDAL, + secretVersionDAL, + projectKeyDAL, + projectBotDAL, + projectMembershipDAL, + secretApprovalRequestDAL, + secretApprovalSecretDAL: sarSecretDAL + }); + + const projectService = projectServiceFactory({ + permissionService, + projectDAL, + projectQueue: projectQueueService, + secretBlindIndexDAL, + identityProjectDAL, + identityOrgMembershipDAL, + projectBotDAL, + projectKeyDAL, + userDAL, + projectEnvDAL, + orgService, + projectMembershipDAL, + folderDAL, + licenseService + }); const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDAL, @@ -307,11 +342,7 @@ export const registerRoutes = async ( projectDAL, folderDAL }); - const projectKeyService = projectKeyServiceFactory({ - permissionService, - projectKeyDAL, - projectMembershipDAL - }); + const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -346,9 +377,9 @@ export const registerRoutes = async ( folderDAL, permissionService, secretImportDAL, + projectDAL, secretDAL }); - const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL }); const integrationAuthService = integrationAuthServiceFactory({ integrationAuthDAL, integrationDAL, @@ -382,6 +413,7 @@ export const registerRoutes = async ( secretVersionTagDAL, secretBlindIndexDAL, permissionService, + projectDAL, secretDAL, secretTagDAL, snapshotService, @@ -395,6 +427,7 @@ export const registerRoutes = async ( secretTagDAL, secretApprovalRequestSecretDAL: sarSecretDAL, secretApprovalRequestReviewerDAL: sarReviewerDAL, + projectDAL, secretVersionDAL, secretBlindIndexDAL, secretApprovalRequestDAL, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 744ba6ebf..fbc68d974 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -48,6 +48,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await projectRouter.register(registerProjectMembershipRouter); await projectRouter.register(registerSecretTagRouter); }, + { prefix: "/workspace" } ); diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 09de72233..afa2135c0 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { OrgMembershipsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + OrgMembershipsSchema, + ProjectMembershipRole, + ProjectMembershipsSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -80,7 +86,10 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, - members: req.body.members + members: req.body.members.map((member) => ({ + ...member, + projectRole: ProjectMembershipRole.Member + })) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index f0faaa0f4..0a5f8d85e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -2,13 +2,11 @@ import { z } from "zod"; import { IntegrationsSchema, - ProjectKeysSchema, ProjectMembershipsSchema, ProjectsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; -import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -119,7 +117,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspace = await server.services.project.getAProject({ actorId: req.permission.id, @@ -171,7 +169,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspace = await server.services.project.deleteProject({ actorId: req.permission.id, @@ -216,6 +214,41 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/:workspaceId", + method: "PATCH", + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + name: z.string().trim().optional(), + autoCapitalization: z.boolean().optional() + }), + response: { + 200: z.object({ + workspace: ProjectsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const workspace = await server.services.project.updateProject({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + update: { + name: req.body.name, + autoCapitalization: req.body.autoCapitalization + } + }); + return { + workspace + }; + } + }); + server.route({ url: "/:workspaceId/auto-capitalization", method: "POST", @@ -249,48 +282,6 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); - server.route({ - url: "/:workspaceId/invite-signup", - method: "POST", - schema: { - params: z.object({ - workspaceId: z.string().trim() - }), - body: z.object({ - email: z.string().trim() - }), - response: { - 200: z.object({ - invitee: UsersSchema, - latestKey: ProjectKeysSchema.optional() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const { invitee, latestKey } = await server.services.projectMembership.inviteUserToProject({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - email: req.body.email - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.ADD_WORKSPACE_MEMBER, - metadata: { - userId: invitee.id, - email: invitee.email - } - } - }); - return { invitee, latestKey }; - } - }); - server.route({ url: "/:workspaceId/integrations", method: "GET", diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index 1f423c084..deebbc981 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -2,6 +2,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; +import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerServiceTokenRouter } from "./service-token-router"; import { registerUserRouter } from "./user-router"; @@ -21,6 +22,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (projectServer) => { await projectServer.register(registerProjectRouter); await projectServer.register(registerIdentityProjectRouter); + await projectServer.register(registerProjectMembershipRouter); }, { prefix: "/workspace" } ); diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts new file mode 100644 index 000000000..417fcb44c --- /dev/null +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +import { ProjectMembershipsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/memberships", + schema: { + params: z.object({ + projectId: z.string().describe("The ID of the project.") + }), + body: z.object({ + emails: z.string().email().array().describe("Emails of the users to add to the project.") + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.projectMembership.addUsersToProjectNonE2EE({ + projectId: req.params.projectId, + actorId: req.permission.id, + actor: req.permission.type, + emails: req.body.emails + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.ADD_BATCH_WORKSPACE_MEMBER, + metadata: memberships.map(({ userId, id }) => ({ + userId: userId || "", + membershipId: id, + email: "" + })) + } + }); + + return { memberships }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/memberships", + schema: { + params: z.object({ + projectId: z.string().describe("The ID of the project.") + }), + + body: z.object({ + emails: z.string().email().array().describe("Emails of the users to remove from the project.") + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.projectMembership.deleteProjectMemberships({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + emails: req.body.emails + }); + + for (const membership of memberships) { + // eslint-disable-next-line no-await-in-loop + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.projectId, + event: { + type: EventType.REMOVE_WORKSPACE_MEMBER, + metadata: { + userId: membership.userId, + email: "" + } + } + }); + } + return { memberships }; + } + }); +}; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index d38efdbb9..7cbcff37c 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,11 +1,21 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectKeysSchema } from "@app/db/schemas"; +import { ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { authRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const projectWithEnv = ProjectsSchema.merge( + z.object({ + _id: z.string(), + environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() + }) +); + export const registerProjectRouter = async (server: FastifyZodProvider) => { + /* Get project key */ server.route({ url: "/:workspaceId/encrypted-key", method: "GET", @@ -34,8 +44,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const key = await server.services.projectKey.getLatestProjectKey({ actor: req.permission.type, actorId: req.permission.id, - projectId: req.params.workspaceId, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId }); await server.services.auditLog.createAuditLog({ @@ -52,4 +62,97 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return key; } }); + + /* Start upgrade of a project */ + server.route({ + url: "/:projectId/upgrade", + method: "POST", + schema: { + params: z.object({ + projectId: z.string().trim() + }), + + body: z.object({ + userPrivateKey: z.string().trim() + }), + response: { + 200: z.void() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + await server.services.project.upgradeProject({ + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.params.projectId, + userPrivateKey: req.body.userPrivateKey + }); + } + }); + + /* Get upgrade status of project */ + 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", + url: "/", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + projectName: z.string().trim(), + slug: z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional(), + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + project: projectWithEnv + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const project = await server.services.project.createProject({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.body.organizationId, + workspaceName: req.body.projectName, + slug: req.body.slug + }); + + return { project }; + } + }); }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 6e4d60bba..dcc9381b7 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -275,7 +275,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: if (isOauthSignUpDisabled) throw new BadRequestError({ message: "User signup disabled", name: "Oauth 2 login" }); if (!user) { - user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod] }); + user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod], isGhost: false }); } const isLinkingRequired = !user?.authMethods?.includes(authMethod); const isUserCompleted = user.isAccepted; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 021ca7070..61fcfe3ce 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, isGhost: false }); } if (!user) throw new Error("Failed to create user"); diff --git a/backend/src/services/integration-auth/integration-auth-dal.ts b/backend/src/services/integration-auth/integration-auth-dal.ts index f2d9c9ea2..d32cd1579 100644 --- a/backend/src/services/integration-auth/integration-auth-dal.ts +++ b/backend/src/services/integration-auth/integration-auth-dal.ts @@ -1,10 +1,35 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TIntegrationAuthDALFactory = ReturnType; export const integrationAuthDALFactory = (db: TDbClient) => { const integrationAuthOrm = ormify(db, TableName.IntegrationAuth); - return integrationAuthOrm; + + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TIntegrationAuthsUpdate }>, + tx?: Knex + ) => { + try { + const integrationAuths = await Promise.all( + data.map(async ({ filter, data: updateData }) => { + const [doc] = await (tx || db)(TableName.IntegrationAuth).where(filter).update(updateData).returning("*"); + if (!doc) throw new BadRequestError({ message: "Failed to update document" }); + return doc; + }) + ); + return integrationAuths; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + return { + ...integrationAuthOrm, + bulkUpdate + }; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index bb3cbbd95..05c77e746 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -76,7 +76,8 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("lastName").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId"), db.ref("publicKey").withSchema(TableName.UserEncryptionKey) - ); + ) + .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ ...data, user: { email, firstName, lastName, id: userId, publicKey } @@ -86,6 +87,79 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const findOrgMembersByEmail = async (orgId: string, emails: string[]) => { + try { + const members = await db(TableName.OrgMembership) + .where({ orgId }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: false }) + .whereIn("email", emails); + return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ + ...data, + user: { email, firstName, lastName, id: userId, publicKey } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find all org members" }); + } + }; + + const findOrgGhostUser = async (orgId: string) => { + try { + const member = await db(TableName.OrgMembership) + .where({ orgId }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: true }) + .first(); + return member; + } catch (error) { + return null; + } + }; + + const ghostUserExists = async (orgId: string) => { + try { + const member = await db(TableName.OrgMembership) + .where({ orgId }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`) + .select(db.ref("id").withSchema(TableName.Users).as("userId")) + .where({ isGhost: true }) + .first(); + return Boolean(member); + } catch (error) { + return false; + } + }; + const create = async (dto: TOrganizationsInsert, tx?: Knex) => { try { const [organization] = await (tx || db)(TableName.Organization).insert(dto).returning("*"); @@ -191,6 +265,9 @@ export const orgDALFactory = (db: TDbClient) => { findAllOrgMembers, findOrgById, findAllOrgsByUserId, + ghostUserExists, + findOrgMembersByEmail, + findOrgGhostUser, create, updateById, deleteById, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 1d78ef0b9..f39ac5366 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -1,6 +1,8 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; +import crypto from "crypto"; import jwt from "jsonwebtoken"; +import { Knex } from "knex"; import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; @@ -11,6 +13,7 @@ import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config- import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; @@ -28,6 +31,7 @@ import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, TFindAllWorkspacesDTO, + TFindOrgMembersByEmailDTO, TInviteUserToOrgDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, @@ -93,6 +97,15 @@ export const orgServiceFactory = ({ return members; }; + const findOrgMembersByEmail = async ({ actor, actorId, orgId, emails }: TFindOrgMembersByEmailDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + + const members = await orgDAL.findOrgMembersByEmail(orgId, emails); + + return members; + }; + const findAllWorkspaces = async ({ actor, actorId, actorOrgId, orgId }: TFindAllWorkspacesDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); @@ -118,6 +131,54 @@ export const orgServiceFactory = ({ return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id)); }; + const addGhostUser = async (orgId: string, tx?: Knex) => { + const email = `sudo-${alphaNumericNanoId(16)}-${orgId}@infisical.com`; // We add a nanoid because the email is unique. And we have to create a new ghost user each time, so we can have access to the private key. + const password = crypto.randomBytes(128).toString("hex"); + + const user = await userDAL.create( + { + isGhost: true, + authMethods: [AuthMethod.EMAIL], + email, + isAccepted: true + }, + tx + ); + + const encKeys = await generateUserSrpKeys(email, password); + + await userDAL.upsertUserEncryptionKey( + user.id, + { + encryptionVersion: 2, + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + publicKey: encKeys.publicKey, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, + salt: encKeys.salt, + verifier: encKeys.verifier + }, + tx + ); + + const createMembershipData = { + orgId, + userId: user.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted + }; + + await orgDAL.createMembership(createMembershipData, tx); + + return { + user, + keys: encKeys + }; + }; + /* * Update organization details * */ @@ -338,7 +399,8 @@ export const orgServiceFactory = ({ { email: inviteeEmail, isAccepted: false, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isGhost: false }, tx ); @@ -487,10 +549,12 @@ export const orgServiceFactory = ({ inviteUserToOrganization, verifyUserToOrg, updateOrg, + findOrgMembersByEmail, createOrganization, deleteOrganizationById, deleteOrgMembership, findAllWorkspaces, + addGhostUser, updateOrgMembership, // incident contacts findIncidentContacts, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index bbb325247..bd8fe2e95 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -30,6 +30,13 @@ export type TVerifyUserToOrgDTO = { code: string; }; +export type TFindOrgMembersByEmailDTO = { + actor: ActorType; + actorId: string; + orgId: string; + emails: string[]; +}; + export type TFindAllWorkspacesDTO = { actor: ActorType; actorId: string; diff --git a/backend/src/services/project-bot/project-bot-dal.ts b/backend/src/services/project-bot/project-bot-dal.ts index 7f342f0ae..74abf8f21 100644 --- a/backend/src/services/project-bot/project-bot-dal.ts +++ b/backend/src/services/project-bot/project-bot-dal.ts @@ -27,5 +27,19 @@ export const projectBotDALFactory = (db: TDbClient) => { } }; - return { ...projectBotOrm, findOne }; + const findProjectByBotId = async (botId: string) => { + try { + const project = await db(TableName.ProjectBot) + .where({ [`${TableName.ProjectBot}.id` as "id"]: botId }) + .join(TableName.Project, `${TableName.ProjectBot}.projectId`, `${TableName.Project}.id`) + .select(selectAllTableCols(TableName.Project)) + .first(); + + return project || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find project by bot id" }); + } + }; + + return { ...projectBotOrm, findOne, findProjectByBotId }; }; diff --git a/backend/src/services/project-bot/project-bot-service.ts b/backend/src/services/project-bot/project-bot-service.ts index 5ead160f2..456a1e7a0 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -1,125 +1,111 @@ import { ForbiddenError } from "@casl/ability"; -import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; +import { ProjectVersion, SecretKeyEncoding } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { getConfig } from "@app/lib/config/env"; -import { - decryptAsymmetric, - decryptSymmetric, - decryptSymmetric128BitHexKeyUTF8, - encryptSymmetric, - encryptSymmetric128BitHexKeyUTF8, - generateAsymmetricKeyPair -} from "@app/lib/crypto"; +import { decryptAsymmetric, generateAsymmetricKeyPair } from "@app/lib/crypto"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { TProjectPermission } from "@app/lib/types"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotDALFactory } from "./project-bot-dal"; -import { TSetActiveStateDTO } from "./project-bot-types"; +import { TFindBotByProjectIdDTO, TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types"; type TProjectBotServiceFactoryDep = { permissionService: Pick; + projectDAL: Pick; projectBotDAL: TProjectBotDALFactory; }; export type TProjectBotServiceFactory = ReturnType; -export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => { - const getBotKey = async (projectId: string) => { - const appCfg = getConfig(); - const encryptionKey = appCfg.ENCRYPTION_KEY; - const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; +export const projectBotServiceFactory = ({ + projectBotDAL, + projectDAL, + permissionService +}: TProjectBotServiceFactoryDep) => { + const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => + infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + const getBotKey = async (projectId: string) => { const bot = await projectBotDAL.findOne({ projectId }); if (!bot) throw new BadRequestError({ message: "failed to find bot key" }); if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) throw new BadRequestError({ message: "Encryption key missing" }); - if (rootEncryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.BASE64) { - const privateKeyBot = decryptSymmetric({ - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey, - key: rootEncryptionKey - }); - return decryptAsymmetric({ - ciphertext: bot.encryptedProjectKey, - privateKey: privateKeyBot, - nonce: bot.encryptedProjectKeyNonce, - publicKey: bot.sender.publicKey - }); - } - if (encryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.UTF8) { - const privateKeyBot = decryptSymmetric128BitHexKeyUTF8({ - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey, - key: encryptionKey - }); - return decryptAsymmetric({ - ciphertext: bot.encryptedProjectKey, - privateKey: privateKeyBot, - nonce: bot.encryptedProjectKeyNonce, - publicKey: bot.sender.publicKey - }); - } + const botPrivateKey = getBotPrivateKey({ bot }); - throw new BadRequestError({ - message: "Failed to obtain bot copy of workspace key needed for operation" + return decryptAsymmetric({ + ciphertext: bot.encryptedProjectKey, + privateKey: botPrivateKey, + nonce: bot.encryptedProjectKeyNonce, + publicKey: bot.sender.publicKey }); }; - const findBotByProjectId = async ({ actorId, actor, actorOrgId, projectId }: TProjectPermission) => { + const findBotByProjectId = async ({ + actorId, + actor, + projectId, + actorOrgId, + privateKey, + botKey, + publicKey + }: TFindBotByProjectIdDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); - const appCfg = getConfig(); const bot = await projectBotDAL.transaction(async (tx) => { const doc = await projectBotDAL.findOne({ projectId }, tx); if (doc) return doc; - const { publicKey, privateKey } = generateAsymmetricKeyPair(); - if (appCfg.ROOT_ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric(privateKey, appCfg.ROOT_ENCRYPTION_KEY); - return projectBotDAL.create( - { - name: "Infisical Bot", - projectId, - tag, - iv, - encryptedPrivateKey: ciphertext, - isActive: false, - publicKey, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.BASE64 - }, - tx - ); + const keys = privateKey && publicKey ? { privateKey, publicKey } : generateAsymmetricKeyPair(); + + const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(keys.privateKey); + + const project = await projectDAL.findById(projectId, tx); + + if (project.version === ProjectVersion.V2) { + throw new BadRequestError({ message: "Failed to create bot, project is upgraded." }); } - if (appCfg.ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(privateKey, appCfg.ENCRYPTION_KEY); - return projectBotDAL.create( - { - name: "Infisical Bot", - projectId, - tag, - iv, - encryptedPrivateKey: ciphertext, - isActive: false, - publicKey, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 - }, - tx - ); - } - throw new BadRequestError({ message: "Failed to create bot due to missing encryption key" }); + + return projectBotDAL.create( + { + name: "Infisical Bot", + projectId, + tag, + iv, + encryptedPrivateKey: ciphertext, + isActive: false, + publicKey: keys.publicKey, + algorithm, + keyEncoding: encoding, + ...(botKey && { + encryptedProjectKey: botKey.encryptedKey, + encryptedProjectKeyNonce: botKey.nonce + }) + }, + tx + ); }); return bot; }; + const findProjectByBotId = async (botId: string) => { + try { + const bot = await projectBotDAL.findProjectByBotId(botId); + return bot; + } catch (e) { + throw new BadRequestError({ message: "Failed to find bot by ID" }); + } + }; + const setBotActiveState = async ({ actor, botId, botKey, actorId, actorOrgId, isActive }: TSetActiveStateDTO) => { const bot = await projectBotDAL.findById(botId); if (!bot) throw new BadRequestError({ message: "Bot not found" }); @@ -127,6 +113,16 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T const { permission } = await permissionService.getProjectPermission(actor, actorId, bot.projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + const project = await projectBotDAL.findProjectByBotId(botId); + + if (!project) { + throw new BadRequestError({ message: "Failed to find project by bot ID" }); + } + + if (project.version === ProjectVersion.V2) { + throw new BadRequestError({ message: "Failed to set bot active for upgraded project. Bot is already active" }); + } + if (isActive) { if (!botKey?.nonce || !botKey?.encryptedKey) { throw new BadRequestError({ message: "Failed to set bot active - missing bot key" }); @@ -153,6 +149,8 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T return { findBotByProjectId, setBotActiveState, + getBotPrivateKey, + findProjectByBotId, getBotKey }; }; diff --git a/backend/src/services/project-bot/project-bot-types.ts b/backend/src/services/project-bot/project-bot-types.ts index 94a2943eb..50fec2200 100644 --- a/backend/src/services/project-bot/project-bot-types.ts +++ b/backend/src/services/project-bot/project-bot-types.ts @@ -1,3 +1,4 @@ +import { TProjectBots } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TSetActiveStateDTO = { @@ -8,3 +9,16 @@ export type TSetActiveStateDTO = { }; botId: string; } & Omit; + +export type TFindBotByProjectIdDTO = { + privateKey?: string; + publicKey?: string; + botKey?: { + nonce: string; + encryptedKey: string; + }; +} & TProjectPermission; + +export type TGetPrivateKeyDTO = { + bot: TProjectBots; +}; diff --git a/backend/src/services/project-key/project-key-dal.ts b/backend/src/services/project-key/project-key-dal.ts index 7423a48de..d1b4053d0 100644 --- a/backend/src/services/project-key/project-key-dal.ts +++ b/backend/src/services/project-key/project-key-dal.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName, TProjectKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -10,10 +12,11 @@ export const projectKeyDALFactory = (db: TDbClient) => { const findLatestProjectKey = async ( userId: string, - projectId: string + projectId: string, + tx?: Knex ): Promise<(TProjectKeys & { sender: { publicKey: string } }) | undefined> => { try { - const projectKey = await db(TableName.ProjectKeys) + const projectKey = await (tx || db)(TableName.ProjectKeys) .join(TableName.Users, `${TableName.ProjectKeys}.senderId`, `${TableName.Users}.id`) .join(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`) .where({ projectId, receiverId: userId }) @@ -29,9 +32,9 @@ export const projectKeyDALFactory = (db: TDbClient) => { } }; - const findAllProjectUserPubKeys = async (projectId: string) => { + const findAllProjectUserPubKeys = async (projectId: string, tx?: Knex) => { try { - const pubKeys = await db(TableName.ProjectMembership) + const pubKeys = await (tx || db)(TableName.ProjectMembership) .where({ projectId }) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 22b9937a9..e0002f911 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TProjectMembershipDALFactory = ReturnType; @@ -24,20 +24,63 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("projectId").withSchema(TableName.ProjectMembership), db.ref("role").withSchema(TableName.ProjectMembership), db.ref("roleId").withSchema(TableName.ProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId") - ); - return members.map(({ email, firstName, lastName, publicKey, ...data }) => ({ + ) + .where({ isGhost: false }); + return members.map(({ email, firstName, lastName, publicKey, isGhost, ...data }) => ({ ...data, - user: { email, firstName, lastName, id: data.userId, publicKey } + user: { email, firstName, lastName, id: data.userId, publicKey, isGhost } })); } catch (error) { throw new DatabaseError({ error, name: "Find all project members" }); } }; - return { ...projectMemberOrm, findAllProjectMembers }; + const findProjectGhostUser = async (projectId: string) => { + try { + const ghostUser = await db(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .select(selectAllTableCols(TableName.Users)) + .where({ isGhost: true }) + .first(); + + return ghostUser; + } catch (error) { + throw new DatabaseError({ error, name: "Find project top-level user" }); + } + }; + + const findMembershipsByEmail = async (projectId: string, emails: string[]) => { + try { + const members = await db(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + selectAllTableCols(TableName.ProjectMembership), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("email").withSchema(TableName.Users) + ) + .whereIn("email", emails) + .where({ isGhost: false }); + return members.map(({ userId, email, ...data }) => ({ + ...data, + user: { id: userId, email } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find members by email" }); + } + }; + + return { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByEmail }; }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6b2123e5e..e96247c97 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,15 +1,28 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; -import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas"; +import { + OrgMembershipStatus, + ProjectMembershipRole, + ProjectVersion, + SecretKeyEncoding, + TableName, + TProjectMemberships, + TUsers +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; +import { ActorType } from "../auth/auth-type"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; @@ -17,7 +30,9 @@ import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TAddUsersToWorkspaceDTO, - TDeleteProjectMembershipDTO, + TAddUsersToWorkspaceNonE2EEDTO, + TDeleteProjectMembershipOldDTO, + TDeleteProjectMembershipsDTO, TGetProjectMembershipDTO, TInviteUserToProjectDTO, TUpdateProjectMembershipDTO @@ -26,11 +41,12 @@ import { type TProjectMembershipServiceFactoryDep = { permissionService: Pick; smtpService: TSmtpService; + projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; - userDAL: Pick; + userDAL: Pick; projectRoleDAL: Pick; - orgDAL: Pick; - projectDAL: Pick; + orgDAL: Pick; + projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; }; @@ -42,6 +58,7 @@ export const projectMembershipServiceFactory = ({ projectMembershipDAL, smtpService, projectRoleDAL, + projectBotDAL, orgDAL, userDAL, projectDAL, @@ -55,64 +72,90 @@ export const projectMembershipServiceFactory = ({ return projectMembershipDAL.findAllProjectMembers(projectId); }; - const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, email }: TInviteUserToProjectDTO) => { + const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, emails }: TInviteUserToProjectDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - const invitee = await userDAL.findOne({ email }); - if (!invitee || !invitee.isAccepted) - throw new BadRequestError({ - message: "Faield to validate invitee", - name: "Invite user to project" - }); - - const inviteeMembership = await projectMembershipDAL.findOne({ - userId: invitee.id, - projectId - }); - if (inviteeMembership) - throw new BadRequestError({ - message: "Existing member of project", - name: "Invite user to project" - }); + const invitees: TUsers[] = []; const project = await projectDAL.findById(projectId); - const inviteeMembershipOrg = await orgDAL.findMembership({ - userId: invitee.id, - orgId: project.orgId, - status: OrgMembershipStatus.Accepted + const users = await userDAL.find({ + $in: { email: emails } }); - if (!inviteeMembershipOrg) - throw new BadRequestError({ - message: "Failed to validate invitee org membership", - name: "Invite user to project" + + await projectDAL.transaction(async (tx) => { + for (const invitee of users) { + if (!invitee.isAccepted) + throw new BadRequestError({ + message: "Failed to validate invitee", + name: "Invite user to project" + }); + + const inviteeMembership = await projectMembershipDAL.findOne( + { + userId: invitee.id, + projectId + }, + tx + ); + + if (inviteeMembership) { + throw new BadRequestError({ + message: "Existing member of project", + name: "Invite user to project" + }); + } + + const inviteeMembershipOrg = await orgDAL.findMembership({ + userId: invitee.id, + orgId: project.orgId, + status: OrgMembershipStatus.Accepted + }); + + if (!inviteeMembershipOrg) { + throw new BadRequestError({ + message: "Failed to validate invitee org membership", + name: "Invite user to project" + }); + } + + await projectMembershipDAL.create( + { + userId: invitee.id, + projectId, + role: ProjectMembershipRole.Member + }, + tx + ); + + invitees.push(invitee); + } + + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical workspace invitation", + recipients: invitees.map((i) => i.email), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } }); + }); const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); - await projectMembershipDAL.create({ - userId: invitee.id, - projectId, - role: ProjectMembershipRole.Member - }); - const sender = await userDAL.findById(actorId); - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical workspace invitation", - recipients: [invitee.email], - substitutions: { - inviterFirstName: sender.firstName, - inviterEmail: sender.email, - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - - return { invitee, latestKey }; + return { invitees, latestKey }; }; - const addUsersToProject = async ({ projectId, actorId, actor, actorOrgId, members }: TAddUsersToWorkspaceDTO) => { + const addUsersToProject = async ({ + projectId, + actorId, + actor, + actorOrgId, + members, + sendEmails = true + }: TAddUsersToWorkspaceDTO) => { const project = await projectDAL.findById(projectId); if (!project) throw new BadRequestError({ message: "Project not found" }); @@ -134,11 +177,16 @@ export const projectMembershipServiceFactory = ({ await projectMembershipDAL.transaction(async (tx) => { await projectMembershipDAL.insertMany( - orgMembers.map(({ userId }) => ({ - projectId, - userId: userId as string, - role: ProjectMembershipRole.Member - })), + orgMembers.map(({ userId, id: membershipId }) => { + const role = + members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member; + + return { + projectId, + userId: userId as string, + role + }; + }), tx ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); @@ -153,22 +201,132 @@ export const projectMembershipServiceFactory = ({ tx ); }); - const sender = await userDAL.findById(actorId); - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical workspace invitation", - recipients: orgMembers.map(({ email }) => email).filter(Boolean), - substitutions: { - inviterFirstName: sender.firstName, - inviterEmail: sender.email, - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); + + if (sendEmails) { + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical workspace invitation", + recipients: orgMembers.map(({ email }) => email).filter(Boolean), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } return orgMembers; }; + const addUsersToProjectNonE2EE = async ({ + projectId, + actorId, + actor, + emails, + sendEmails = true + }: TAddUsersToWorkspaceNonE2EEDTO) => { + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (project.version === ProjectVersion.V1) { + throw new BadRequestError({ message: "Please upgrade your project on your dashboard" }); + } + + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + + const orgMembers = await orgDAL.findOrgMembersByEmail(project.orgId, emails); + + if (orgMembers.length !== emails.length) throw new BadRequestError({ message: "Some users are not part of org" }); + + const existingMembers = await projectMembershipDAL.find({ + projectId, + $in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) } + }); + if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); + + const ghostUser = await projectDAL.findProjectGhostUser(projectId); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }); + + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const newWsMembers = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: botPrivateKey, + members: orgMembers.map((membership) => ({ + orgMembershipId: membership.id, + projectMembershipRole: ProjectMembershipRole.Member, + userPublicKey: membership.user.publicKey + })) + }); + + const members: TProjectMemberships[] = []; + + await projectMembershipDAL.transaction(async (tx) => { + const result = await projectMembershipDAL.insertMany( + orgMembers.map(({ user }) => ({ + projectId, + userId: user.id, + role: ProjectMembershipRole.Member + })), + tx + ); + + members.push(...result); + + const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); + await projectKeyDAL.insertMany( + orgMembers.map(({ user, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: user.id, + projectId + })), + tx + ); + }); + + if (sendEmails) { + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical workspace invitation", + recipients: orgMembers.map(({ user }) => user.email).filter(Boolean), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } + return members; + }; + const updateProjectMembership = async ({ actorId, actor, @@ -180,6 +338,15 @@ export const projectMembershipServiceFactory = ({ const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); + + if (membershipUser?.isGhost) { + throw new BadRequestError({ + message: "Unauthorized member update", + name: "Update project membership" + }); + } + const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); if (isCustomRole) { const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); @@ -205,16 +372,26 @@ export const projectMembershipServiceFactory = ({ return membership; }; + // This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now. const deleteProjectMembership = async ({ actorId, actor, actorOrgId, projectId, membershipId - }: TDeleteProjectMembershipDTO) => { + }: TDeleteProjectMembershipOldDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + const member = await userDAL.findUserByProjectMembershipId(membershipId); + + if (member?.isGhost) { + throw new BadRequestError({ + message: "Unauthorized member delete", + name: "Delete project membership" + }); + } + const membership = await projectMembershipDAL.transaction(async (tx) => { const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); @@ -223,11 +400,78 @@ export const projectMembershipServiceFactory = ({ return membership; }; + const deleteProjectMemberships = async ({ + actorId, + actor, + actorOrgId, + projectId, + emails + }: TDeleteProjectMembershipsDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + + const project = await projectDAL.findById(projectId); + + if (!project) { + throw new BadRequestError({ + message: "Project not found", + name: "Delete project membership" + }); + } + + if (project.version === ProjectVersion.V1) { + throw new BadRequestError({ message: "Please upgrade your project on your dashboard" }); + } + + const projectMembers = await projectMembershipDAL.findMembershipsByEmail(projectId, emails); + + if (projectMembers.length !== emails.length) { + throw new BadRequestError({ + message: "Some users are not part of project", + name: "Delete project membership" + }); + } + + if (actor === ActorType.USER && projectMembers.some(({ user }) => user.id === actorId)) { + throw new BadRequestError({ + message: "Cannot remove yourself from project", + name: "Delete project membership" + }); + } + + const memberships = await projectMembershipDAL.transaction(async (tx) => { + const deletedMemberships = await projectMembershipDAL.delete( + { + projectId, + $in: { + id: projectMembers.map(({ id }) => id) + } + }, + tx + ); + + await projectKeyDAL.delete( + { + projectId, + $in: { + receiverId: projectMembers.map(({ user }) => user.id).filter(Boolean) + } + }, + tx + ); + + return deletedMemberships; + }); + return memberships; + }; + return { getProjectMemberships, inviteUserToProject, updateProjectMembership, - deleteProjectMembership, + addUsersToProjectNonE2EE, + deleteProjectMemberships, + deleteProjectMembership, // TODO: Remove this addUsersToProject }; }; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 6ee1d2965..634b51d77 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,9 +1,10 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; export type TInviteUserToProjectDTO = { - email: string; + emails: string[]; } & TProjectPermission; export type TUpdateProjectMembershipDTO = { @@ -11,14 +12,25 @@ export type TUpdateProjectMembershipDTO = { role: string; } & TProjectPermission; -export type TDeleteProjectMembershipDTO = { +export type TDeleteProjectMembershipOldDTO = { membershipId: string; } & TProjectPermission; +export type TDeleteProjectMembershipsDTO = { + emails: string[]; +} & TProjectPermission; + export type TAddUsersToWorkspaceDTO = { + sendEmails?: boolean; members: { orgMembershipId: string; workspaceEncryptedKey: string; workspaceEncryptedNonce: string; + projectRole: ProjectMembershipRole; }[]; } & TProjectPermission; + +export type TAddUsersToWorkspaceNonE2EEDTO = { + sendEmails?: boolean; + emails: string[]; +} & TProjectPermission; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 44ba57481..7d0826e12 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -1,6 +1,8 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { ProjectsSchema, TableName } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { ProjectsSchema, ProjectUpgradeStatus, ProjectVersion, TableName, TProjectsUpdate } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TProjectDALFactory = ReturnType; @@ -52,6 +54,32 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const findProjectGhostUser = async (projectId: string) => { + try { + const ghostUser = await db(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .select(selectAllTableCols(TableName.Users)) + .where({ isGhost: true }) + .first(); + return ghostUser; + } catch (error) { + throw new DatabaseError({ error, name: "Find project top-level user" }); + } + }; + + const setProjectUpgradeStatus = async (projectId: string, status: ProjectUpgradeStatus | null, tx?: Knex) => { + try { + const data: TProjectsUpdate = { + upgradeStatus: status + } as const; + + await (tx || 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) @@ -132,10 +160,25 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const checkProjectUpgradeStatus = async (projectId: string) => { + const project = await projectOrm.findById(projectId); + const upgradeInProgress = + project.upgradeStatus === ProjectUpgradeStatus.InProgress && project.version === ProjectVersion.V1; + + if (upgradeInProgress) { + throw new BadRequestError({ + message: "Project is currently being upgraded, and secrets cannot be written. Please try again" + }); + } + }; + return { ...projectOrm, findAllProjects, + setProjectUpgradeStatus, findAllProjectsByIdentity, - findProjectById + findProjectGhostUser, + findProjectById, + checkProjectUpgradeStatus }; }; diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts new file mode 100644 index 000000000..0f1f5a08d --- /dev/null +++ b/backend/src/services/project/project-fns.ts @@ -0,0 +1,50 @@ +import crypto from "crypto"; + +import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; + +import { AddUserToWsDTO } from "./project-types"; + +export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateKey }: AddUserToWsDTO) => { + const plaintextProjectKey = decryptAsymmetric({ + ciphertext: decryptKey.encryptedKey, + nonce: decryptKey.nonce, + publicKey: decryptKey.sender.publicKey, + privateKey: userPrivateKey + }); + + const newWsMembers = members.map(({ orgMembershipId, userPublicKey, projectMembershipRole }) => { + const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAsymmetric( + plaintextProjectKey, + userPublicKey, + userPrivateKey + ); + + return { + orgMembershipId, + projectRole: projectMembershipRole, + workspaceEncryptedKey: inviteeCipherText, + workspaceEncryptedNonce: inviteeNonce + }; + }); + + return newWsMembers; +}; + +type TCreateProjectKeyDTO = { + publicKey: string; + privateKey: string; +}; + +export const createProjectKey = ({ publicKey, privateKey }: TCreateProjectKeyDTO) => { + // 3. Create a random key that we'll use as the project key. + const randomBytes = crypto.randomBytes(16).toString("hex"); + + // 4. Encrypt the project key with the users key pair. + const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric( + randomBytes, + publicKey, + privateKey + ); + + return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; +}; diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts new file mode 100644 index 000000000..616aa1aef --- /dev/null +++ b/backend/src/services/project/project-queue.ts @@ -0,0 +1,541 @@ +/* eslint-disable no-await-in-loop */ +import { + IntegrationAuthsSchema, + ProjectMembershipRole, + ProjectUpgradeStatus, + ProjectVersion, + SecretApprovalRequestsSecretsSchema, + SecretKeyEncoding, + SecretsSchema, + SecretVersionsSchema, + TIntegrationAuths, + TSecretApprovalRequestsSecrets, + TSecrets, + TSecretVersions +} 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 { + decryptIntegrationAuths, + decryptSecretApprovals, + decryptSecrets, + decryptSecretVersions +} from "@app/lib/crypto"; +import { + decryptAsymmetric, + encryptSymmetric128BitHexKeyUTF8, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueJobTypes, TQueueServiceFactory } from "@app/queue"; + +import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-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 { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TProjectDALFactory } from "./project-dal"; +import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; + +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; + integrationAuthDAL: TIntegrationAuthDALFactory; + userDAL: Pick; + + projectEnvDAL: Pick; + projectDAL: Pick; + orgDAL: Pick; +}; + +export const projectQueueFactory = ({ + queueService, + secretDAL, + folderDAL, + userDAL, + secretVersionDAL, + integrationAuthDAL, + 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 + }); + + const oldProjectKey = await projectKeyDAL.findLatestProjectKey(data.startedByUserId, data.projectId); + + if (!project || !oldProjectKey) { + throw new Error("Project or project key not found"); + } + + if (project.upgradeStatus !== ProjectUpgradeStatus.Failed && project.upgradeStatus !== null) { + throw new Error("Project upgrade status is not valid"); + } + + await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.InProgress); // Set the status to in progress. This is important to prevent multiple upgrades at the same time. + + // eslint-disable-next-line no-promise-executor-return + // await new Promise((resolve) => setTimeout(resolve, 50_000)); + + 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 projectIntegrationAuths = await integrationAuthDAL.find({ + projectId: project.id + }); + const secrets: TSecrets[] = []; + const secretVersions: TSecretVersions[] = []; + const approvalSecrets: TSecretApprovalRequestsSecrets[] = []; + const folderSecretVersionIdsToDelete: string[] = []; + + 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 700 secret versions for each folder. + { + limit: 1000, + sort: [["createdAt", "desc"]] + } + ); + + const deletedSecretVersions = await secretVersionDAL.find( + { + folderId: folder.id + }, + { + // Get all the secret versions that are not the latest 700 + offset: 1000 + } + ); + folderSecretVersionIdsToDelete.push(...deletedSecretVersions.map((el) => el.id)); + + const approvalRequests = await secretApprovalRequestDAL.find({ + status: RequestState.Open, + folderId: folder.id + }); + const secretApprovals = await secretApprovalSecretDAL.find({ + $in: { + requestId: approvalRequests.map((el) => el.id) + } + }); + + secrets.push(...folderSecrets); + secretVersions.push(...folderSecretVersions); + approvalSecrets.push(...secretApprovals); + } + + const decryptedSecrets = decryptSecrets(secrets, userPrivateKey, oldProjectKey); + const decryptedSecretVersions = decryptSecretVersions(secretVersions, userPrivateKey, oldProjectKey); + const decryptedApprovalSecrets = decryptSecretApprovals(approvalSecrets, userPrivateKey, oldProjectKey); + const decryptedIntegrationAuths = decryptIntegrationAuths(projectIntegrationAuths, userPrivateKey, oldProjectKey); + + // 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] = assignWorkspaceKeysToMembers({ + 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 + }); + + const updatedSecrets: TSecrets[] = []; + const updatedSecretVersions: TSecretVersions[] = []; + const updatedSecretApprovals: TSecretApprovalRequestsSecrets[] = []; + const updatedIntegrationAuths: TIntegrationAuths[] = []; + for (const rawSecret of decryptedSecrets) { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecret.decrypted.secretComment || "", + botKey + ); + + const payload: TSecrets = { + ...rawSecret.original, + keyEncoding: SecretKeyEncoding.UTF8, + + 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 (!SecretsSchema.safeParse(payload).success) { + throw new Error(`Invalid secret payload: ${JSON.stringify(payload)}`); + } + + updatedSecrets.push(payload); + } + + for (const rawSecretVersion of decryptedSecretVersions) { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretVersion.decrypted.secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretVersion.decrypted.secretValue || "", + botKey + ); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretVersion.decrypted.secretComment || "", + botKey + ); + + const payload: TSecretVersions = { + ...rawSecretVersion.original, + keyEncoding: SecretKeyEncoding.UTF8, + + 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 (!SecretVersionsSchema.safeParse(payload).success) { + throw new Error(`Invalid secret version payload: ${JSON.stringify(payload)}`); + } + + updatedSecretVersions.push(payload); + } + + for (const rawSecretApproval of decryptedApprovalSecrets) { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretApproval.decrypted.secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretApproval.decrypted.secretValue || "", + botKey + ); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretApproval.decrypted.secretComment || "", + botKey + ); + + const payload: TSecretApprovalRequestsSecrets = { + ...rawSecretApproval.original, + keyEncoding: SecretKeyEncoding.UTF8, + + 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 (!SecretApprovalRequestsSecretsSchema.safeParse(payload).success) { + throw new Error(`Invalid secret approval payload: ${JSON.stringify(payload)}`); + } + + updatedSecretApprovals.push(payload); + } + + for (const integrationAuth of decryptedIntegrationAuths) { + const access = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.access, botKey); + const accessId = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.accessId, botKey); + const refresh = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.refresh, botKey); + + const payload: TIntegrationAuths = { + ...integrationAuth.original, + keyEncoding: SecretKeyEncoding.UTF8, + + accessCiphertext: access.ciphertext, + accessIV: access.iv, + accessTag: access.tag, + + accessIdCiphertext: accessId.ciphertext, + accessIdIV: accessId.iv, + accessIdTag: accessId.tag, + + refreshCiphertext: refresh.ciphertext, + refreshIV: refresh.iv, + refreshTag: refresh.tag + } as const; + + if (!IntegrationAuthsSchema.safeParse(payload).success) { + throw new Error(`Invalid integration auth payload: ${JSON.stringify(payload)}`); + } + + updatedIntegrationAuths.push(payload); + } + + if (updatedSecrets.length !== secrets.length) { + throw new Error("Failed to update some secrets"); + } + if (updatedSecretVersions.length !== secretVersions.length) { + throw new Error("Failed to update some secret versions"); + } + if (updatedSecretApprovals.length !== approvalSecrets.length) { + throw new Error("Failed to update some secret approvals"); + } + if (updatedIntegrationAuths.length !== projectIntegrationAuths.length) { + throw new Error("Failed to update some integration auths"); + } + + const secretUpdates = await secretDAL.bulkUpdateNoVersionIncrement(updatedSecrets, tx); + const secretVersionUpdates = await secretVersionDAL.bulkUpdateNoVersionIncrement(updatedSecretVersions, tx); + const secretApprovalUpdates = await secretApprovalSecretDAL.bulkUpdateNoVersionIncrement( + updatedSecretApprovals, + tx + ); + const integrationAuthUpdates = await integrationAuthDAL.bulkUpdate( + updatedIntegrationAuths.map((el) => ({ + filter: { id: el.id }, + data: { + ...el, + id: undefined + } + })), + tx + ); + + // Delete all secret versions that are no longer needed. We only store the latest 100 versions for each secret. + await secretVersionDAL.delete( + { + $in: { + id: folderSecretVersionIdsToDelete + } + }, + tx + ); + + if ( + secretUpdates.length !== updatedSecrets.length || + secretVersionUpdates.length !== updatedSecretVersions.length || + secretApprovalUpdates.length !== updatedSecretApprovals.length || + integrationAuthUpdates.length !== updatedIntegrationAuths.length + ) { + throw new Error("Parts of the upgrade failed. Some secrets were not updated"); + } + + await projectDAL.setProjectUpgradeStatus(data.projectId, null, tx); + + // await new Promise((resolve) => setTimeout(resolve, 15_000)); + // throw new Error("Transaction was successful!"); + }); + } catch (err) { + const [project] = await projectDAL + .find({ + 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(err, "Failed to upgrade project"); + } + + throw err; + } + }); + + queueService.listen(QueueName.UpgradeProjectToGhost, "failed", (job, err) => { + logger.error(err, "Upgrade project failed", job?.data); + }); + + return { + upgradeProject + }; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 387c9bd78..c7c3af6fa 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,22 +1,40 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { ProjectMembershipRole } 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 { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; import { createSecretBlindIndex } from "@app/lib/crypto"; -import { BadRequestError } from "@app/lib/errors"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +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 { 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 { 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 { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types"; +import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; +import { TProjectQueueFactory } from "./project-queue"; +import { + TCreateProjectDTO, + TDeleteProjectDTO, + TGetProjectDTO, + TUpdateProjectDTO, + TUpgradeProjectDTO +} from "./project-types"; export const DEFAULT_PROJECT_ENVS = [ { name: "Development", slug: "dev" }, @@ -26,11 +44,18 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; - folderDAL: Pick; - projectEnvDAL: Pick; - projectMembershipDAL: Pick; + projectQueue: TProjectQueueFactory; + userDAL: TUserDALFactory; + folderDAL: TSecretFolderDALFactory; + projectEnvDAL: Pick; + identityOrgMembershipDAL: TIdentityOrgDALFactory; + identityProjectDAL: TIdentityProjectDALFactory; + projectKeyDAL: Pick; + projectBotDAL: Pick; + projectMembershipDAL: Pick; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; + orgService: Pick; licenseService: Pick; }; @@ -38,8 +63,15 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + projectQueue, + projectKeyDAL, permissionService, + userDAL, folderDAL, + orgService, + identityProjectDAL, + projectBotDAL, + identityOrgMembershipDAL, secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, @@ -48,8 +80,13 @@ export const projectServiceFactory = ({ /* * Create workspace. Make user the admin * */ - const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName }: TCreateProjectDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); + const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName, slug }: TCreateProjectDTO) => { + const { permission, membership: orgMembership } = await permissionService.getOrgPermission( + actor, + actorId, + orgId, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); const appCfg = getConfig(); @@ -64,20 +101,28 @@ export const projectServiceFactory = ({ }); } - const newProject = projectDAL.transaction(async (tx) => { + const results = await projectDAL.transaction(async (tx) => { + const ghostUser = await orgService.addGhostUser(orgId, tx); + const project = await projectDAL.create( - { name: workspaceName, orgId, slug: slugify(`${workspaceName}-${alphaNumericNanoId(4)}`) }, + { + name: workspaceName, + orgId, + slug: slug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), + version: ProjectVersion.V2 + }, tx ); - // set user as admin member for proeject + // set ghost user as admin of project await projectMembershipDAL.create( { - userId: actorId, + userId: ghostUser.user.id, role: ProjectMembershipRole.Admin, projectId: project.id }, tx ); + // generate the blind index for project await secretBlindIndexDAL.create( { @@ -99,18 +144,165 @@ export const projectServiceFactory = ({ envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), tx ); - // _id for backward compat - return { ...project, environments: envs, _id: project.id }; + + // 3. Create a random key that we'll use as the project key. + const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({ + publicKey: ghostUser.keys.publicKey, + privateKey: ghostUser.keys.plainPrivateKey + }); + + // 4. Save the project key for the ghost user. + await projectKeyDAL.create( + { + projectId: project.id, + receiverId: ghostUser.user.id, + encryptedKey: encryptedProjectKey, + nonce: encryptedProjectKeyIv, + senderId: ghostUser.user.id + }, + tx + ); + + const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey); + + // 5. Create & a bot for the project + await projectBotDAL.create( + { + name: "Infisical Bot (Ghost)", + projectId: project.id, + tag, + iv, + encryptedProjectKey, + encryptedProjectKeyNonce: encryptedProjectKeyIv, + encryptedPrivateKey: ciphertext, + isActive: true, + publicKey: ghostUser.keys.publicKey, + senderId: ghostUser.user.id, + algorithm, + keyEncoding: encoding + }, + tx + ); + + // Find the ghost users latest key + const latestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx); + + if (!latestKey) { + throw new Error("Latest key not found for user"); + } + + // If the project is being created by a user, add the user to the project as an admin + if (actor === ActorType.USER) { + // Find public key of user + const user = await userDAL.findUserEncKeyByUserId(actorId); + + if (!user) { + throw new Error("User not found"); + } + + const [projectAdmin] = assignWorkspaceKeysToMembers({ + decryptKey: latestKey, + userPrivateKey: ghostUser.keys.plainPrivateKey, + members: [ + { + userPublicKey: user.publicKey, + orgMembershipId: orgMembership.id, + projectMembershipRole: ProjectMembershipRole.Admin + } + ] + }); + + // Create a membership for the user + await projectMembershipDAL.create( + { + projectId: project.id, + userId: user.id, + role: projectAdmin.projectRole + }, + tx + ); + + // Create a project key for the user + await projectKeyDAL.create( + { + encryptedKey: projectAdmin.workspaceEncryptedKey, + nonce: projectAdmin.workspaceEncryptedNonce, + senderId: ghostUser.user.id, + receiverId: user.id, + projectId: project.id + }, + tx + ); + } + + // If the project is being created by an identity, add the identity to the project as an admin + else if (actor === ActorType.IDENTITY) { + // Find identity org membership + const identityOrgMembership = await identityOrgMembershipDAL.findOne( + { + identityId: actorId, + orgId: project.orgId + }, + tx + ); + + // If identity org membership not found, throw error + if (!identityOrgMembership) { + throw new BadRequestError({ + message: `Failed to find identity with id ${actorId}` + }); + } + + // Get the role permission for the identity + const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( + ProjectMembershipRole.Admin, + orgId + ); + + const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPrivilege) + throw new ForbiddenRequestError({ + message: "Failed to add identity to project with more privileged role" + }); + const isCustomRole = Boolean(customRole); + + await identityProjectDAL.create( + { + identityId: actorId, + projectId: project.id, + role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, + roleId: customRole?.id + }, + tx + ); + } + + return { + ...project, + environments: envs, + _id: project.id + }; }); - return newProject; + return results; }; const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - const deletedProject = await projectDAL.deleteById(projectId); + const deletedProject = await projectDAL.transaction(async (tx) => { + const project = await projectDAL.deleteById(projectId, tx); + const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(projectId).catch(() => null); + + // Delete the org membership for the ghost user if it's found. + if (projectGhostUser) { + await userDAL.deleteById(projectGhostUser.id, tx); + } + + return project; + }); + return deletedProject; }; @@ -124,6 +316,17 @@ export const projectServiceFactory = ({ return projectDAL.findProjectById(projectId); }; + const updateProject = async ({ projectId, actor, actorId, actorOrgId, update }: TUpdateProjectDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + const updatedProject = await projectDAL.updateById(projectId, { + name: update.name, + autoCapitalization: update.autoCapitalization + }); + return updatedProject; + }; + const toggleAutoCapitalization = async ({ projectId, actor, @@ -146,12 +349,55 @@ export const projectServiceFactory = ({ return updatedProject; }; + const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => { + const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); + + if (membership?.role !== ProjectMembershipRole.Admin) { + throw new ForbiddenRequestError({ + message: "User must be admin" + }); + } + + 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 with id ${projectId} not found` + }); + } + + return project.upgradeStatus || null; + }; + return { createProject, deleteProject, getProjects, + updateProject, + getProjectUpgradeStatus, getAProject, toggleAutoCapitalization, - updateName + updateName, + upgradeProject }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 2ffea1117..3843450c2 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,3 +1,6 @@ +import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + import { ActorType } from "../auth/auth-type"; export type TCreateProjectDTO = { @@ -6,6 +9,7 @@ export type TCreateProjectDTO = { actorOrgId?: string; orgId: string; workspaceName: string; + slug?: string; }; export type TDeleteProjectDTO = { @@ -21,3 +25,24 @@ export type TGetProjectDTO = { actorOrgId?: string; projectId: string; }; + +export type TUpdateProjectDTO = { + update: { + name?: string; + autoCapitalization?: boolean; + }; +} & TProjectPermission; + +export type TUpgradeProjectDTO = { + userPrivateKey: string; +} & TProjectPermission; + +export type AddUserToWsDTO = { + decryptKey: TProjectKeys & { sender: { publicKey: string } }; + userPrivateKey: string; + members: { + orgMembershipId: string; + projectMembershipRole: ProjectMembershipRole; + userPublicKey: string; + }[]; +}; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index a519c7820..1beae9be6 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -4,6 +4,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -21,6 +22,7 @@ type TSecretImportServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; folderDAL: TSecretFolderDALFactory; secretDAL: Pick; + projectDAL: Pick; projectEnvDAL: TProjectEnvDALFactory; permissionService: Pick; }; @@ -34,6 +36,7 @@ export const secretImportServiceFactory = ({ projectEnvDAL, permissionService, folderDAL, + projectDAL, secretDAL }: TSecretImportServiceFactoryDep) => { const createImport = async ({ @@ -62,6 +65,8 @@ export const secretImportServiceFactory = ({ }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" }); diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index c4b74eef8..11cd522ca 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -22,7 +22,11 @@ export const secretDALFactory = (db: TDbClient) => { // the idea is to use postgres specific function // insert with id this will cause a conflict then merge the data - const bulkUpdate = async (data: Array<{ filter: Partial; data: TSecretsUpdate }>, tx?: Knex) => { + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TSecretsUpdate }>, + + tx?: Knex + ) => { try { const secs = await Promise.all( data.map(async ({ filter, data: updateData }) => { @@ -41,6 +45,35 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const bulkUpdateNoVersionIncrement = async (data: TSecrets[], tx?: Knex) => { + try { + const existingSecrets = await secretOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingSecrets.length !== data.length) { + throw new BadRequestError({ message: "Some of the secrets do not exist" }); + } + + if (data.length === 0) return []; + + const updatedSecrets = await (tx || db)(TableName.Secret) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + const deleteMany = async ( data: Array<{ blindIndex: string; type: SecretType }>, folderId: string, @@ -145,5 +178,13 @@ export const secretDALFactory = (db: TDbClient) => { } }; - return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes }; + return { + ...secretOrm, + update, + bulkUpdate, + deleteMany, + bulkUpdateNoVersionIncrement, + findByFolderId, + findByBlindIndexes + }; }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index ff9dce9a8..96d851a63 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -11,6 +11,7 @@ import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { ActorType } from "../auth/auth-type"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -49,6 +50,7 @@ type TSecretServiceFactoryDep = { secretTagDAL: TSecretTagDALFactory; secretVersionDAL: TSecretVersionDALFactory; folderDAL: Pick; + projectDAL: Pick; secretBlindIndexDAL: TSecretBlindIndexDALFactory; permissionService: Pick; snapshotService: Pick; @@ -68,6 +70,7 @@ export const secretServiceFactory = ({ permissionService, snapshotService, secretQueueService, + projectDAL, projectBotService, secretImportDAL, secretVersionTagDAL @@ -281,6 +284,8 @@ export const secretServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); const folderId = folder.id; @@ -359,6 +364,8 @@ export const secretServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); const folderId = folder.id; @@ -459,6 +466,8 @@ export const secretServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); const folderId = folder.id; @@ -650,6 +659,8 @@ export const secretServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); const folderId = folder.id; @@ -705,6 +716,8 @@ export const secretServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); const folderId = folder.id; @@ -776,6 +789,8 @@ export const secretServiceFactory = ({ subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); const folderId = folder.id; diff --git a/backend/src/services/secret/secret-version-dal.ts b/backend/src/services/secret/secret-version-dal.ts index 7a6695e18..758352ed2 100644 --- a/backend/src/services/secret/secret-version-dal.ts +++ b/backend/src/services/secret/secret-version-dal.ts @@ -1,8 +1,8 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TSecretVersions } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { TableName, TSecretVersions, TSecretVersionsUpdate } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TSecretVersionDALFactory = ReturnType; @@ -36,6 +36,57 @@ export const secretVersionDALFactory = (db: TDbClient) => { } }; + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TSecretVersionsUpdate }>, + tx?: Knex + ) => { + try { + const secs = await Promise.all( + data.map(async ({ filter, data: updateData }) => { + const [doc] = await (tx || db)(TableName.SecretVersion) + .where(filter) + .update(updateData) + .increment("version", 1) // TODO: Is this really needed? + .returning("*"); + if (!doc) throw new BadRequestError({ message: "Failed to update document" }); + return doc; + }) + ); + return secs; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const bulkUpdateNoVersionIncrement = async (data: TSecretVersions[], tx?: Knex) => { + try { + const existingSecretVersions = await secretVersionOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingSecretVersions.length !== data.length) { + throw new BadRequestError({ message: "Some of the secret versions do not exist" }); + } + + if (data.length === 0) return []; + + const updatedSecretVersions = await (tx || db)(TableName.SecretVersion) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedSecretVersions; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => { try { const docs: Array = await (tx || db)(TableName.SecretVersion) @@ -59,5 +110,11 @@ export const secretVersionDALFactory = (db: TDbClient) => { } }; - return { ...secretVersionOrm, findLatestVersionMany, findLatestVersionByFolderId }; + return { + ...secretVersionOrm, + findLatestVersionMany, + bulkUpdate, + findLatestVersionByFolderId, + bulkUpdateNoVersionIncrement + }; }; diff --git a/backend/src/services/smtp/templates/workspaceInvitation.handlebars b/backend/src/services/smtp/templates/workspaceInvitation.handlebars index 60556555c..39a9b74ba 100644 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ b/backend/src/services/smtp/templates/workspaceInvitation.handlebars @@ -1,15 +1,15 @@ - - - - + + + Project Invitation - - + +

Join your team on Infisical

-

{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical project — {{workspaceName}}

+

You have been invited to a new Infisical project — {{workspaceName}}

Join now

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- +

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets + and configs.

+ \ No newline at end of file diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 9cdc55c8a..64dac73f2 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, + isGhost: false, isAccepted: true, authMethods: [AuthMethod.EMAIL] }, diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 0de490399..2c89b9c4d 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -23,7 +23,7 @@ export const userDALFactory = (db: TDbClient) => { const findUserEncKeyByEmail = async (email: string) => { try { return await db(TableName.Users) - .where({ email }) + .where({ email, isGhost: false }) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) .first(); } catch (error) { @@ -47,6 +47,17 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findUserByProjectMembershipId = async (projectMembershipId: string) => { + try { + return await db(TableName.ProjectMembership) + .where({ [`${TableName.ProjectMembership}.id` as "id"]: projectMembershipId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .first(); + } catch (error) { + throw new DatabaseError({ error, name: "Find user by project membership id" }); + } + }; + const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => { try { const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*"); @@ -111,6 +122,7 @@ export const userDALFactory = (db: TDbClient) => { findUserEncKeyByEmail, findUserEncKeyByUserId, updateUserEncryptionByUserId, + findUserByProjectMembershipId, upsertUserEncryptionKey, createUserEncryption, findOneUserAction, diff --git a/docs/api-reference/endpoints/workspaces/create-workspace.mdx b/docs/api-reference/endpoints/workspaces/create-workspace.mdx new file mode 100644 index 000000000..ec3d09b81 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/create-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Workspace" +openapi: "POST /api/v2/workspace" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/delete-workspace.mdx b/docs/api-reference/endpoints/workspaces/delete-workspace.mdx new file mode 100644 index 000000000..ca6cb8914 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/delete-workspace.mdx @@ -0,0 +1,8 @@ +--- +title: "Delete Workspace" +openapi: "DELETE /api/v1/workspace/{workspaceId}" +--- + + + This operation is irreversible. All data associated with the project will be deleted. Please use with caution. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/get-workspace.mdx b/docs/api-reference/endpoints/workspaces/get-workspace.mdx new file mode 100644 index 000000000..2d27d9455 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/get-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Workspace" +openapi: "GET /api/v1/workspace/{workspaceId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx b/docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx new file mode 100644 index 000000000..a09c586ca --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Invite Member" +openapi: "POST /api/v2/workspace/{projectId}/memberships" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/remove-member-from-workspace.mdx b/docs/api-reference/endpoints/workspaces/remove-member-from-workspace.mdx new file mode 100644 index 000000000..8b781a4e8 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/remove-member-from-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Member" +openapi: "DELETE /api/v2/workspace/{projectId}/memberships" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/update-workspace.mdx b/docs/api-reference/endpoints/workspaces/update-workspace.mdx new file mode 100644 index 000000000..81a6ec8ea --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/update-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Workspace" +openapi: "PATCH /api/v1/workspace/{workspaceId}" +--- \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 32ded21fc..ccdead084 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -386,9 +386,14 @@ { "group": "Projects", "pages": [ + "api-reference/endpoints/workspaces/create-workspace", + "api-reference/endpoints/workspaces/delete-workspace", + "api-reference/endpoints/workspaces/get-workspace", + "api-reference/endpoints/workspaces/update-workspace", + "api-reference/endpoints/workspaces/invite-member-to-workspace", + "api-reference/endpoints/workspaces/remove-member-from-workspace", "api-reference/endpoints/workspaces/memberships", "api-reference/endpoints/workspaces/update-membership", - "api-reference/endpoints/workspaces/delete-membership", "api-reference/endpoints/workspaces/list-identity-memberships", "api-reference/endpoints/workspaces/update-identity-membership", "api-reference/endpoints/workspaces/delete-identity-membership", diff --git a/frontend/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx index 9f109b931..8ad06375b 100644 --- a/frontend/src/components/basic/table/ProjectUsersTable.tsx +++ b/frontend/src/components/basic/table/ProjectUsersTable.tsx @@ -59,8 +59,8 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa const workspaceId = router.query.id as string; // Delete the row in the table (e.g. a user) // #TODO: Add a pop-up that warns you that the user is going to be deleted. - const handleDelete = async (membershipId: string) => { - await deleteUserFromWorkspaceMutateAsync({ membershipId, workspaceId }); + const handleDelete = async (email: string) => { + await deleteUserFromWorkspaceMutateAsync({ emails: [email], workspaceId }); }; const handleRoleUpdate = async (index: number, e: string) => { @@ -368,7 +368,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa myRole !== "member" ? (
+
+ + ); +}; 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/helpers/project.ts b/frontend/src/helpers/project.ts index 21f73e4c6..06f13dc65 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -1,10 +1,5 @@ -import crypto from "crypto"; - -import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import encryptSecrets from "@app/components/utilities/secrets/encryptSecrets"; -import { uploadWsKey } from "@app/hooks/api/keys/queries"; import { createSecret } from "@app/hooks/api/secrets/mutations"; -import { fetchUserDetails } from "@app/hooks/api/users/queries"; import { createWorkspace } from "@app/hooks/api/workspace/queries"; const secretsToBeAdded = [ @@ -91,43 +86,22 @@ const initProjectHelper = async ({ }) => { // create new project const { - data: { workspace } + data: { project } } = await createWorkspace({ - workspaceName: projectName, - organizationId - }); - - // create and upload new (encrypted) project key - const randomBytes = crypto.randomBytes(16).toString("hex"); - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); - - if (!PRIVATE_KEY) throw new Error("Failed to find private key"); - - const user = await fetchUserDetails(); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: randomBytes, - publicKey: user.publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKey({ - workspaceId: workspace.id, - userId: user.id, - encryptedKey: ciphertext, - nonce + organizationId, + projectName }); // encrypt and upload secrets to new project const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeAdded, - workspaceId: workspace.id, + workspaceId: project.id, env: "dev" }); secrets?.forEach((secret) => { createSecret({ - workspaceId: workspace.id, + workspaceId: project.id, environment: secret.environment, type: secret.type, secretKey: secret.secretName, @@ -147,7 +121,7 @@ const initProjectHelper = async ({ }); }); - return workspace; + return project; }; export { initProjectHelper }; diff --git a/frontend/src/hooks/api/keys/queries.tsx b/frontend/src/hooks/api/keys/queries.tsx index 3cf11e189..7e59c0840 100644 --- a/frontend/src/hooks/api/keys/queries.tsx +++ b/frontend/src/hooks/api/keys/queries.tsx @@ -8,9 +8,9 @@ const encKeyKeys = { getUserWorkspaceKey: (workspaceID: string) => ["workspace-key-pair", { workspaceID }] as const }; -export const fetchUserWsKey = async (workspaceID: string) => { +export const fetchUserWsKey = async (projectId: string) => { const { data } = await apiRequest.get( - `/api/v2/workspace/${workspaceID}/encrypted-key` + `/api/v2/workspace/${projectId}/encrypted-key` ); return data; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 04d215898..6748db6ac 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -22,7 +22,7 @@ export * from "./secrets/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { SubscriptionPlan } from "./subscriptions/types"; export type { WsTag } from "./tags/types"; -export type { AddUserToWsDTO, OrgUser, TWorkspaceUser, User, UserEnc } from "./users/types"; +export type { AddUserToWsDTOE2EE, OrgUser, TWorkspaceUser, User, UserEnc } from "./users/types"; export type { TWebhook } from "./webhooks/types"; export type { CreateEnvironmentDTO, diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 75dd36cc5..9c51948f2 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,4 +1,4 @@ -export { useAddUserToWs } from "./mutation"; +export { useAddUserToWsE2EE, useAddUserToWsNonE2EE } from "./mutation"; export { fetchOrgUsers, useAddUserToOrg, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 03276c2ef..7d982cea1 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -7,12 +7,12 @@ import { import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; -import { AddUserToWsDTO } from "./types"; +import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types"; -export const useAddUserToWs = () => { +export const useAddUserToWsE2EE = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, AddUserToWsDTO>({ + return useMutation<{}, {}, AddUserToWsDTOE2EE>({ mutationFn: async ({ workspaceId, members, decryptKey, userPrivateKey }) => { // assymmetrically decrypt symmetric key with local private key const key = decryptAssymmetric({ @@ -45,3 +45,19 @@ export const useAddUserToWs = () => { } }); }; + +export const useAddUserToWsNonE2EE = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, AddUserToWsDTONonE2EE>({ + mutationFn: async ({ projectId, emails }) => { + const { data } = await apiRequest.post(`/api/v2/workspace/${projectId}/memberships`, { + emails + }); + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(projectId)); + } + }); +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index ebddefb14..366ee93bf 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -63,7 +63,7 @@ export type TProjectMembership = { export type TWorkspaceUser = OrgUser; -export type AddUserToWsDTO = { +export type AddUserToWsDTOE2EE = { workspaceId: string; decryptKey: UserWsKeyPair; userPrivateKey: string; @@ -73,6 +73,11 @@ export type AddUserToWsDTO = { }[]; }; +export type AddUserToWsDTONonE2EE = { + projectId: string; + emails: string[]; +}; + export type UpdateOrgUserRoleDTO = { organizationId: string; membershipId: string; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 63be0f7ad..b96223077 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,12 +1,12 @@ export { useAddIdentityToWorkspace, - useAddUserToWorkspace, useCreateWorkspace, useCreateWsEnvironment, useDeleteIdentityFromWorkspace, useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, + useGetUpgradeProjectStatus, useGetUserWorkspaceMemberships, useGetUserWorkspaces, useGetWorkspaceAuthorizations, @@ -21,5 +21,6 @@ export { useToggleAutoCapitalization, useUpdateIdentityWorkspaceRole, useUpdateUserWorkspaceRole, - useUpdateWsEnvironment + useUpdateWsEnvironment, + useUpgradeProject } from "./queries"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index f47a77537..d970cab54 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -14,6 +14,7 @@ import { DeleteWorkspaceDTO, NameWorkspaceSecretsDTO, RenameWorkspaceDTO, + TGetUpgradeProjectStatusDTO, ToggleAutoCapitalizationDTO, UpdateEnvironmentDTO, Workspace @@ -24,6 +25,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 +53,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 } @@ -61,6 +71,36 @@ export const fetchWorkspaceSecrets = async (workspaceId: string) => { return secrets; }; +export const useUpgradeProject = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, { projectId: string; privateKey: string }>({ + mutationFn: ({ projectId, privateKey }) => { + return apiRequest.post(`/api/v2/workspace/${projectId}/upgrade`, { + userPrivateKey: privateKey + }); + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; + +export const useGetUpgradeProjectStatus = ({ + projectId, + onSuccess, + enabled, + refetchInterval +}: TGetUpgradeProjectStatusDTO) => { + return useQuery({ + queryKey: workspaceKeys.getProjectUpgradeStatus(projectId), + queryFn: () => fetchProjectUpgradeStatus(projectId), + enabled, + onSuccess, + refetchInterval + }); +}; + const fetchUserWorkspaces = async () => { const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace"); return data.workspaces; @@ -158,19 +198,19 @@ export const useGetWorkspaceIntegrations = (workspaceId: string) => export const createWorkspace = ({ organizationId, - workspaceName -}: CreateWorkspaceDTO): Promise<{ data: { workspace: Workspace } }> => { - return apiRequest.post("/api/v1/workspace", { workspaceName, organizationId }); + projectName +}: CreateWorkspaceDTO): Promise<{ data: { project: Workspace } }> => { + return apiRequest.post("/api/v2/workspace", { projectName, organizationId }); }; export const useCreateWorkspace = () => { const queryClient = useQueryClient(); - return useMutation<{ data: { workspace: Workspace } }, {}, CreateWorkspaceDTO>({ - mutationFn: async ({ organizationId, workspaceName }) => + return useMutation<{ data: { project: Workspace } }, {}, CreateWorkspaceDTO>({ + mutationFn: async ({ organizationId, projectName }) => createWorkspace({ organizationId, - workspaceName + projectName }), onSuccess: () => { queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); @@ -279,40 +319,16 @@ export const useGetWorkspaceUsers = (workspaceId: string) => { }); }; -export const useAddUserToWorkspace = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ email, workspaceId }: { email: string; workspaceId: string }) => { - const { - data: { invitee, latestKey } - } = await apiRequest.post(`/api/v1/workspace/${workspaceId}/invite-signup`, { email }); - - return { - invitee, - latestKey - }; - }, - onSuccess: (_, dto) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(dto.workspaceId)); - } - }); -}; - export const useDeleteUserFromWorkspace = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - membershipId, - workspaceId - }: { - workspaceId: string; - membershipId: string; - }) => { + mutationFn: async ({ emails, workspaceId }: { workspaceId: string; emails: string[] }) => { const { data: { deletedMembership } - } = await apiRequest.delete(`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`); + } = await apiRequest.delete(`/api/v2/workspace/${workspaceId}/memberships`, { + data: { emails } + }); return deletedMembership; }, onSuccess: (_, { workspaceId }) => { diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 10d222a2a..c4aada862 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -1,10 +1,18 @@ +export enum ProjectVersion { + V1 = 1, + V2 = 2 +} + export type Workspace = { __v: number; id: string; name: string; orgId: string; + version: ProjectVersion; + upgradeStatus: string | null; autoCapitalization: boolean; environments: WorkspaceEnv[]; + slug: string; }; export type WorkspaceEnv = { @@ -23,9 +31,16 @@ export type NameWorkspaceSecretsDTO = { }[]; }; +export type TGetUpgradeProjectStatusDTO = { + projectId: string; + onSuccess?: (data?: { status: string }) => void; + enabled?: boolean; + refetchInterval?: number; +}; + // mutation dto export type CreateWorkspaceDTO = { - workspaceName: string; + projectName: string; organizationId: string; }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index d175afe48..8957f6d91 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -4,7 +4,6 @@ /* eslint-disable vars-on-top */ /* eslint-disable no-var */ /* eslint-disable func-names */ -import crypto from "crypto"; import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -34,7 +33,6 @@ import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { OrgPermissionCan } from "@app/components/permissions"; import { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage"; -import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button, Checkbox, @@ -51,6 +49,7 @@ import { SelectItem, UpgradePlanModal } from "@app/components/v2"; +import { UpgradeOverlay } from "@app/components/v2/UpgradeOverlay"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -62,16 +61,14 @@ import { import { usePopUp } from "@app/hooks"; import { fetchOrgUsers, - useAddUserToWs, + useAddUserToWsNonE2EE, useCreateWorkspace, useGetOrgTrialUrl, useGetSecretApprovalRequestCount, useGetUserAction, useLogoutUser, - useRegisterUserAction, - useUploadWsKey + useRegisterUserAction } from "@app/hooks/api"; -import { fetchUserWsKey } from "@app/hooks/api/keys/queries"; import { CreateOrgModal } from "@app/views/Org/components"; interface LayoutProps { @@ -129,8 +126,8 @@ export const AppLayout = ({ children }: LayoutProps) => { : true; const createWs = useCreateWorkspace(); - const uploadWsKey = useUploadWsKey(); - const addWsUser = useAddUserToWs(); + const addUsersToProject = useAddUserToWsNonE2EE(); + const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION; const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -220,59 +217,44 @@ export const AppLayout = ({ children }: LayoutProps) => { const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => { // type check - if (!currentOrg?.id) return; + if (!currentOrg) return; + if (!user) return; try { const { data: { - workspace: { id: newWorkspaceId } + project: { id: newProjectId } } } = await createWs.mutateAsync({ - organizationId: currentOrg?.id, - workspaceName: name - }); - - const randomBytes = crypto.randomBytes(16).toString("hex"); - const PRIVATE_KEY = String(localStorage.getItem("PRIVATE_KEY")); - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: randomBytes, - publicKey: user.publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKey.mutateAsync({ - encryptedKey: ciphertext, - nonce, - userId: user?.id, - workspaceId: newWorkspaceId + organizationId: currentOrg.id, + projectName: name }); if (addMembers) { - // not using hooks because need at this point only const orgUsers = await fetchOrgUsers(currentOrg.id); - const decryptKey = await fetchUserWsKey(newWorkspaceId); - await addWsUser.mutateAsync({ - workspaceId: newWorkspaceId, - decryptKey, - userPrivateKey: PRIVATE_KEY, - members: orgUsers - .filter( - ({ status, user: orgUser }) => status === "accepted" && user.email !== orgUser.email - ) - .map(({ user: orgUser, id: orgMembershipId }) => ({ - userPublicKey: orgUser.publicKey, - orgMembershipId - })) + + await addUsersToProject.mutateAsync({ + emails: orgUsers + .map((member) => member.user.email) + .filter((email) => email !== user.email), + projectId: newProjectId }); } + + // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. + await new Promise((resolve) => setTimeout(resolve, 2_000)); + createNotification({ text: "Workspace created", type: "success" }); handlePopUpClose("addNewWs"); - router.push(`/project/${newWorkspaceId}/secrets/overview`); + router.push(`/project/${newProjectId}/secrets/overview`); } catch (err) { console.error(err); createNotification({ text: "Failed to create workspace", type: "error" }); } }; - + return ( <>
@@ -280,6 +262,7 @@ export const AppLayout = ({ children }: LayoutProps) => {