From 1292b5bf5653fcb539340fec08f445f5810747e3 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 6 Sep 2024 23:31:55 +0530 Subject: [PATCH 1/5] feat(api): manage users in org and project level without waiting for confirmation --- .../group/user-group-membership-dal.ts | 5 +- backend/src/lib/crypto/srp.ts | 10 +- backend/src/lib/validator/validate-email.ts | 10 +- backend/src/server/routes/index.ts | 3 +- .../src/server/routes/v1/invite-org-router.ts | 19 +- .../routes/v2/project-membership-router.ts | 17 +- .../src/services/auth/auth-signup-service.ts | 241 ++++++---- backend/src/services/org/org-service.ts | 450 +++++++++++------- backend/src/services/org/org-types.ts | 12 +- .../project-membership-service.ts | 50 -- backend/src/services/user/user-dal.ts | 5 +- 11 files changed, 491 insertions(+), 331 deletions(-) diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts index cadbbdc36..be654b338 100644 --- a/backend/src/ee/services/group/user-group-membership-dal.ts +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -41,10 +41,9 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { }; // special query - const findUserGroupMembershipsInProject = async (usernames: string[], projectId: string) => { + const findUserGroupMembershipsInProject = async (usernames: string[], projectId: string, tx?: Knex) => { try { - const usernameDocs: string[] = await db - .replicaNode()(TableName.UserGroupMembership) + const usernameDocs: string[] = await (tx || db.replicaNode())(TableName.UserGroupMembership) .join( TableName.GroupProjectMembership, `${TableName.UserGroupMembership}.groupId`, diff --git a/backend/src/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index 29f716306..d8d21f5c1 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -36,12 +36,16 @@ export const srpCheckClientProof = async ( // 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) => { +export const generateUserSrpKeys = async ( + email: string, + password: string, + customKeys?: { publicKey: string; privateKey: string } +) => { const pair = nacl.box.keyPair(); const secretKeyUint8Array = pair.secretKey; const publicKeyUint8Array = pair.publicKey; - const privateKey = tweetnacl.encodeBase64(secretKeyUint8Array); - const publicKey = tweetnacl.encodeBase64(publicKeyUint8Array); + const privateKey = customKeys?.privateKey || tweetnacl.encodeBase64(secretKeyUint8Array); + const publicKey = customKeys?.publicKey || tweetnacl.encodeBase64(publicKeyUint8Array); // eslint-disable-next-line const client = new jsrp.client(); diff --git a/backend/src/lib/validator/validate-email.ts b/backend/src/lib/validator/validate-email.ts index ec3ef3976..99c09302d 100644 --- a/backend/src/lib/validator/validate-email.ts +++ b/backend/src/lib/validator/validate-email.ts @@ -1,10 +1,16 @@ import fs from "fs/promises"; import path from "path"; -export const isDisposableEmail = async (email: string) => { - const emailDomain = email.split("@")[1]; +export const isDisposableEmail = async (email: string | string[]) => { const disposableEmails = await fs.readFile(path.join(__dirname, "disposable_emails.txt"), "utf8"); + if (Array.isArray(email)) { + return email.some((el) => { + const emailDomain = el.split("@")[1]; + return disposableEmails.split("\n").includes(emailDomain); + }); + } + const emailDomain = email.split("@")[1]; if (disposableEmails.split("\n").includes(emailDomain)) return true; return false; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f33456bd5..7aca8087b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -495,7 +495,8 @@ export const registerRoutes = async ( smtpService, userDAL, groupDAL, - orgBotDAL + orgBotDAL, + projectRoleDAL }); const signupService = authSignupServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index c2907d3ca..58713b4d5 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -18,9 +18,14 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { body: z.object({ inviteeEmails: z.array(z.string().trim().email()), organizationId: z.string().trim(), - projectIds: z.array(z.string().trim()).optional(), - projectRoleSlug: z.nativeEnum(ProjectMembershipRole).optional(), - organizationRoleSlug: z.nativeEnum(OrgMembershipRole) + projects: z + .object({ + id: z.string(), + projectRoleSlug: z.string().default(ProjectMembershipRole.Member) + }) + .array() + .optional(), + organizationRoleSlug: z.string().default(OrgMembershipRole.Member) }), response: { 200: z.object({ @@ -40,12 +45,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const completeInviteLinks = await server.services.org.inviteUserToOrganization({ + const { signupTokens: completeInviteLinks } = await server.services.org.inviteUserToOrganization({ orgId: req.body.organizationId, - userId: req.permission.id, + actor: req.permission.type, + actorId: req.permission.id, inviteeEmails: req.body.inviteeEmails, - projectIds: req.body.projectIds, - projectRoleSlug: req.body.projectRoleSlug, + projects: req.body.projects, organizationRoleSlug: req.body.organizationRoleSlug, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index a9592faab..174c9eabe 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { ProjectMembershipsSchema } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECT_USERS } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -36,14 +36,21 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }, 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, + const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; + const { projectMemberships: memberships } = await server.services.org.inviteUserToOrganization({ actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, actorOrgId: req.permission.orgId, actor: req.permission.type, - emails: req.body.emails, - usernames: req.body.usernames + inviteeEmails: usernamesAndEmails, + orgId: req.permission.orgId, + organizationRoleSlug: OrgMembershipRole.Member, + projects: [ + { + id: req.params.projectId, + projectRoleSlug: ProjectMembershipRole.Member + } + ] }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 7cca07110..af78d7730 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,15 +1,15 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; -import { OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { OrgMembershipStatus, SecretKeyEncoding, TableName } from "@app/db/schemas"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; -import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { getUserPrivateKey } from "@app/lib/crypto/srp"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; +import { BadRequestError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -17,11 +17,10 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; +import { TokenType } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { addMembersToProject } from "../project-membership/project-membership-fns"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; @@ -67,8 +66,6 @@ export const authSignupServiceFactory = ({ smtpService, orgService, orgDAL, - projectMembershipDAL, - projectUserMembershipRoleDAL, licenseService }: TAuthSignupDep) => { // first step of signup. create user and send email @@ -183,26 +180,82 @@ export const authSignupServiceFactory = ({ const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); - const userEncKey = await userDAL.upsertUserEncryptionKey( - us.id, - { - salt, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); + const systemGeneratedUserEncryptionKey = await userDAL.findUserEncKeyByUserId(us.id, tx); + let userEncKey; + + // below condition is true means this is system generated credentials + // the private key is actually system generated password + // thus we will re-encrypt the system generated private key with the new password + // akhilmhdh: you may find this like why? The reason is simple we are moving away from e2ee and these are pieces of it + // without a dummy key in place some things will break and backward compatiability too. 2025 we will be removing all these things + if ( + systemGeneratedUserEncryptionKey && + !systemGeneratedUserEncryptionKey.hashedPassword && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding + ) { + // get server generated password + const serverGeneratedPassword = infisicalSymmetricDecrypt({ + iv: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV, + tag: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag, + ciphertext: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey, + keyEncoding: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding + }); + const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { + ...systemGeneratedUserEncryptionKey + }); + const encKeys = await generateUserSrpKeys(email, password, { + publicKey: systemGeneratedUserEncryptionKey.publicKey, + privateKey: serverGeneratedPrivateKey + }); + // now reencrypt server generated key with user provided password + userEncKey = await userDAL.upsertUserEncryptionKey( + us.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, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } else { + userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: 2, + salt, + verifier, + publicKey, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } + // If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it if ( (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod as AuthMethod)) && @@ -312,8 +365,7 @@ export const authSignupServiceFactory = ({ encryptedPrivateKey, encryptedPrivateKeyIV, encryptedPrivateKeyTag, - authorization, - tokenMetadata + authorization }: TCompleteAccountInviteDTO) => { const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { @@ -348,65 +400,76 @@ export const authSignupServiceFactory = ({ const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); - const userEncKey = await userDAL.upsertUserEncryptionKey( - us.id, - { - salt, - encryptionVersion: 2, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); - - if (tokenMetadata) { - const metadataObj = jwt.verify(tokenMetadata, appCfg.AUTH_SECRET) as TTokenMetadata; - - if ( - metadataObj?.payload?.userId !== user.id || - metadataObj?.payload?.orgId !== orgMembership.orgId || - metadataObj?.type !== TokenMetadataType.InviteToProjects - ) { - throw new UnauthorizedError({ - message: "Malformed or invalid metadata token" - }); - } - - for await (const projectId of metadataObj.payload.projectIds) { - await addMembersToProject({ - orgDAL, - projectDAL, - projectMembershipDAL, - projectKeyDAL, - userGroupMembershipDAL, - projectBotDAL, - projectUserMembershipRoleDAL, - smtpService - }).addMembersToNonE2EEProject( - { - emails: [user.email!], - usernames: [], - projectId, - projectMembershipRole: metadataObj.payload.projectRoleSlug, - sendEmails: false - }, - { - tx, - throwOnProjectNotFound: false - } - ); - } + const systemGeneratedUserEncryptionKey = await userDAL.findUserEncKeyByUserId(us.id, tx); + let userEncKey; + // this means this is system generated credentials + // now replace the private key + if ( + systemGeneratedUserEncryptionKey && + !systemGeneratedUserEncryptionKey.hashedPassword && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding + ) { + // get server generated password + const serverGeneratedPassword = infisicalSymmetricDecrypt({ + iv: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV, + tag: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag, + ciphertext: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey, + keyEncoding: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding + }); + const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { + ...systemGeneratedUserEncryptionKey + }); + const encKeys = await generateUserSrpKeys(email, password, { + publicKey: systemGeneratedUserEncryptionKey.publicKey, + privateKey: serverGeneratedPrivateKey + }); + // now reencrypt server generated key with user provided password + userEncKey = await userDAL.upsertUserEncryptionKey( + us.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, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } else { + userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: 2, + salt, + verifier, + publicKey, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); } const updatedMembersips = await orgDAL.updateMembership( diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 6696a5323..f81719fac 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -9,7 +9,9 @@ import { OrgMembershipStatus, ProjectMembershipRole, ProjectVersion, + SecretKeyEncoding, TableName, + TProjectMemberships, TUsers } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; @@ -18,13 +20,15 @@ import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-grou 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 { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; -import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateSymmetricKey, infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; @@ -32,14 +36,14 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; +import { TokenType } from "../auth-token/auth-token-types"; import { TProjectDALFactory } from "../project/project-dal"; -import { verifyProjectVersions } from "../project/project-fns"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { addMembersToProject } from "../project-membership/project-membership-fns"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; @@ -84,6 +88,7 @@ type TOrgServiceFactoryDep = { "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" >; projectUserAdditionalPrivilegeDAL: Pick; + projectRoleDAL: Pick; userGroupMembershipDAL: Pick; projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; @@ -111,7 +116,8 @@ export const orgServiceFactory = ({ samlConfigDAL, userGroupMembershipDAL, projectBotDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + projectRoleDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -440,17 +446,17 @@ export const orgServiceFactory = ({ */ const inviteUserToOrganization = async ({ orgId, - userId, + actorId, + actor, inviteeEmails, organizationRoleSlug, - projectRoleSlug, - projectIds, + projects: invitedProjects, actorAuthMethod, actorOrgId }: TInviteUserToOrgDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); const org = await orgDAL.findOrgById(orgId); @@ -461,6 +467,13 @@ export const orgServiceFactory = ({ }); } + const isEmailInvalid = await isDisposableEmail(inviteeEmails); + if (isEmailInvalid) { + throw new BadRequestError({ + message: "Provided a disposable email", + name: "Org invite" + }); + } const plan = await licenseService.getPlan(orgId); if (plan?.memberLimit && plan.membersUsed >= plan.memberLimit) { // limit imposed on number of members allowed / number of members used exceeds the number of members allowed @@ -475,205 +488,316 @@ export const orgServiceFactory = ({ message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." }); } - - if (projectIds?.length) { - const projects = await projectDAL.find({ - orgId, - $in: { - id: projectIds - } - }); - - // if its not v3, throw an error - if (!verifyProjectVersions(projects, ProjectVersion.V3)) { + const isCustomOrgRole = !Object.values(OrgMembershipRole).includes(organizationRoleSlug as OrgMembershipRole); + if (isCustomOrgRole) { + if (!plan?.rbac) throw new BadRequestError({ - message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." + message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." }); - } } - const inviteeUsers = await orgDAL.transaction(async (tx) => { - const users: Pick< - TUsers & { orgId: string }, - "id" | "firstName" | "lastName" | "email" | "orgId" | "username" - >[] = []; + const projectsToInvite = invitedProjects?.length + ? await projectDAL.find({ + orgId, + $in: { + id: invitedProjects?.map(({ id }) => id) + } + }) + : []; + if (projectsToInvite.length !== invitedProjects?.length) { + throw new UnauthorizedError({ + message: "One or more project doesn't have access to" + }); + } + + if (projectsToInvite.some((el) => el.version !== ProjectVersion.V3)) { + throw new BadRequestError({ + message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." + }); + } + + const mailsForOrgInvitation: { email: string; userId: string; firstName: string; lastName: string }[] = []; + const mailsForProjectInvitaion: { email: string[]; projectName: string }[] = []; + const newProjectMemberships: TProjectMemberships[] = []; + await orgDAL.transaction(async (tx) => { + const users: Pick[] = []; + for await (const inviteeEmail of inviteeEmails) { - const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); - if (inviteeUser) { - // if user already exist means its already part of infisical - // Thus the signup flow is not needed anymore - const [inviteeMembership] = await orgDAL.findMembership( + // if the user doesn't exist we create the user with the email + if (!inviteeUser) { + inviteeUser = await userDAL.create( { - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, - [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false }, - { tx } + tx ); - if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { - throw new BadRequestError({ - message: `Failed to invite members because ${inviteeEmail} is already part of the organization`, - name: "Invite user to org" - }); - } - - if (!inviteeMembership) { - await orgDAL.createMembership( - { - userId: inviteeUser.id, - inviteEmail: inviteeEmail, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited, - isActive: true - }, - tx - ); - - if (projectIds?.length) { - if ( - organizationRoleSlug === OrgMembershipRole.Custom || - projectRoleSlug === ProjectMembershipRole.Custom - ) { - throw new BadRequestError({ - message: "Custom roles are not supported for inviting users to projects and organizations" - }); - } - - if (!projectRoleSlug) { - throw new BadRequestError({ - message: "Selecting a project role is required to invite users to projects" - }); - } - - await projectMembershipDAL.insertMany( - projectIds.map((id) => ({ projectId: id, userId: inviteeUser.id })), - tx - ); - for await (const projectId of projectIds) { - await addMembersToProject({ - orgDAL, - projectDAL, - projectMembershipDAL, - projectKeyDAL, - userGroupMembershipDAL, - projectBotDAL, - projectUserMembershipRoleDAL, - smtpService - }).addMembersToNonE2EEProject( - { - emails: [inviteeEmail], - usernames: [], - projectId, - projectMembershipRole: projectRoleSlug, - sendEmails: false - }, - { - tx - } - ); - } - } - } - return [{ ...inviteeUser, orgId }]; } - const isEmailInvalid = await isDisposableEmail(inviteeEmail); - if (isEmailInvalid) { - throw new BadRequestError({ - message: "Provided a disposable email", - name: "Org invite" + + const inviteeUserId = inviteeUser?.id; + const existingEncrytionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx); + + // when user is missing the encrytion keys + // this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp + // So what we do is we generate a random secure password and then encrypt it with a random pub-private key + // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password + if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncrytionKey)) { + const serverGeneratedPassword = crypto.randomBytes(32).toString("hex"); + const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(serverGeneratedPassword); + const encKeys = await generateUserSrpKeys(inviteeEmail, serverGeneratedPassword); + await userDAL.createUserEncryption( + { + userId: inviteeUserId, + 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, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } + + const [inviteeMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUserId + }, + { tx } + ); + + // if there exist no org membership we set is as given by the request + if (!inviteeMembership) { + let roleId; + const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug; + if (isCustomOrgRole) { + const customRole = await orgRoleDAL.findOne({ slug: organizationRoleSlug, orgId }); + if (!customRole) + throw new BadRequestError({ name: "Invite membership", message: "Organization role not found" }); + roleId = customRole.id; + } + + await orgDAL.createMembership( + { + userId: inviteeUser.id, + inviteEmail: inviteeEmail, + orgId, + role: orgRole, + status: OrgMembershipStatus.Invited, + isActive: true, + roleId + }, + tx + ); + mailsForOrgInvitation.push({ + email: inviteeEmail, + userId: inviteeUser.id, + firstName: inviteeUser?.firstName || "", + lastName: inviteeUser.lastName || "" }); } - // not invited before - const user = await userDAL.create( + + users.push(inviteeUser); + } + + const userIds = users.map(({ id }) => id); + const usernames = users.map((el) => el.username); + const userEncryptionKeys = await userDAL.findUserEncKeyByUserIdsBatch({ userIds }, tx); + // we don't need to spam with email. Thus org invitation doesn't need project invitation again + const userIdsWithOrgInvitation = new Set(mailsForOrgInvitation.map((el) => el.userId)); + + // if there exist no project membership we set is as given by the request + for await (const project of projectsToInvite) { + const projectId = project.id; + const { permission: projectPermission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Member + ); + const existingMembers = await projectMembershipDAL.find( { - username: inviteeEmail, - email: inviteeEmail, - isAccepted: false, - authMethods: [AuthMethod.EMAIL], - isGhost: false + projectId: project.id, + $in: { userId: userIds } }, + { tx } + ); + const existingMembersGroupByUserId = groupBy(existingMembers, (i) => i.userId); + const userIdsToExcludeAsPartOfGroup = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernames, projectId, tx) + ); + const userWithEncryptionKeyInvitedToProject = userEncryptionKeys.filter( + (user) => !existingMembersGroupByUserId?.[user.userId] && !userIdsToExcludeAsPartOfGroup.has(user.userId) + ); + // eslint-disable-next-line no-continue + if (!userWithEncryptionKeyInvitedToProject.length) continue; + + // validate custom project role + const invitedProjectRole = + invitedProjects.find((el) => el.id === project.id)?.projectRoleSlug || ProjectMembershipRole.Member; + const isCustomProjectRole = !Object.values(ProjectMembershipRole).includes( + invitedProjectRole as ProjectMembershipRole + ); + if (isCustomProjectRole) { + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." + }); + } + + const ghostUser = await projectDAL.findProjectGhostUser(projectId, tx); + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId, tx); + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }, tx); + 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: userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ + orgMembershipId: userEnc.userId, + projectMembershipRole: invitedProjectRole as ProjectMembershipRole, + userPublicKey: userEnc.publicKey + })) + }); + + const projectMemberships = await projectMembershipDAL.insertMany( + userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ + projectId, + userId: userEnc.userId + })), tx ); - await orgDAL.createMembership( - { - inviteEmail: inviteeEmail, - orgId, - userId: user.id, - role: organizationRoleSlug, - status: OrgMembershipStatus.Invited, - isActive: true - }, + newProjectMemberships.push(...projectMemberships); + + let customRoleId: string; + const projectRole = isCustomProjectRole ? ProjectMembershipRole.Custom : invitedProjectRole; + if (isCustomProjectRole) { + const customRole = await projectRoleDAL.findOne({ slug: invitedProjectRole, projectId }); + if (!customRole) throw new BadRequestError({ name: "Invite membership", message: "Project role not found" }); + customRoleId = customRole.id; + } + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectRole, customRoleId })), tx ); - users.push({ - ...user, - orgId + await projectKeyDAL.insertMany( + newWsMembers.map((el) => ({ + encryptedKey: el.workspaceEncryptedKey, + nonce: el.workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: el.orgMembershipId, + projectId + })), + tx + ); + mailsForProjectInvitaion.push({ + email: userWithEncryptionKeyInvitedToProject + .filter((el) => !userIdsWithOrgInvitation.has(el.userId)) + .map((el) => el.email || el.username), + projectName: project.name }); } return users; }); - const user = await userDAL.findById(userId); - + await licenseService.updateSubscriptionOrgMemberCount(orgId); const signupTokens: { email: string; link: string }[] = []; - if (inviteeUsers) { - for await (const invitee of inviteeUsers) { + // send org invite mail + await Promise.allSettled( + mailsForOrgInvitation.map(async (el) => { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: invitee.id, + userId: el.userId, orgId }); - let inviteMetadata: string = ""; - if (projectIds && projectIds?.length > 0) { - inviteMetadata = jwt.sign( - { - type: TokenMetadataType.InviteToProjects, - payload: { - projectIds, - projectRoleSlug: projectRoleSlug!, // Implicitly checked inside transaction if projectRoleSlug is undefined - userId: invitee.id, - orgId - } - } satisfies TTokenMetadata, - appCfg.AUTH_SECRET, - { - expiresIn: appCfg.JWT_INVITE_LIFETIME - } - ); - } - signupTokens.push({ - email: invitee.email || invitee.username, - link: `${appCfg.SITE_URL}/signupinvite?token=${token}${ - inviteMetadata ? `&metadata=${inviteMetadata}` : "" - }&to=${invitee.email || invitee.username}&organization_id=${org?.id}` + email: el.email, + link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${org?.id}` }); - await smtpService.sendMail({ + return smtpService.sendMail({ template: SmtpTemplates.OrgInvite, subjectLine: "Infisical organization invitation", - recipients: [invitee.email || invitee.username], + recipients: [el.email], substitutions: { - metadata: inviteMetadata, - inviterFirstName: user.firstName, - inviterUsername: user.username, + inviterFirstName: el.firstName, + inviterUsername: el.email, organizationName: org?.name, - email: invitee.email || invitee.username, + email: el.email, organizationId: org?.id.toString(), token, callback_url: `${appCfg.SITE_URL}/signupinvite` } }); - } - } - await licenseService.updateSubscriptionOrgMemberCount(orgId); + }) + ); + + await Promise.allSettled( + mailsForProjectInvitaion + .filter((el) => Boolean(el.email.length)) + .map(async (el) => { + return smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: el.email, + substitutions: { + workspaceName: el.projectName, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + }) + ); if (!appCfg.isSmtpConfigured) { - return signupTokens; + return { signupTokens, projectMemberships: newProjectMemberships }; } + + return { signupTokens: undefined, projectMemberships: newProjectMemberships }; }; /** diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 3fa4ae493..f89a602ad 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,4 +1,3 @@ -import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -26,14 +25,17 @@ export type TDeleteOrgMembershipDTO = { }; export type TInviteUserToOrgDTO = { - userId: string; + actorId: string; + actor: ActorType; orgId: string; actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; inviteeEmails: string[]; - organizationRoleSlug: OrgMembershipRole; - projectIds?: string[]; - projectRoleSlug?: ProjectMembershipRole; + organizationRoleSlug: string; + projects?: { + id: string; + projectRoleSlug?: string; + }[]; }; export type TVerifyUserToOrgDTO = { diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 077838918..49a86f31b 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -22,11 +22,9 @@ import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; -import { addMembersToProject } from "./project-membership-fns"; import { ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, - TAddUsersToWorkspaceNonE2EEDTO, TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipsDTO, TGetProjectMembershipByUsernameDTO, @@ -61,7 +59,6 @@ export const projectMembershipServiceFactory = ({ projectUserMembershipRoleDAL, smtpService, projectRoleDAL, - projectBotDAL, orgDAL, projectUserAdditionalPrivilegeDAL, userDAL, @@ -214,52 +211,6 @@ export const projectMembershipServiceFactory = ({ return orgMembers; }; - const addUsersToProjectNonE2EE = async ({ - projectId, - actorId, - actorAuthMethod, - actor, - actorOrgId, - emails, - usernames, - 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, - actorAuthMethod, - actorOrgId - ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - - const members = await addMembersToProject({ - orgDAL, - projectDAL, - projectMembershipDAL, - projectKeyDAL, - userGroupMembershipDAL, - projectBotDAL, - projectUserMembershipRoleDAL, - smtpService - }).addMembersToNonE2EEProject({ - emails, - usernames, - projectId, - projectMembershipRole: ProjectMembershipRole.Member, - sendEmails - }); - - return members; - }; - const updateProjectMembership = async ({ actorId, actor, @@ -530,7 +481,6 @@ export const projectMembershipServiceFactory = ({ getProjectMemberships, getProjectMembershipByUsername, updateProjectMembership, - addUsersToProjectNonE2EE, deleteProjectMemberships, deleteProjectMembership, // TODO: Remove this addUsersToProject, diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 9ec495ca3..99f403e84 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -82,10 +82,9 @@ export const userDALFactory = (db: TDbClient) => { } }; - const findUserEncKeyByUserId = async (userId: string) => { + const findUserEncKeyByUserId = async (userId: string, tx?: Knex) => { try { - const user = await db - .replicaNode()(TableName.Users) + const user = await (tx || db.replicaNode())(TableName.Users) .where(`${TableName.Users}.id`, userId) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) .first(); From 9d7bfae51945b894d8d7f608a9c4e0c840db447b Mon Sep 17 00:00:00 2001 From: = Date: Fri, 6 Sep 2024 23:33:12 +0530 Subject: [PATCH 2/5] feat: made default role on project invite as no access to org level --- backend/src/server/routes/v2/project-membership-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index 174c9eabe..625deacf9 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -44,7 +44,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, inviteeEmails: usernamesAndEmails, orgId: req.permission.orgId, - organizationRoleSlug: OrgMembershipRole.Member, + organizationRoleSlug: OrgMembershipRole.NoAccess, projects: [ { id: req.params.projectId, From eb7c804bb952825eb4e4446445ba8131d0614962 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 6 Sep 2024 23:33:57 +0530 Subject: [PATCH 3/5] feat(ui): made corresponding changes in api call made from frontend --- frontend/src/hooks/api/users/types.ts | 3 +-- .../OrgMembersSection/AddOrgMemberModal.tsx | 3 +-- .../MembersTab/components/MembersTable.tsx | 13 ++++++++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 550159fb4..6a6c35d93 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -150,8 +150,7 @@ export type DeletOrgMembershipDTO = { export type AddUserToOrgDTO = { inviteeEmails: string[]; - projectIds?: string[]; - projectRoleSlug?: string; + projects?: { id: string; projectRoleSlug: string }[]; organizationRoleSlug: string; organizationId: string; }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index 3e9720718..2a615237b 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -129,8 +129,7 @@ export const AddOrgMemberModal = ({ organizationId: currentOrg?.id, inviteeEmails: emails.split(",").map((email) => email.trim()), organizationRoleSlug, - projectIds, - projectRoleSlug + projects: projectIds.map((id) => ({ id, projectRoleSlug })) }); setCompleteInviteLinks(data?.completeInviteLinks ?? null); diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx index 5aab83882..bffd10a61 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx @@ -1,10 +1,11 @@ -import { useMemo,useState } from "react"; +import { useMemo, useState } from "react"; import { faClock, faEdit, faMagnifyingGlass, faTrash, - faUsers} from "@fortawesome/free-solid-svg-icons"; + faUsers +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -27,12 +28,14 @@ import { Th, THead, Tooltip, - Tr} from "@app/components/v2"; + Tr +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useUser, - useWorkspace} from "@app/context"; + useWorkspace +} from "@app/context"; import { useGetWorkspaceUsers } from "@app/hooks/api"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { TWorkspaceUser } from "@app/hooks/api/types"; @@ -104,7 +107,7 @@ export const MembersTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Prop {!isMembersLoading && filterdUsers?.map((projectMember, index) => { const { user: u, inviteEmail, id: membershipId, roles } = projectMember; - const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-"; const email = u?.email || inviteEmail; return ( From e7191c2f715876869753fe8f6a1c4e4aafc1237a Mon Sep 17 00:00:00 2001 From: = Date: Mon, 9 Sep 2024 16:17:59 +0530 Subject: [PATCH 4/5] feat: made project role multi support for org invite --- .../src/server/routes/v1/invite-org-router.ts | 2 +- .../routes/v2/project-membership-router.ts | 2 +- backend/src/services/org/org-service.ts | 52 ++++++++++++------- backend/src/services/org/org-types.ts | 2 +- frontend/src/hooks/api/users/types.ts | 2 +- .../OrgMembersSection/AddOrgMemberModal.tsx | 2 +- .../MembersTab/components/AddMemberModal.tsx | 4 +- 7 files changed, 40 insertions(+), 26 deletions(-) diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 58713b4d5..9991f6032 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -21,7 +21,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { projects: z .object({ id: z.string(), - projectRoleSlug: z.string().default(ProjectMembershipRole.Member) + projectRoleSlug: z.string().array().default([ProjectMembershipRole.Member]) }) .array() .optional(), diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index 625deacf9..6ab574d64 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -48,7 +48,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider projects: [ { id: req.params.projectId, - projectRoleSlug: ProjectMembershipRole.Member + projectRoleSlug: [ProjectMembershipRole.Member] } ] }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index f81719fac..9dad7086e 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -12,6 +12,7 @@ import { SecretKeyEncoding, TableName, TProjectMemberships, + TProjectUserMembershipRolesInsert, TUsers } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; @@ -88,7 +89,7 @@ type TOrgServiceFactoryDep = { "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" >; projectUserAdditionalPrivilegeDAL: Pick; - projectRoleDAL: Pick; + projectRoleDAL: Pick; userGroupMembershipDAL: Pick; projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; @@ -652,12 +653,15 @@ export const orgServiceFactory = ({ if (!userWithEncryptionKeyInvitedToProject.length) continue; // validate custom project role - const invitedProjectRole = - invitedProjects.find((el) => el.id === project.id)?.projectRoleSlug || ProjectMembershipRole.Member; - const isCustomProjectRole = !Object.values(ProjectMembershipRole).includes( - invitedProjectRole as ProjectMembershipRole + const invitedProjectRoles = invitedProjects.find((el) => el.id === project.id)?.projectRoleSlug || [ + ProjectMembershipRole.Member + ]; + + const customProjectRoles = invitedProjectRoles.filter( + (role) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) ); - if (isCustomProjectRole) { + const hasCustomRole = Boolean(customProjectRoles.length); + if (hasCustomRole) { if (!plan?.rbac) throw new BadRequestError({ message: @@ -665,6 +669,17 @@ export const orgServiceFactory = ({ }); } + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customProjectRoles.map((role) => role) } + }) + : []; + if (customRoles.length !== customProjectRoles.length) + throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + const ghostUser = await projectDAL.findProjectGhostUser(projectId, tx); if (!ghostUser) { throw new BadRequestError({ @@ -698,7 +713,7 @@ export const orgServiceFactory = ({ userPrivateKey: botPrivateKey, members: userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ orgMembershipId: userEnc.userId, - projectMembershipRole: invitedProjectRole as ProjectMembershipRole, + projectMembershipRole: ProjectMembershipRole.Admin, userPublicKey: userEnc.publicKey })) }); @@ -712,17 +727,18 @@ export const orgServiceFactory = ({ ); newProjectMemberships.push(...projectMemberships); - let customRoleId: string; - const projectRole = isCustomProjectRole ? ProjectMembershipRole.Custom : invitedProjectRole; - if (isCustomProjectRole) { - const customRole = await projectRoleDAL.findOne({ slug: invitedProjectRole, projectId }); - if (!customRole) throw new BadRequestError({ name: "Invite membership", message: "Project role not found" }); - customRoleId = customRole.id; - } - await projectUserMembershipRoleDAL.insertMany( - projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectRole, customRoleId })), - tx - ); + const sanitizedProjectMembershipRoles: TProjectUserMembershipRolesInsert[] = []; + invitedProjectRoles.forEach((projectRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[projectRole]?.[0]); + projectMemberships.forEach((membership) => { + sanitizedProjectMembershipRoles.push({ + projectMembershipId: membership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : projectRole, + customRoleId: customRolesGroupBySlug[projectRole] ? customRolesGroupBySlug[projectRole][0].id : null + }); + }); + }); + await projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); await projectKeyDAL.insertMany( newWsMembers.map((el) => ({ diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index f89a602ad..ef0a5606a 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -34,7 +34,7 @@ export type TInviteUserToOrgDTO = { organizationRoleSlug: string; projects?: { id: string; - projectRoleSlug?: string; + projectRoleSlug?: string[]; }[]; }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 6a6c35d93..6c538811b 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -150,7 +150,7 @@ export type DeletOrgMembershipDTO = { export type AddUserToOrgDTO = { inviteeEmails: string[]; - projects?: { id: string; projectRoleSlug: string }[]; + projects?: { id: string; projectRoleSlug: string[] }[]; organizationRoleSlug: string; organizationId: string; }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index 2a615237b..7194432c5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -129,7 +129,7 @@ export const AddOrgMemberModal = ({ organizationId: currentOrg?.id, inviteeEmails: emails.split(",").map((email) => email.trim()), organizationRoleSlug, - projects: projectIds.map((id) => ({ id, projectRoleSlug })) + projects: projectIds.map((id) => ({ id, projectRoleSlug: [projectRoleSlug] })) }); setCompleteInviteLinks(data?.completeInviteLinks ?? null); diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index 8a5fe705a..6db92861e 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -101,9 +101,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { members?.forEach((member) => { wsUserUsernames.set(member.user.username, true); }); - return (orgUsers || []).filter( - ({ status, user: u }) => status === "accepted" && !wsUserUsernames.has(u.username) - ); + return (orgUsers || []).filter(({ user: u }) => !wsUserUsernames.has(u.username)); }, [orgUsers, members]); return ( From 52a885716d98e61fa7ff1af05710ad8e8ec6279a Mon Sep 17 00:00:00 2001 From: = Date: Wed, 11 Sep 2024 10:46:49 +0530 Subject: [PATCH 5/5] feat: changes on review comments --- backend/src/lib/crypto/srp.ts | 10 ++++++++-- backend/src/lib/validator/validate-email.ts | 10 +++++----- backend/src/services/auth/auth-signup-service.ts | 9 +++++---- backend/src/services/user/user-fns.ts | 14 +++++--------- backend/src/services/user/user-types.ts | 4 ++++ 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/backend/src/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index d8d21f5c1..e6afd0f99 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -5,6 +5,7 @@ import nacl from "tweetnacl"; import tweetnacl from "tweetnacl-util"; import { TUserEncryptionKeys } from "@app/db/schemas"; +import { UserEncryption } from "@app/services/user/user-types"; import { decryptSymmetric128BitHexKeyUTF8, encryptAsymmetric, encryptSymmetric } from "./encryption"; @@ -115,7 +116,7 @@ export const getUserPrivateKey = async ( | "encryptionVersion" > ) => { - if (user.encryptionVersion === 1) { + if (user.encryptionVersion === UserEncryption.V1) { return decryptSymmetric128BitHexKeyUTF8({ ciphertext: user.encryptedPrivateKey, iv: user.iv, @@ -123,7 +124,12 @@ export const getUserPrivateKey = async ( key: password.slice(0, 32).padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0") }); } - if (user.encryptionVersion === 2 && user.protectedKey && user.protectedKeyIV && user.protectedKeyTag) { + if ( + user.encryptionVersion === UserEncryption.V2 && + user.protectedKey && + user.protectedKeyIV && + user.protectedKeyTag + ) { const derivedKey = await argon2.hash(password, { salt: Buffer.from(user.salt), memoryCost: 65536, diff --git a/backend/src/lib/validator/validate-email.ts b/backend/src/lib/validator/validate-email.ts index 99c09302d..c0c50155b 100644 --- a/backend/src/lib/validator/validate-email.ts +++ b/backend/src/lib/validator/validate-email.ts @@ -1,16 +1,16 @@ import fs from "fs/promises"; import path from "path"; -export const isDisposableEmail = async (email: string | string[]) => { +export const isDisposableEmail = async (emails: string | string[]) => { const disposableEmails = await fs.readFile(path.join(__dirname, "disposable_emails.txt"), "utf8"); - if (Array.isArray(email)) { - return email.some((el) => { - const emailDomain = el.split("@")[1]; + if (Array.isArray(emails)) { + return emails.some((email) => { + const emailDomain = email.split("@")[1]; return disposableEmails.split("\n").includes(emailDomain); }); } - const emailDomain = email.split("@")[1]; + const emailDomain = emails.split("@")[1]; if (disposableEmails.split("\n").includes(emailDomain)) return true; return false; }; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index af78d7730..11c4bc61e 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -24,6 +24,7 @@ import { TProjectMembershipDALFactory } from "../project-membership/project-memb import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; +import { UserEncryption } from "../user/user-types"; import { TAuthDALFactory } from "./auth-dal"; import { validateProviderAuthToken, validateSignUpAuthorization } from "./auth-fns"; import { TCompleteAccountInviteDTO, TCompleteAccountSignupDTO } from "./auth-signup-type"; @@ -174,7 +175,7 @@ export const authSignupServiceFactory = ({ encryptedPrivateKey, iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag, - encryptionVersion: 2 + encryptionVersion: UserEncryption.V2 }); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(privateKey); const updateduser = await authDAL.transaction(async (tx) => { @@ -214,7 +215,7 @@ export const authSignupServiceFactory = ({ userEncKey = await userDAL.upsertUserEncryptionKey( us.id, { - encryptionVersion: 2, + encryptionVersion: UserEncryption.V2, protectedKey: encKeys.protectedKey, protectedKeyIV: encKeys.protectedKeyIV, protectedKeyTag: encKeys.protectedKeyTag, @@ -236,7 +237,7 @@ export const authSignupServiceFactory = ({ userEncKey = await userDAL.upsertUserEncryptionKey( us.id, { - encryptionVersion: 2, + encryptionVersion: UserEncryption.V2, salt, verifier, publicKey, @@ -452,7 +453,7 @@ export const authSignupServiceFactory = ({ userEncKey = await userDAL.upsertUserEncryptionKey( us.id, { - encryptionVersion: 2, + encryptionVersion: UserEncryption.V2, salt, verifier, publicKey, diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts index 639320e24..23aff77a4 100644 --- a/backend/src/services/user/user-fns.ts +++ b/backend/src/services/user/user-fns.ts @@ -4,18 +4,14 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TUserDALFactory } from "@app/services/user/user-dal"; export const normalizeUsername = async (username: string, userDAL: Pick) => { - let attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); + let attempt: string; + let user; - let user = await userDAL.findOne({ username: attempt }); - if (!user) return attempt; - - while (true) { + do { attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); // eslint-disable-next-line no-await-in-loop user = await userDAL.findOne({ username: attempt }); + } while (user); - if (!user) { - return attempt; - } - } + return attempt; }; diff --git a/backend/src/services/user/user-types.ts b/backend/src/services/user/user-types.ts index e69de29bb..f7b46978a 100644 --- a/backend/src/services/user/user-types.ts +++ b/backend/src/services/user/user-types.ts @@ -0,0 +1,4 @@ +export enum UserEncryption { + V1 = 1, + V2 = 2 +}