From 3b1fc4b1564ed5faf65d54a09eaa29e5b7d13d97 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 6 Feb 2024 19:27:53 +0400 Subject: [PATCH] More gohst user --- .../services/audit-log/audit-log-service.ts | 1 + .../src/ee/services/license/licence-fns.ts | 8 +- backend/src/lib/project/index.ts | 21 ++ backend/src/server/routes/index.ts | 6 + .../src/server/routes/v1/project-router.ts | 1 + .../src/server/routes/v3/project-router.ts | 196 ++---------------- .../project-bot/project-bot-service.ts | 64 ++---- .../services/project-key/project-key-dal.ts | 11 +- .../project-membership-service.ts | 137 +++++++++++- .../project-membership-types.ts | 5 + backend/src/services/project/project-dal.ts | 15 ++ .../src/services/project/project-service.ts | 169 ++++++++++++++- .../templates/workspaceInvitation.handlebars | 18 +- frontend/src/hooks/api/workspace/types.ts | 1 + .../src/pages/org/[id]/overview/index.tsx | 35 ++-- 15 files changed, 413 insertions(+), 275 deletions(-) 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/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 9b1d4c203..b8b02ec54 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -9,11 +9,11 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ _id: null, slug: null, tier: -1, - workspaceLimit: null, + workspaceLimit: 5000, workspacesUsed: 0, - memberLimit: null, + memberLimit: 5000, membersUsed: 0, - environmentLimit: null, + environmentLimit: 5000, environmentsUsed: 0, secretVersioning: true, pitRecovery: false, @@ -28,7 +28,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true }); diff --git a/backend/src/lib/project/index.ts b/backend/src/lib/project/index.ts index f680dc7eb..d4e97ba17 100644 --- a/backend/src/lib/project/index.ts +++ b/backend/src/lib/project/index.ts @@ -1,3 +1,5 @@ +import crypto from "crypto"; + import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; import { decryptAsymmetric, encryptAsymmetric } from "../crypto"; @@ -37,3 +39,22 @@ export const createWsMembers = ({ members, decryptKey, userPrivateKey }: AddUser return newWsMembers; }; + +type TCreateWorkspaceKeyDTO = { + publicKey: string; + privateKey: string; +}; + +export const createWorkspaceKey = ({ publicKey, privateKey }: TCreateWorkspaceKeyDTO) => { + // 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/server/routes/index.ts b/backend/src/server/routes/index.ts index 53dd86762..e793d2133 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -286,6 +286,7 @@ export const registerRoutes = async ( projectMembershipDAL, projectDAL, permissionService, + projectBotDAL, orgDAL, userDAL, smtpService, @@ -302,6 +303,11 @@ export const registerRoutes = async ( permissionService, projectDAL, secretBlindIndexDAL, + identityProjectDAL, + identityOrgMembershipDAL, + projectBotDAL, + projectKeyDAL, + userDAL, projectEnvDAL, orgService, projectMembershipDAL, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index f7616de4e..873a2e78c 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -249,6 +249,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + // Is this actually used..? server.route({ url: "/:workspaceId/invite-signup", method: "POST", diff --git a/backend/src/server/routes/v3/project-router.ts b/backend/src/server/routes/v3/project-router.ts index db39c5677..c1759f718 100644 --- a/backend/src/server/routes/v3/project-router.ts +++ b/backend/src/server/routes/v3/project-router.ts @@ -1,15 +1,8 @@ -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 { ProjectMembershipsSchema, ProjectsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { authRateLimit } from "@app/server/config/rateLimiter"; -import { ActorType } from "@app/services/auth/auth-type"; const projectWithEnv = ProjectsSchema.merge( z.object({ @@ -29,7 +22,6 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ projectName: z.string().trim(), - inviteMemberEmails: z.array(z.string().email()).optional(), organizationId: z.string().trim() }), response: { @@ -39,110 +31,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - 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 a new project (will set the e2ee db field to false). - const { project, ghostUser } = await server.services.project.createProject({ + const project = 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. - const randomBytes = crypto.randomBytes(16).toString("hex"); - - // 4. Encrypt the project key with the users key pair. - const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric( - randomBytes, - ghostUser.keys.publicKey, - ghostUser.keys.plainPrivateKey - ); - - // 4. Save the project key for the ghost user. - await server.services.projectKey.uploadProjectKeys({ - projectId: project.id, - actor: req.permission.type, - actorId: ghostUser.user.id, - nonce: encryptedProjectKeyIv, - receiverId: ghostUser.user.id, - encryptedKey: encryptedProjectKey - }); - - // 5. Create a bot for the project. - const bot = await server.services.projectBot.findBotByProjectId({ - actorId: ghostUser.user.id, - actor: req.permission.type, - 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 - }); - - // 6. Activate the bot. - await server.services.projectBot.setBotActiveState({ - botKey: { - encryptedKey: encryptedProjectKey, - nonce: encryptedProjectKeyIv - }, - actorId: ghostUser.user.id, - isActive: true, - actor: req.permission.type, - botId: bot.id - }); - - // 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: ghostUser.user.id, - actor: req.permission.type, - projectId: project.id - }); - - if (!latestKey) throw new Error("Failed to get latest key"); - - // 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 - }); - - 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 - }); - } - return { project }; } }); @@ -159,82 +54,35 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), body: z.object({ emails: z.string().email().array() - }) + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.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({ + const memberships = await server.services.projectMembership.addUsersToProjectNonE2EE({ + projectId: req.params.projectId, actorId: req.permission.id, actor: req.permission.type, - projectId: req.params.projectId - }); - - 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({ + await server.services.auditLog.createAuditLog({ 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 + ...req.auditLogInfo, + event: { + type: EventType.ADD_BATCH_WORKSPACE_MEMBER, + metadata: memberships.map(({ userId, id }) => ({ + userId: userId || "", + membershipId: id, + email: "" + })) + } }); - return {}; + return { memberships }; } }); }; diff --git a/backend/src/services/project-bot/project-bot-service.ts b/backend/src/services/project-bot/project-bot-service.ts index a26170be3..476fba829 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -1,17 +1,11 @@ import { ForbiddenError } from "@casl/ability"; import { Knex } from "knex"; -import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; +import { 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, - encryptSymmetric, - encryptSymmetric128BitHexKeyUTF8, - generateAsymmetricKeyPair -} from "@app/lib/crypto"; -import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { decryptAsymmetric, generateAsymmetricKeyPair } from "@app/lib/crypto"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { TProjectBotDALFactory } from "./project-bot-dal"; @@ -60,7 +54,6 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T }: 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); @@ -68,41 +61,22 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T const keys = privateKey && publicKey ? { privateKey, publicKey } : generateAsymmetricKeyPair(); - if (appCfg.ROOT_ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric(keys.privateKey, appCfg.ROOT_ENCRYPTION_KEY); - return projectBotDAL.create( - { - name: "Infisical Bot", - projectId, - tag, - iv, - encryptedPrivateKey: ciphertext, - isActive: false, - publicKey: keys.publicKey, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.BASE64 - }, - tx - ); - } - if (appCfg.ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(keys.privateKey, appCfg.ENCRYPTION_KEY); - return projectBotDAL.create( - { - name: "Infisical Bot", - projectId, - tag, - iv, - encryptedPrivateKey: ciphertext, - isActive: false, - publicKey: keys.publicKey, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 - }, - tx - ); - } - throw new BadRequestError({ message: "Failed to create bot due to missing encryption key" }); + const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(keys.privateKey); + + return projectBotDAL.create( + { + name: "Infisical Bot", + projectId, + tag, + iv, + encryptedPrivateKey: ciphertext, + isActive: false, + publicKey: keys.publicKey, + algorithm, + keyEncoding: encoding + }, + tx + ); }); return bot; }; 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-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 0f628712b..584b7699d 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,16 +1,26 @@ /* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; -import { OrgMembershipStatus, ProjectMembershipRole, TableName, TUsers } from "@app/db/schemas"; +import { + OrgMembershipStatus, + ProjectMembershipRole, + 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 { createWsMembers } from "@app/lib/project"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; +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"; @@ -18,6 +28,7 @@ import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TAddUsersToWorkspaceDTO, + TAddUsersToWorkspaceNonE2EEDTO, TDeleteProjectMembershipDTO, TGetProjectMembershipDTO, TInviteUserToProjectDTO, @@ -27,11 +38,12 @@ import { type TProjectMembershipServiceFactoryDep = { permissionService: Pick; smtpService: TSmtpService; + projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; userDAL: Pick; projectRoleDAL: Pick; - orgDAL: Pick; - projectDAL: Pick; + orgDAL: Pick; + projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; }; @@ -43,6 +55,7 @@ export const projectMembershipServiceFactory = ({ projectMembershipDAL, smtpService, projectRoleDAL, + projectBotDAL, orgDAL, userDAL, projectDAL, @@ -98,15 +111,12 @@ export const projectMembershipServiceFactory = ({ 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` } @@ -175,15 +185,12 @@ export const projectMembershipServiceFactory = ({ }); 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` } @@ -192,6 +199,117 @@ export const projectMembershipServiceFactory = ({ 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" }); + + 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 ghost user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find ghost 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 = createWsMembers({ + 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, id: membershipId }) => { + const role = + orgMembers.find((membership) => membership.id === membershipId)?.role || ProjectMembershipRole.Member; + + return { + projectId, + userId: user.id, + role + }; + }), + 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, @@ -268,6 +386,7 @@ export const projectMembershipServiceFactory = ({ getProjectMemberships, inviteUserToProject, updateProjectMembership, + addUsersToProjectNonE2EE, deleteProjectMembership, addUsersToProject }; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 786072d56..4928ad522 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -25,3 +25,8 @@ export type TAddUsersToWorkspaceDTO = { 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..4d7c6072e 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -52,6 +52,20 @@ 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({ ghost: true }) + .first(); + return ghostUser; + } catch (error) { + throw new DatabaseError({ error, name: "Find project ghost user" }); + } + }; + const findAllProjectsByIdentity = async (identityId: string) => { try { const workspaces = await db(TableName.IdentityProjectMembership) @@ -136,6 +150,7 @@ export const projectDALFactory = (db: TDbClient) => { ...projectOrm, findAllProjects, findAllProjectsByIdentity, + findProjectGhostUser, findProjectById }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 080278322..26fb5056f 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -6,16 +6,25 @@ 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 { createWorkspaceKey, createWsMembers } from "@app/lib/project"; +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"; @@ -27,8 +36,13 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; + userDAL: TUserDALFactory; folderDAL: Pick; projectEnvDAL: Pick; + identityOrgMembershipDAL: TIdentityOrgDALFactory; + identityProjectDAL: TIdentityProjectDALFactory; + projectKeyDAL: Pick; + projectBotDAL: Pick; projectMembershipDAL: Pick; orgService: Pick; secretBlindIndexDAL: Pick; @@ -40,9 +54,14 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + projectKeyDAL, permissionService, + userDAL, folderDAL, orgService, + identityProjectDAL, + projectBotDAL, + identityOrgMembershipDAL, secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, @@ -52,7 +71,12 @@ 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 { permission, membership: orgMembership } = await permissionService.getOrgPermission( + actor, + actorId, + orgId, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); const appCfg = getConfig(); @@ -110,14 +134,141 @@ export const projectServiceFactory = ({ envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), tx ); - // _id for backward compat - return { - project: { - ...project, - environments: envs, - _id: project.id + + // 3. Create a random key that we'll use as the project key. + const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createWorkspaceKey({ + 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 }, - ghostUser + 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, + encryptedPrivateKey: ciphertext, + isActive: true, + publicKey: ghostUser.keys.publicKey, + 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] = createWsMembers({ + 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 + // IS THIS CORRECT? + 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 }; }); 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/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 8b825d062..240a9e858 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -6,6 +6,7 @@ export type Workspace = { e2ee: boolean; autoCapitalization: boolean; environments: WorkspaceEnv[]; + slug: string; }; export type WorkspaceEnv = { diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 56cf50af6..1b574611c 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -50,8 +50,8 @@ import { } from "@app/context"; import { withPermission } from "@app/hoc"; import { - // fetchOrgUsers, - // useAddUserToWs, + fetchOrgUsers, + useAddUserToWsNonE2EE, useCreateWorkspace, useRegisterUserAction } from "@app/hooks/api"; @@ -471,7 +471,7 @@ const OrganizationPage = withPermission( const currentOrg = String(router.query.id); const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === currentOrg) || []; const { createNotification } = useNotificationContext(); - // const addWsUser = useAddUserToWs(); + const addUsersToProject = useAddUserToWsNonE2EE(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addNewWs", @@ -498,10 +498,11 @@ const OrganizationPage = withPermission( const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => { // type check if (!currentOrg) return; + if (!user) return; try { const { data: { - project: { id: newWorkspaceId } + project: { id: newProjectId } } } = await createWs.mutateAsync({ organizationId: currentOrg, @@ -509,27 +510,19 @@ const OrganizationPage = withPermission( }); if (addMembers) { - // not using hooks because need at this point only - // const orgUsers = await fetchOrgUsers(currentOrg); - // 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 - // })) - // }); + const orgUsers = await fetchOrgUsers(currentOrg); + + await addUsersToProject.mutateAsync({ + emails: orgUsers + .map((member) => member.user.email) + .filter((email) => email !== user.email), + projectId: newProjectId + }); } 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" });