From 84cab17f5c29f18de4a2329aa2648415345063cf Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 6 Feb 2024 03:23:01 +0400 Subject: [PATCH] Ghost user WIP --- .../src/server/routes/v3/project-router.ts | 198 ++++++++++++++---- backend/src/services/org/org-service.ts | 64 ++++-- backend/src/services/org/org-types.ts | 7 + .../project-bot/project-bot-service.ts | 54 +++-- .../services/project-bot/project-bot-types.ts | 18 +- .../project-membership-service.ts | 158 ++++++++------ .../project-membership-types.ts | 3 +- .../src/services/project/project-service.ts | 31 ++- 8 files changed, 373 insertions(+), 160 deletions(-) diff --git a/backend/src/server/routes/v3/project-router.ts b/backend/src/server/routes/v3/project-router.ts index 5f83167a7..db39c5677 100644 --- a/backend/src/server/routes/v3/project-router.ts +++ b/backend/src/server/routes/v3/project-router.ts @@ -1,10 +1,15 @@ +import { ForbiddenError } from "@casl/ability"; import crypto from "crypto"; import { z } from "zod"; import { ProjectMembershipRole, ProjectsSchema } from "@app/db/schemas"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { encryptAsymmetric } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; import { createWsMembers } from "@app/lib/project"; import { authRateLimit } from "@app/server/config/rateLimiter"; +import { ActorType } from "@app/services/auth/auth-type"; const projectWithEnv = ProjectsSchema.merge( z.object({ @@ -24,103 +29,212 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ projectName: z.string().trim(), - inviteAllOrgMembers: z.boolean(), + inviteMemberEmails: z.array(z.string().email()).optional(), organizationId: z.string().trim() }), response: { 200: z.object({ - workspace: projectWithEnv + project: projectWithEnv }) } }, handler: async (req) => { - // 1. create the ghost user and add it to the org as admin - const ghost = await server.services.org.addGhostUser(req.body.organizationId); + const { permission } = await server.services.permission.getOrgPermission( + req.permission.type, + req.permission.id, + req.body.organizationId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); - // 2. create the workspace - const workspace = await server.services.project.createProject({ - actorId: ghost.user.id, + // 2. Create a new project (will set the e2ee db field to false). + const { project, ghostUser } = await server.services.project.createProject({ + actorId: req.permission.id, actor: req.permission.type, orgId: req.body.organizationId, workspaceName: req.body.projectName }); - // 3. create a random key that we'll use as the project key + // 3. Create a random key that we'll use as the project key. const randomBytes = crypto.randomBytes(16).toString("hex"); - const ghostPrivateKey = ghost.keys.plainPrivateKey; - + // 4. Encrypt the project key with the users key pair. const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric( randomBytes, - ghost.keys.publicKey, - ghostPrivateKey + ghostUser.keys.publicKey, + ghostUser.keys.plainPrivateKey ); - // 3. create workspace keys for the ghost user + // 4. Save the project key for the ghost user. await server.services.projectKey.uploadProjectKeys({ - projectId: workspace.id, + projectId: project.id, actor: req.permission.type, - actorId: ghost.user.id, + actorId: ghostUser.user.id, nonce: encryptedProjectKeyIv, - receiverId: ghost.user.id, + receiverId: ghostUser.user.id, encryptedKey: encryptedProjectKey }); - // 4. create a project bot + // 5. Create a bot for the project. const bot = await server.services.projectBot.findBotByProjectId({ - actorId: ghost.user.id, + actorId: ghostUser.user.id, actor: req.permission.type, - projectId: workspace.id + projectId: project.id, + + // We set the publicKey and privateKey of the bot to the same as the ghost user. + // We do this because we'll need to access the private key again later, when adding new members to the project. + publicKey: ghostUser.keys.publicKey, + privateKey: ghostUser.keys.plainPrivateKey }); - // 5. activate the bot + // 6. Activate the bot. await server.services.projectBot.setBotActiveState({ botKey: { encryptedKey: encryptedProjectKey, nonce: encryptedProjectKeyIv }, - actorId: ghost.user.id, + actorId: ghostUser.user.id, isActive: true, actor: req.permission.type, botId: bot.id }); - // 6. get the current user & org membership + // 7. get the current user & org membership const user = await server.services.user.getMe(req.permission.id); const userOrgMembership = await server.services.permission.getUserOrgPermission(user.id, req.body.organizationId); // 7. Get the latest key from the ghost! const latestKey = await server.services.projectKey.getLatestProjectKey({ - actorId: ghost.user.id, + actorId: ghostUser.user.id, actor: req.permission.type, - projectId: workspace.id + projectId: project.id }); if (!latestKey) throw new Error("Failed to get latest key"); - // 8. Create workspace members for the current user + // If the project is being created by a user, add the user to the project as an admin + if (req.permission.type === ActorType.USER) { + const projectAdmin = createWsMembers({ + decryptKey: latestKey, + members: [ + { + userPublicKey: user.publicKey, + orgMembershipId: userOrgMembership.membership.id, + projectMembershipRole: ProjectMembershipRole.Admin // <-- Make the first user an admin + } + ], + userPrivateKey: ghostUser.keys.plainPrivateKey + }); - const projectAdmin = await createWsMembers({ - decryptKey: latestKey, - members: [ - { - userPublicKey: user.publicKey, - orgMembershipId: userOrgMembership.membership.id, - projectMembershipRole: ProjectMembershipRole.Admin // <-- Make the first user an admin - } - ], - userPrivateKey: ghostPrivateKey - }); + await server.services.projectMembership.addUsersToProject({ + projectId: project.id, + actorId: ghostUser.user.id, + actor: req.permission.type, + members: projectAdmin + }); + } + // If the project is being created by an identity, add the identity to the project as an admin + else if (req.permission.type === ActorType.IDENTITY) { + await server.services.identityProject.createProjectIdentity({ + actor: ActorType.IDENTITY, + actorId: ghostUser.user.id, + identityId: req.permission.id, + projectId: project.id, + role: ProjectMembershipRole.Admin + }); + } - // 9. Add the current user to the workspace - await server.services.projectMembership.addUsersToProject({ - projectId: workspace.id, - actorId: ghost.user.id, + return { project }; + } + }); + + server.route({ + method: "POST", + url: "/:projectId/memberships", + config: { + rateLimit: authRateLimit + }, + schema: { + params: z.object({ + projectId: z.string() + }), + body: z.object({ + emails: z.string().email().array() + }) + }, + handler: async (req) => { + const { permission } = await server.services.permission.getProjectPermission( + req.permission.type, + req.permission.id, + req.params.projectId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + + const project = await server.services.project.getAProject({ + actorId: req.permission.id, actor: req.permission.type, - members: projectAdmin + projectId: req.params.projectId }); - return { workspace }; + const ghostUser = await server.services.project.findProjectGhostUser(req.params.projectId); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed" // TODO: Add a message + }); + } + + const latestKey = await server.services.projectKey.getLatestProjectKey({ + actorId: ghostUser.id, + actor: ActorType.USER, + projectId: req.params.projectId + }); + + if (!latestKey) { + throw new BadRequestError({ + message: "Failed to find project key" + }); + } + + const bot = await server.services.projectBot.findBotByProjectId({ + actor: req.permission.type, + actorId: req.permission.id, + projectId: req.params.projectId + }); + + // We get the bot private key, because the bot private key is the same as the ghost user's private key. + const botPrivateKey = server.services.projectBot.getBotPrivateKey({ bot }); + + const members = await server.services.org.findOrgMembersByEmail({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: project.orgId, + emails: req.body.emails + }); + + if (members.length !== req.body.emails.length) { + throw new BadRequestError({ + message: "Some users are not part of the organization" + }); + } + + const wsMembers = createWsMembers({ + members: members.map((membership) => ({ + orgMembershipId: membership.id, + projectMembershipRole: ProjectMembershipRole.Member, + userPublicKey: membership.user.publicKey + })), + decryptKey: latestKey, + userPrivateKey: botPrivateKey + }); + + await server.services.projectMembership.addUsersToProject({ + projectId: req.params.projectId, + actorId: ghostUser.id, // We set the actor ID to the ghost user, because this is used as senderId in the project key sharing + actor: ActorType.USER, + members: wsMembers + }); + + return {}; } }); }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 3b20b9a14..bbc8fe7a6 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -2,6 +2,8 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import crypto from "crypto"; import jwt from "jsonwebtoken"; +import { Knex } from "knex"; +import { nanoid } from "nanoid"; import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; @@ -30,6 +32,7 @@ import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, TFindAllWorkspacesDTO, + TFindOrgMembersByEmailDTO, TInviteUserToOrgDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, @@ -95,6 +98,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); @@ -120,31 +132,38 @@ export const orgServiceFactory = ({ return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id)); }; - const addGhostUser = async (orgId: string) => { - const email = `ghost@${orgId}.com`; + const addGhostUser = async (orgId: string, tx?: Knex) => { + const email = `ghost@${nanoid(8)}-${orgId}.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({ - ghost: true, - authMethods: [AuthMethod.EMAIL], - email: `ghost@${orgId}.com`, - isAccepted: true - }); + const user = await userDAL.create( + { + ghost: 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 - }); + 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, @@ -153,9 +172,7 @@ export const orgServiceFactory = ({ status: OrgMembershipStatus.Accepted }; - console.log("createMembershipData", createMembershipData); - - await orgDAL.createMembership(createMembershipData); + await orgDAL.createMembership(createMembershipData, tx); return { user, @@ -533,6 +550,7 @@ export const orgServiceFactory = ({ inviteUserToOrganization, verifyUserToOrg, updateOrg, + findOrgMembersByEmail, createOrganization, deleteOrganizationById, deleteOrgMembership, 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-service.ts b/backend/src/services/project-bot/project-bot-service.ts index a9514c1e3..a26170be3 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -13,10 +13,9 @@ import { } from "@app/lib/crypto"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { TProjectPermission } from "@app/lib/types"; import { TProjectBotDALFactory } from "./project-bot-dal"; -import { TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types"; +import { TFindBotByProjectIdDTO, TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types"; type TProjectBotServiceFactoryDep = { permissionService: Pick; @@ -26,12 +25,12 @@ type TProjectBotServiceFactoryDep = { export type TProjectBotServiceFactory = ReturnType; export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => { - const getBotPrivateKey = async ({ encoding, nonce, tag, encryptedPrivateKey }: TGetPrivateKeyDTO) => + const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => infisicalSymmetricDecrypt({ - keyEncoding: encoding, - iv: nonce, - tag, - ciphertext: encryptedPrivateKey + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey }); const getBotKey = async (projectId: string) => { @@ -41,24 +40,24 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) throw new BadRequestError({ message: "Encryption key missing" }); - const privateKeyBot = await getBotPrivateKey({ - nonce: bot.iv, - tag: bot.tag, - encryptedPrivateKey: bot.encryptedPrivateKey, - encoding: bot.keyEncoding as SecretKeyEncoding - }); - - console.log("privateKeyBot", privateKeyBot); + const botPrivateKey = getBotPrivateKey({ bot }); return decryptAsymmetric({ ciphertext: bot.encryptedProjectKey, - privateKey: privateKeyBot, + 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, + publicKey + }: TFindBotByProjectIdDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const appCfg = getConfig(); @@ -67,9 +66,10 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T const doc = await projectBotDAL.findOne({ projectId }, tx); if (doc) return doc; - const { publicKey, privateKey } = generateAsymmetricKeyPair(); + const keys = privateKey && publicKey ? { privateKey, publicKey } : generateAsymmetricKeyPair(); + if (appCfg.ROOT_ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric(privateKey, appCfg.ROOT_ENCRYPTION_KEY); + const { iv, tag, ciphertext } = encryptSymmetric(keys.privateKey, appCfg.ROOT_ENCRYPTION_KEY); return projectBotDAL.create( { name: "Infisical Bot", @@ -78,7 +78,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T iv, encryptedPrivateKey: ciphertext, isActive: false, - publicKey, + publicKey: keys.publicKey, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.BASE64 }, @@ -86,7 +86,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T ); } if (appCfg.ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(privateKey, appCfg.ENCRYPTION_KEY); + const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(keys.privateKey, appCfg.ENCRYPTION_KEY); return projectBotDAL.create( { name: "Infisical Bot", @@ -95,7 +95,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T iv, encryptedPrivateKey: ciphertext, isActive: false, - publicKey, + publicKey: keys.publicKey, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8 }, @@ -107,6 +107,15 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T 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" }); @@ -145,6 +154,7 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T 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 3d4f400de..4431c6686 100644 --- a/backend/src/services/project-bot/project-bot-types.ts +++ b/backend/src/services/project-bot/project-bot-types.ts @@ -1,4 +1,5 @@ -import { SecretKeyEncoding } from "@app/db/schemas"; +// import { SecretKeyEncoding } from "@app/db/schemas"; +import { TProjectBots } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TSetActiveStateDTO = { @@ -10,9 +11,16 @@ export type TSetActiveStateDTO = { botId: string; } & Omit; +export type TFindBotByProjectIdDTO = { + privateKey?: string; + publicKey?: string; +} & TProjectPermission; + export type TGetPrivateKeyDTO = { - encoding: SecretKeyEncoding; - nonce: string; - tag: string; - encryptedPrivateKey: string; + // encoding: SecretKeyEncoding; + // nonce: string; + // tag: string; + // encryptedPrivateKey: string; + + bot: TProjectBots; }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 477891bf8..0f628712b 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,6 +1,7 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; -import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas"; +import { OrgMembershipStatus, ProjectMembershipRole, TableName, 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"; @@ -27,7 +28,7 @@ type TProjectMembershipServiceFactoryDep = { permissionService: Pick; smtpService: TSmtpService; projectMembershipDAL: TProjectMembershipDALFactory; - userDAL: Pick; + userDAL: Pick; projectRoleDAL: Pick; orgDAL: Pick; projectDAL: Pick; @@ -55,64 +56,78 @@ 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 invitees: TUsers[] = []; + + for (const email of emails) { + 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 project = await projectDAL.findById(projectId); + 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 }); - 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 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` + } }); - const project = await projectDAL.findById(projectId); - 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" - }); + invitees.push(invitee); + } 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" }); @@ -158,19 +173,22 @@ 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 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` + } + }); + } return orgMembers; }; @@ -185,6 +203,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?.ghost) { + 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 }); @@ -220,6 +247,15 @@ export const projectMembershipServiceFactory = ({ const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); + + if (membershipUser?.ghost) { + throw new BadRequestError({ + message: "Cannot delete ghost", + 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); diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 6fe2977ee..786072d56 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -4,7 +4,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; export type TInviteUserToProjectDTO = { - email: string; + emails: string[]; } & TProjectPermission; export type TUpdateProjectMembershipDTO = { @@ -17,6 +17,7 @@ export type TDeleteProjectMembershipDTO = { } & TProjectPermission; export type TAddUsersToWorkspaceDTO = { + sendEmails?: boolean; members: { orgMembershipId: string; workspaceEncryptedKey: string; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index f56c40fa2..080278322 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -11,6 +11,7 @@ import { createSecretBlindIndex } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TOrgServiceFactory } from "../org/org-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; @@ -28,7 +29,8 @@ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; folderDAL: Pick; projectEnvDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; + orgService: Pick; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; licenseService: Pick; @@ -40,6 +42,7 @@ export const projectServiceFactory = ({ projectDAL, permissionService, folderDAL, + orgService, secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, @@ -64,7 +67,9 @@ 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, @@ -74,10 +79,10 @@ export const projectServiceFactory = ({ }, tx ); - // set user as admin member for project + // set ghost user as admin of project await projectMembershipDAL.create( { - userId: actorId, + userId: ghostUser.user.id, role: ProjectMembershipRole.Admin, projectId: project.id }, @@ -106,10 +111,23 @@ export const projectServiceFactory = ({ tx ); // _id for backward compat - return { ...project, environments: envs, _id: project.id }; + return { + project: { + ...project, + environments: envs, + _id: project.id + }, + ghostUser + }; }); - return newProject; + return results; + }; + + const findProjectGhostUser = async (projectId: string) => { + const user = await projectMembershipDAL.findProjectGhostUser(projectId); + + return user; }; const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => { @@ -156,6 +174,7 @@ export const projectServiceFactory = ({ createProject, deleteProject, getProjects, + findProjectGhostUser, getAProject, toggleAutoCapitalization, updateName