diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8a2b961e9..dea905aa2 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -74,6 +74,7 @@ const envSchema = z JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")), JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")), + JWT_INVITE_LIFETIME: zpStr(z.string().default("1d")), JWT_MFA_LIFETIME: zpStr(z.string().default("5m")), JWT_PROVIDER_AUTH_LIFETIME: zpStr(z.string().default("15m")), // Oauth diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 363688ac2..a15482226 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -477,9 +477,12 @@ export const registerRoutes = async ( orgRoleDAL, permissionService, orgDAL, + userGroupMembershipDAL, + projectBotDAL, incidentContactDAL, tokenService, projectUserAdditionalPrivilegeDAL, + projectUserMembershipRoleDAL, projectDAL, projectMembershipDAL, orgMembershipDAL, @@ -499,6 +502,8 @@ export const registerRoutes = async ( projectDAL, projectBotDAL, groupProjectDAL, + projectMembershipDAL, + projectUserMembershipRoleDAL, orgDAL, orgService, licenseService @@ -683,6 +688,7 @@ export const registerRoutes = async ( orgDAL, orgService, projectMembershipDAL, + projectRoleDAL, folderDAL, licenseService, certificateAuthorityDAL, diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 873710f10..c2907d3ca 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { UsersSchema } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas"; import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -16,23 +16,37 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { method: "POST", schema: { body: z.object({ - inviteeEmail: z.string().trim().email(), - organizationId: z.string().trim() + 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) }), response: { 200: z.object({ message: z.string(), - completeInviteLink: z.string().optional() + completeInviteLinks: z + .array( + z.object({ + email: z.string(), + link: z.string() + }) + ) + .optional() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const completeInviteLink = await server.services.org.inviteUserToOrganization({ + + const completeInviteLinks = await server.services.org.inviteUserToOrganization({ orgId: req.body.organizationId, userId: req.permission.id, - inviteeEmail: req.body.inviteeEmail, + inviteeEmails: req.body.inviteeEmails, + projectIds: req.body.projectIds, + projectRoleSlug: req.body.projectRoleSlug, + organizationRoleSlug: req.body.organizationRoleSlug, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId }); @@ -41,14 +55,15 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { event: PostHogEventTypes.UserOrgInvitation, distinctId: getTelemetryDistinctId(req), properties: { - inviteeEmail: req.body.inviteeEmail, + inviteeEmails: req.body.inviteeEmails, + organizationRoleSlug: req.body.organizationRoleSlug, ...req.auditLogInfo } }); return { - completeInviteLink, - message: `Send an invite link to ${req.body.inviteeEmail}` + completeInviteLinks, + message: `Send an invite link to ${req.body.inviteeEmails.join(", ")}` }; } }); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index ac9703f07..a380eb934 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { IntegrationsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + IntegrationsSchema, + ProjectMembershipsSchema, + ProjectRolesSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -122,15 +128,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + querystring: z.object({ + includeRoles: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), response: { 200: z.object({ - workspaces: projectWithEnv.array() + workspaces: projectWithEnv + .extend({ + roles: ProjectRolesSchema.array().optional() + }) + .array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), handler: async (req) => { - const workspaces = await server.services.project.getProjects(req.permission.id); + const workspaces = await server.services.project.getProjects({ + includeRoles: req.query.includeRoles, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); return { workspaces }; } }); diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 59131464a..2b603f523 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -179,7 +179,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { encryptedPrivateKeyIV: z.string().trim(), encryptedPrivateKeyTag: z.string().trim(), salt: z.string().trim(), - verifier: z.string().trim() + verifier: z.string().trim(), + tokenMetadata: z.string().optional() }), response: { 200: z.object({ diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 8917bd672..65d16850a 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,3 +1,5 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; + export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified @@ -49,3 +51,19 @@ export type TIssueAuthTokenDTO = { ip: string; userAgent: string; }; + +export enum TokenMetadataType { + InviteToProjects = "projects-invite" +} + +export type TTokenInviteToProjectsMetadataPayload = { + projectIds: string[]; + projectRoleSlug: ProjectMembershipRole; + userId: string; + orgId: string; +}; + +export type TTokenMetadata = { + type: TokenMetadataType.InviteToProjects; + payload: TTokenInviteToProjectsMetadataPayload; +}; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 83a5b27d9..7cca07110 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -9,7 +9,7 @@ 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 } from "@app/lib/errors"; +import { BadRequestError, UnauthorizedError } 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,9 +17,12 @@ 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 { TokenType } from "../auth-token/auth-token-types"; +import { TokenMetadataType, TokenType, TTokenMetadata } 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"; import { TAuthDALFactory } from "./auth-dal"; @@ -32,10 +35,14 @@ type TAuthSignupDep = { userDAL: TUserDALFactory; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, - "find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds" + | "find" + | "transaction" + | "insertMany" + | "deletePendingUserGroupMembershipsByUserIds" + | "findUserGroupMembershipsInProject" >; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; groupProjectDAL: Pick; orgService: Pick; @@ -43,6 +50,8 @@ type TAuthSignupDep = { tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; licenseService: Pick; + projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; }; export type TAuthSignupFactory = ReturnType; @@ -58,6 +67,8 @@ export const authSignupServiceFactory = ({ smtpService, orgService, orgDAL, + projectMembershipDAL, + projectUserMembershipRoleDAL, licenseService }: TAuthSignupDep) => { // first step of signup. create user and send email @@ -301,7 +312,8 @@ export const authSignupServiceFactory = ({ encryptedPrivateKey, encryptedPrivateKeyIV, encryptedPrivateKeyTag, - authorization + authorization, + tokenMetadata }: TCompleteAccountInviteDTO) => { const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { @@ -358,6 +370,45 @@ export const authSignupServiceFactory = ({ 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 updatedMembersips = await orgDAL.updateMembership( { inviteEmail: email, status: OrgMembershipStatus.Invited }, { userId: us.id, status: OrgMembershipStatus.Accepted }, diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts index 9cd70f8c7..3308b9d12 100644 --- a/backend/src/services/auth/auth-signup-type.ts +++ b/backend/src/services/auth/auth-signup-type.ts @@ -37,4 +37,5 @@ export type TCompleteAccountInviteDTO = { ip: string; userAgent: string; authorization: string; + tokenMetadata?: string; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 051869429..d7c6ba31a 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -114,10 +114,11 @@ export const orgDALFactory = (db: TDbClient) => { } }; - const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => { + const findOrgMembersByUsername = async (orgId: string, usernames: string[], tx?: Knex) => { try { - const members = await db - .replicaNode()(TableName.OrgMembership) + const conn = tx || db; + const members = await conn(TableName.OrgMembership) + // .replicaNode()(TableName.OrgMembership) .where(`${TableName.OrgMembership}.orgId`, orgId) .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .leftJoin( @@ -126,18 +127,18 @@ export const orgDALFactory = (db: TDbClient) => { `${TableName.Users}.id` ) .select( - db.ref("id").withSchema(TableName.OrgMembership), - db.ref("inviteEmail").withSchema(TableName.OrgMembership), - db.ref("orgId").withSchema(TableName.OrgMembership), - db.ref("role").withSchema(TableName.OrgMembership), - db.ref("roleId").withSchema(TableName.OrgMembership), - db.ref("status").withSchema(TableName.OrgMembership), - db.ref("username").withSchema(TableName.Users), - db.ref("email").withSchema(TableName.Users), - db.ref("firstName").withSchema(TableName.Users), - db.ref("lastName").withSchema(TableName.Users), - db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + conn.ref("id").withSchema(TableName.OrgMembership), + conn.ref("inviteEmail").withSchema(TableName.OrgMembership), + conn.ref("orgId").withSchema(TableName.OrgMembership), + conn.ref("role").withSchema(TableName.OrgMembership), + conn.ref("roleId").withSchema(TableName.OrgMembership), + conn.ref("status").withSchema(TableName.OrgMembership), + conn.ref("username").withSchema(TableName.Users), + conn.ref("email").withSchema(TableName.Users), + conn.ref("firstName").withSchema(TableName.Users), + conn.ref("lastName").withSchema(TableName.Users), + conn.ref("id").withSchema(TableName.Users).as("userId"), + conn.ref("publicKey").withSchema(TableName.UserEncryptionKey) ) .where({ isGhost: false }) .whereIn("username", usernames); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 23e0aeeff..6696a5323 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -4,9 +4,17 @@ import crypto from "crypto"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { + OrgMembershipRole, + OrgMembershipStatus, + ProjectMembershipRole, + ProjectVersion, + TableName, + TUsers +} from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; 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"; @@ -24,10 +32,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 { TokenType } from "../auth-token/auth-token-types"; +import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; import { TProjectDALFactory } from "../project/project-dal"; +import { verifyProjectVersions } 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 { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; @@ -56,8 +68,11 @@ type TOrgServiceFactoryDep = { userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; projectDAL: TProjectDALFactory; - projectMembershipDAL: Pick; - projectKeyDAL: Pick; + projectMembershipDAL: Pick< + TProjectMembershipDALFactory, + "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" + >; + projectKeyDAL: Pick; orgMembershipDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; @@ -69,6 +84,9 @@ type TOrgServiceFactoryDep = { "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" >; projectUserAdditionalPrivilegeDAL: Pick; + userGroupMembershipDAL: Pick; + projectBotDAL: Pick; + projectUserMembershipRoleDAL: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -90,7 +108,10 @@ export const orgServiceFactory = ({ tokenService, orgBotDAL, licenseService, - samlConfigDAL + samlConfigDAL, + userGroupMembershipDAL, + projectBotDAL, + projectUserMembershipRoleDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -420,10 +441,15 @@ export const orgServiceFactory = ({ const inviteUserToOrganization = async ({ orgId, userId, - inviteeEmail, + inviteeEmails, + organizationRoleSlug, + projectRoleSlug, + projectIds, actorAuthMethod, actorOrgId }: TInviteUserToOrgDTO) => { + const appCfg = getConfig(); + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -450,98 +476,203 @@ export const orgServiceFactory = ({ }); } - const invitee = await orgDAL.transaction(async (tx) => { - const 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( - { - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, - [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id - }, - { tx } - ); - if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { - throw new BadRequestError({ - message: "Failed to invite an existing member of org", - name: "Invite user to org" - }); + if (projectIds?.length) { + const projects = await projectDAL.find({ + orgId, + $in: { + id: projectIds } + }); - if (!inviteeMembership) { - await orgDAL.createMembership( - { - userId: inviteeUser.id, - inviteEmail: inviteeEmail, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited, - isActive: true - }, - tx - ); - } - return inviteeUser; - } - const isEmailInvalid = await isDisposableEmail(inviteeEmail); - if (isEmailInvalid) { + // if its not v3, throw an error + if (!verifyProjectVersions(projects, ProjectVersion.V3)) { throw new BadRequestError({ - message: "Provided a disposable email", - name: "Org invite" + message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." }); } - // not invited before - const user = await userDAL.create( - { - username: inviteeEmail, - email: inviteeEmail, - isAccepted: false, - authMethods: [AuthMethod.EMAIL], - isGhost: false - }, - tx - ); - await orgDAL.createMembership( - { - inviteEmail: inviteeEmail, - orgId, - userId: user.id, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited, - isActive: true - }, - tx - ); - return user; - }); + } - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: invitee.id, - orgId + const inviteeUsers = await orgDAL.transaction(async (tx) => { + const users: Pick< + TUsers & { orgId: string }, + "id" | "firstName" | "lastName" | "email" | "orgId" | "username" + >[] = []; + for await (const inviteeEmail of inviteeEmails) { + const 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( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id + }, + { 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" + }); + } + // not invited before + const user = await userDAL.create( + { + username: inviteeEmail, + email: inviteeEmail, + isAccepted: false, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + await orgDAL.createMembership( + { + inviteEmail: inviteeEmail, + orgId, + userId: user.id, + role: organizationRoleSlug, + status: OrgMembershipStatus.Invited, + isActive: true + }, + tx + ); + + users.push({ + ...user, + orgId + }); + } + return users; }); const user = await userDAL.findById(userId); - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.OrgInvite, - subjectLine: "Infisical organization invitation", - recipients: [inviteeEmail], - substitutions: { - inviterFirstName: user.firstName, - inviterUsername: user.username, - organizationName: org?.name, - email: inviteeEmail, - organizationId: org?.id.toString(), - token, - callback_url: `${appCfg.SITE_URL}/signupinvite` - } - }); + const signupTokens: { email: string; link: string }[] = []; + if (inviteeUsers) { + for await (const invitee of inviteeUsers) { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: invitee.id, + 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}` + }); + + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [invitee.email || invitee.username], + substitutions: { + metadata: inviteMetadata, + inviterFirstName: user.firstName, + inviterUsername: user.username, + organizationName: org?.name, + email: invitee.email || invitee.username, + organizationId: org?.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + } + } await licenseService.updateSubscriptionOrgMemberCount(orgId); + if (!appCfg.isSmtpConfigured) { - return `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${inviteeEmail}&organization_id=${org?.id}`; + return signupTokens; } }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 08b4c1c92..3fa4ae493 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,3 +1,4 @@ +import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -29,7 +30,10 @@ export type TInviteUserToOrgDTO = { orgId: string; actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; - inviteeEmail: string; + inviteeEmails: string[]; + organizationRoleSlug: OrgMembershipRole; + projectIds?: string[]; + projectRoleSlug?: ProjectMembershipRole; }; export type TVerifyUserToOrgDTO = { diff --git a/backend/src/services/project-membership/project-membership-fns.ts b/backend/src/services/project-membership/project-membership-fns.ts new file mode 100644 index 000000000..98acd6eec --- /dev/null +++ b/backend/src/services/project-membership/project-membership-fns.ts @@ -0,0 +1,190 @@ +import { Knex } from "knex"; + +import { ProjectMembershipRole, SecretKeyEncoding, TProjectMemberships } from "@app/db/schemas"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +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 { TOrgDALFactory } from "../org/org-dal"; +import { TProjectDALFactory } from "../project/project-dal"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TProjectMembershipDALFactory } from "./project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; + +type TAddMembersToProjectArg = { + orgDAL: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; + projectKeyDAL: Pick; + projectBotDAL: Pick; + userGroupMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; + smtpService: Pick; +}; + +type AddMembersToNonE2EEProjectDTO = { + emails: string[]; + usernames: string[]; + projectId: string; + projectMembershipRole: ProjectMembershipRole; + sendEmails?: boolean; +}; + +type AddMembersToNonE2EEProjectOptions = { + tx?: Knex; + throwOnProjectNotFound?: boolean; +}; + +export const addMembersToProject = ({ + orgDAL, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + projectBotDAL, + userGroupMembershipDAL, + projectUserMembershipRoleDAL, + smtpService +}: TAddMembersToProjectArg) => { + // Can create multiple memberships for a singular project, based on user email / username + const addMembersToNonE2EEProject = async ( + { emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO, + options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true } + ) => { + const processTransaction = async (tx: Knex) => { + const usernamesAndEmails = [...emails, ...usernames]; + + const project = await projectDAL.findProjectById(projectId); + if (!project) { + if (options.throwOnProjectNotFound) { + throw new BadRequestError({ message: "Project not found when attempting to add user to project" }); + } + + return []; + } + + const orgMembers = await orgDAL.findOrgMembersByUsername( + project.orgId, + [...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))], + tx + ); + + if (orgMembers.length !== usernamesAndEmails.length) + throw new BadRequestError({ message: "Some users are not part of org" }); + + if (!orgMembers.length) return []; + + const existingMembers = await projectMembershipDAL.find({ + projectId, + $in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) } + }); + if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); + + const ghostUser = await projectDAL.findProjectGhostUser(projectId); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }); + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const newWsMembers = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: botPrivateKey, + members: orgMembers.map((membership) => ({ + orgMembershipId: membership.id, + projectMembershipRole, + userPublicKey: membership.user.publicKey + })) + }); + + const members: TProjectMemberships[] = []; + + const userIdsToExcludeForProjectKeyAddition = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) + ); + const projectMemberships = await projectMembershipDAL.insertMany( + orgMembers.map(({ user }) => ({ + projectId, + userId: user.id + })), + tx + ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })), + tx + ); + + members.push(...projectMemberships); + + const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); + await projectKeyDAL.insertMany( + orgMembers + .filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id)) + .map(({ user, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: user.id, + projectId + })), + tx + ); + + if (sendEmails) { + const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string); + + const appCfg = getConfig(); + + if (recipients.length) { + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } + } + + return members; + }; + + if (options.tx) { + return processTransaction(options.tx); + } + return projectMembershipDAL.transaction(processTransaction); + }; + + return { + addMembersToNonE2EEProject + }; +}; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6996625f7..077838918 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -2,19 +2,12 @@ import { ForbiddenError } from "@casl/ability"; import ms from "ms"; -import { - ProjectMembershipRole, - ProjectVersion, - SecretKeyEncoding, - TableName, - TProjectMemberships -} from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectVersion, TableName } 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 { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; 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"; @@ -23,13 +16,13 @@ import { ActorType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; -import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; +import { addMembersToProject } from "./project-membership-fns"; import { ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, @@ -53,7 +46,7 @@ type TProjectMembershipServiceFactoryDep = { userGroupMembershipDAL: TUserGroupMembershipDALFactory; projectRoleDAL: Pick; orgDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; @@ -247,116 +240,23 @@ export const projectMembershipServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - const usernamesAndEmails = [...emails, ...usernames]; - - const orgMembers = await orgDAL.findOrgMembersByUsername(project.orgId, [ - ...new Set(usernamesAndEmails.map((element) => element.toLowerCase())) - ]); - - if (orgMembers.length !== usernamesAndEmails.length) - throw new BadRequestError({ message: "Some users are not part of org" }); - - if (!orgMembers.length) return []; - - const existingMembers = await projectMembershipDAL.find({ + const members = await addMembersToProject({ + orgDAL, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + userGroupMembershipDAL, + projectBotDAL, + projectUserMembershipRoleDAL, + smtpService + }).addMembersToNonE2EEProject({ + emails, + usernames, projectId, - $in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) } - }); - if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); - - const ghostUser = await projectDAL.findProjectGhostUser(projectId); - - if (!ghostUser) { - throw new BadRequestError({ - message: "Failed to find sudo user" - }); - } - - const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); - - if (!ghostUserLatestKey) { - throw new BadRequestError({ - message: "Failed to find sudo user latest key" - }); - } - - const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) { - throw new BadRequestError({ - message: "Failed to find bot" - }); - } - - const botPrivateKey = infisicalSymmetricDecrypt({ - keyEncoding: bot.keyEncoding as SecretKeyEncoding, - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey + projectMembershipRole: ProjectMembershipRole.Member, + sendEmails }); - const newWsMembers = assignWorkspaceKeysToMembers({ - decryptKey: ghostUserLatestKey, - userPrivateKey: botPrivateKey, - members: orgMembers.map((membership) => ({ - orgMembershipId: membership.id, - projectMembershipRole: ProjectMembershipRole.Member, - userPublicKey: membership.user.publicKey - })) - }); - - const members: TProjectMemberships[] = []; - - const userIdsToExcludeForProjectKeyAddition = new Set( - await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) - ); - - await projectMembershipDAL.transaction(async (tx) => { - const projectMemberships = await projectMembershipDAL.insertMany( - orgMembers.map(({ user }) => ({ - projectId, - userId: user.id - })), - tx - ); - await projectUserMembershipRoleDAL.insertMany( - projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), - tx - ); - - members.push(...projectMemberships); - - const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); - await projectKeyDAL.insertMany( - orgMembers - .filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id)) - .map(({ user, id }) => ({ - encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, - nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, - senderId: ghostUser.id, - receiverId: user.id, - projectId - })), - tx - ); - }); - - if (sendEmails) { - const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string); - - const appCfg = getConfig(); - - if (recipients.length) { - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical project invitation", - recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string), - substitutions: { - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - } - } return members; }; diff --git a/backend/src/services/project-role/project-role-fns.ts b/backend/src/services/project-role/project-role-fns.ts new file mode 100644 index 000000000..c465715a7 --- /dev/null +++ b/backend/src/services/project-role/project-role-fns.ts @@ -0,0 +1,52 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; +import { + projectAdminPermissions, + projectMemberPermissions, + projectNoAccessPermissions, + projectViewerPermission +} from "@app/ee/services/permission/project-permission"; + +export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Full administrative access over a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Limited read/write role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Only read role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + updatedAt: new Date() + } + ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); +}; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 8c71a6e02..f60577516 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -5,13 +5,9 @@ import { ProjectMembershipRole } from "@app/db/schemas"; import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { - projectAdminPermissions, - projectMemberPermissions, - projectNoAccessPermissions, ProjectPermissionActions, ProjectPermissionSet, - ProjectPermissionSub, - projectViewerPermission + ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; @@ -20,6 +16,7 @@ import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/id import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; +import { getPredefinedRoles } from "./project-role-fns"; import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; type TProjectRoleServiceFactoryDep = { @@ -37,51 +34,6 @@ const unpackPermissions = (permissions: unknown) => unpackRules((permissions || []) as PackRule>>[]) ); -const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { - return [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - projectId, - name: "Admin", - slug: ProjectMembershipRole.Admin, - permissions: projectAdminPermissions, - description: "Full administrative access over a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - projectId, - name: "Developer", - slug: ProjectMembershipRole.Member, - permissions: projectMemberPermissions, - description: "Limited read/write role in a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response - projectId, - name: "Viewer", - slug: ProjectMembershipRole.Viewer, - permissions: projectViewerPermission, - description: "Only read role in a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - projectId, - name: "No Access", - slug: ProjectMembershipRole.NoAccess, - permissions: projectNoAccessPermissions, - description: "No access to any resources in the project", - createdAt: new Date(), - updatedAt: new Date() - } - ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); -}; - export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService, diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index d6b010e0b..cb836d932 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,5 +1,6 @@ import crypto from "crypto"; +import { ProjectVersion, TProjects } from "@app/db/schemas"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -53,6 +54,16 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; }; +export const verifyProjectVersions = (projects: Pick[], version: ProjectVersion) => { + for (const project of projects) { + if (project.version !== version) { + return false; + } + } + + return true; +}; + export const getProjectKmsCertificateKeyId = async ({ projectId, projectDAL, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index a7111d784..3e8f7582e 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -10,6 +10,7 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; @@ -30,6 +31,8 @@ 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 { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { getPredefinedRoles } from "../project-role/project-role-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; @@ -44,6 +47,7 @@ import { TListProjectCasDTO, TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, + TListProjectsDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, @@ -84,6 +88,7 @@ type TProjectServiceFactoryDep = { orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; + projectRoleDAL: Pick; kmsService: Pick< TKmsServiceFactory, | "updateProjectSecretManagerKmsKey" @@ -112,6 +117,7 @@ export const projectServiceFactory = ({ projectEnvDAL, licenseService, projectUserMembershipRoleDAL, + projectRoleDAL, identityProjectMembershipRoleDAL, certificateAuthorityDAL, certificateDAL, @@ -389,8 +395,34 @@ export const projectServiceFactory = ({ return deletedProject; }; - const getProjects = async (actorId: string) => { + const getProjects = async ({ actorId, includeRoles, actorAuthMethod, actorOrgId }: TListProjectsDTO) => { const workspaces = await projectDAL.findAllProjects(actorId); + + if (includeRoles) { + const { permission } = await permissionService.getUserOrgPermission(actorId, actorOrgId, actorAuthMethod); + + // `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls. + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + const customRoles = await projectRoleDAL.find({ + $in: { + projectId: workspaces.map((workspace) => workspace.id) + } + }); + + const workspaceMappedToRoles = groupBy(customRoles, (role) => role.projectId); + + const workspacesWithRoles = await Promise.all( + workspaces.map(async (workspace) => { + return { + ...workspace, + roles: [...(workspaceMappedToRoles[workspace.id] || []), ...getPredefinedRoles(workspace.id)] + }; + }) + ); + + return workspacesWithRoles; + } + return workspaces; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index ee2f1aea9..c0ef2579e 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -75,6 +75,10 @@ export type TDeleteProjectDTO = { actorOrgId: string | undefined; } & Omit; +export type TListProjectsDTO = { + includeRoles: boolean; +} & Omit; + export type TUpgradeProjectDTO = { userPrivateKey: string; } & TProjectPermission; diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index 024fca132..3ee16ee37 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -9,7 +9,7 @@

Join your organization on Infisical

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}

- Join now + 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.

diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index b168fe5d7..ddeb24211 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -100,7 +100,9 @@ export type TIntegrationCreatedEvent = { export type TUserOrgInvitedEvent = { event: PostHogEventTypes.UserOrgInvitation; properties: { - inviteeEmail: string; + inviteeEmails: string[]; + projectIds?: string[]; + organizationRoleSlug?: string; }; }; diff --git a/frontend/next.config.js b/frontend/next.config.js index d1d5876ed..9b9db1346 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,7 +2,7 @@ const path = require("path"); const ContentSecurityPolicy = ` default-src 'self'; - script-src 'self' https://*.posthog.com https://*.*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; + script-src 'self' https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; child-src https://api.stripe.com; frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com; diff --git a/frontend/src/components/basic/dialog/AddUserDialog.tsx b/frontend/src/components/basic/dialog/AddUserDialog.tsx deleted file mode 100644 index dd2aede0a..000000000 --- a/frontend/src/components/basic/dialog/AddUserDialog.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Fragment } from "react"; -import { Dialog, Transition } from "@headlessui/react"; - -import Button from "../buttons/Button"; -import InputField from "../InputField"; - -type Props = { - isOpen: boolean; - closeModal: () => void; - submitModal: (email: string) => void; - email: string; - setEmail: (email: string) => void; - orgName: string; -}; - -const AddUserDialog = ({ isOpen, closeModal, submitModal, email, setEmail, orgName }: Props) => { - const submit = () => { - submitModal(email); - }; - - return ( -
- - - -
- - -
-
- - - - Invite others to {orgName} - -
-

- An invite is specific to an email address and expires after 1 day. For - security reasons, you will need to separately add members to projects. -

-
-
- -
-
-
-
- {/* - - Unleash Infisical's Full Power - -
-

- You have exceeded the number of members in a free organization. -

-

- Upgrade now and get access to adding more members, as well as to other powerful enhancements. -

-
-
- - -
-
*/} -
-
-
-
-
-
- ); -}; - -export default AddUserDialog; diff --git a/frontend/src/components/signup/TeamInviteStep.tsx b/frontend/src/components/signup/TeamInviteStep.tsx index a06fc3568..934935a42 100644 --- a/frontend/src/components/signup/TeamInviteStep.tsx +++ b/frontend/src/components/signup/TeamInviteStep.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; -import { useAddUserToOrg } from "@app/hooks/api"; +import { useAddUsersToOrg } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -17,7 +17,7 @@ export default function TeamInviteStep(): JSX.Element { const [emails, setEmails] = useState(""); const { data: serverDetails } = useFetchServerStatus(); - const { mutateAsync } = useAddUserToOrg(); + const { mutateAsync } = useAddUsersToOrg(); const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const); // Redirect user to the getting started page @@ -31,8 +31,9 @@ export default function TeamInviteStep(): JSX.Element { .map((email) => email.trim()) .map(async (email) => { mutateAsync({ - inviteeEmail: email, - organizationId: String(localStorage.getItem("orgData.id")) + inviteeEmails: [email], + organizationId: String(localStorage.getItem("orgData.id")), + organizationRoleSlug: "member" }); }); diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 1b4dff1f0..3664e8e96 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -93,6 +93,7 @@ export type CompleteAccountDTO = { salt: string; verifier: string; password: string; + tokenMetadata?: string; }; export type CompleteAccountSignupDTO = CompleteAccountDTO & { diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 865d28789..77e82e4fa 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -47,7 +47,7 @@ export const roleQueryKeys = { ["user-project-permissions", { workspaceId }] as const }; -const getProjectRoles = async (projectId: string) => { +export const getProjectRoles = async (projectId: string) => { const { data } = await apiRequest.get<{ roles: Array> }>( `/api/v1/workspace/${projectId}/roles` ); diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 2026cede1..ed9e5cc47 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -6,7 +6,7 @@ export { } from "./mutation"; export { fetchOrgUsers, - useAddUserToOrg, + useAddUsersToOrg, useCreateAPIKey, useDeleteAPIKey, useDeleteMe, @@ -26,4 +26,5 @@ export { useRevokeMySessions, useUpdateMfaEnabled, useUpdateOrgMembership, - useUpdateUserAuthMethods} from "./queries"; + useUpdateUserAuthMethods +} from "./queries"; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 4629ce4e6..ead567bee 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -157,12 +157,15 @@ export const useGetOrgUsers = (orgId: string) => // mutation // TODO(akhilmhdh): move all mutation to mutation file -export const useAddUserToOrg = () => { +export const useAddUsersToOrg = () => { const queryClient = useQueryClient(); type Response = { data: { message: string; - completeInviteLink: string | undefined; + completeInviteLinks?: { + email: string; + link: string; + }[]; }; }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 825d468da..550159fb4 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -149,7 +149,10 @@ export type DeletOrgMembershipDTO = { }; export type AddUserToOrgDTO = { - inviteeEmail: string; + inviteeEmails: string[]; + projectIds?: string[]; + projectRoleSlug?: string; + organizationRoleSlug: string; organizationId: string; }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 8f1b065da..9ca3bbf34 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -138,8 +138,12 @@ export const useGetUpgradeProjectStatus = ({ }); }; -const fetchUserWorkspaces = async () => { - const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace"); +const fetchUserWorkspaces = async (includeRoles?: boolean) => { + const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace", { + params: { + includeRoles + } + }); return data.workspaces; }; @@ -171,8 +175,8 @@ export const useGetWorkspaceById = ( }); }; -export const useGetUserWorkspaces = () => - useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); +export const useGetUserWorkspaces = (includeRoles?: boolean) => + useQuery(workspaceKeys.getAllUserWorkspace, () => fetchUserWorkspaces(includeRoles)); const fetchUserWorkspaceMemberships = async (orgId: string) => { const { data } = await apiRequest.get>( diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 51bb08e3d..efb31b330 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -1,3 +1,5 @@ +import { TProjectRole } from "../roles/types"; + export enum ProjectVersion { V1 = 1, V2 = 2, @@ -22,6 +24,8 @@ export type Workspace = { auditLogsRetentionDays: number; slug: string; createdAt: string; + + roles?: TProjectRole[]; }; export type WorkspaceEnv = { diff --git a/frontend/src/hooks/useToggle.tsx b/frontend/src/hooks/useToggle.tsx index 83d73bcb1..ecc243ade 100644 --- a/frontend/src/hooks/useToggle.tsx +++ b/frontend/src/hooks/useToggle.tsx @@ -8,6 +8,7 @@ type UseToggleReturn = [ on: VoidFn; off: VoidFn; toggle: VoidFn; + timedToggle: (timeout?: number) => void; } ]; @@ -26,5 +27,13 @@ export const useToggle = (initialState = false): UseToggleReturn => { setValue((prev) => (typeof isOpen === "boolean" ? isOpen : !prev)); }, []); - return [value, { on, off, toggle }]; + const timedToggle = useCallback((timeout = 2000) => { + setValue((prev) => !prev); + + setTimeout(() => { + setValue(false); + }, timeout); + }, []); + + return [value, { on, off, toggle, timedToggle }]; }; diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 349952ba5..cf953664d 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -38,6 +38,7 @@ export default function LoginPage() { const { user, isLoading: userLoading } = useUser(); const queryParams = new URLSearchParams(window.location.search); + const callbackPort = queryParams.get("callback_port"); const logout = useLogoutUser(true); const handleLogout = useCallback(async () => { @@ -52,8 +53,6 @@ export default function LoginPage() { const handleSelectOrganization = useCallback( async (organization: Organization) => { - const callbackPort = queryParams.get("callback_port"); - if (organization.authEnforced) { // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO @@ -116,9 +115,8 @@ export default function LoginPage() { [selectOrg] ); - useEffect(() => { + const handleCliRedirect = useCallback(() => { const authToken = getAuthToken(); - const callbackPort = queryParams.get("callback_port"); if (authToken && !callbackPort) { const decodedJwt = jwt_decode(authToken) as any; @@ -131,13 +129,27 @@ export default function LoginPage() { if (!isLoggedIn()) { router.push("/login"); } + }, []); + + useEffect(() => { + if (callbackPort) { + handleCliRedirect(); + } }, [router]); // Case: User has no organizations. // This can happen if the user was previously a member, but the organization was deleted or the user was removed. useEffect(() => { - if (!organizations.isLoading && organizations.data?.length === 0) { + if (organizations.isLoading || !organizations.data) return; + + if (organizations.data.length === 0) { router.push("/org/none"); + } else if (organizations.data.length === 1) { + if (callbackPort) { + handleCliRedirect(); + } else { + handleSelectOrganization(organizations.data[0]); + } } }, [organizations.isLoading, organizations.data]); diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index 725dc6113..713a64eb3 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -64,6 +64,10 @@ export default function SignupInvite() { const email = (parsedUrl.to as string)?.replace(" ", "+").trim(); const { config } = useServerConfig(); + const queryParams = new URLSearchParams(window.location.search); + + const metadata = queryParams.get("metadata") || undefined; + const { mutateAsync: selectOrganization } = useSelectOrganization(); useEffect(() => { @@ -160,7 +164,8 @@ export default function SignupInvite() { encryptedPrivateKeyIV, encryptedPrivateKeyTag, salt: result.salt, - verifier: result.verifier + verifier: result.verifier, + tokenMetadata: metadata }); // unset temporary signup JWT token and set JWT token 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 aedeb369a..3e9720718 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 @@ -1,65 +1,144 @@ import { Controller, useForm } from "react-hook-form"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { + faCheckCircle, + faChevronDown, + faExclamationCircle +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, IconButton, Input, Modal, ModalContent } from "@app/components/v2"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Modal, + ModalContent, + Select, + SelectItem, + TextArea, + Tooltip +} from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { useToggle } from "@app/hooks"; -import { useAddUserToOrg, useFetchServerStatus } from "@app/hooks/api"; +import { + useAddUsersToOrg, + useFetchServerStatus, + useGetOrgRoles, + useGetUserWorkspaces +} from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const addMemberFormSchema = yup.object({ - email: yup.string().email().required().label("Email").trim().lowercase() +import { OrgInviteLink } from "./OrgInviteLink"; + +const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member"; + +const EmailSchema = z.string().email().min(1).trim().toLowerCase(); + +const addMemberFormSchema = z.object({ + emails: z.string().min(1).trim().toLowerCase(), + projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]), + projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), + organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG) }); -type TAddMemberForm = yup.InferType; +type TAddMemberForm = z.infer; type Props = { popUp: UsePopUpState<["addMember"]>; handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void; - completeInviteLink: string; - setCompleteInviteLink: (link: string) => void; + completeInviteLinks: Array<{ + email: string; + link: string; + }> | null; + setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void; }; export const AddOrgMemberModal = ({ popUp, handlePopUpToggle, - completeInviteLink, - setCompleteInviteLink + completeInviteLinks, + setCompleteInviteLinks }: Props) => { - const { currentOrg } = useOrganization(); + const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? ""); const { data: serverDetails } = useFetchServerStatus(); - const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); - - const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); + const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); + const { data: projects } = useGetUserWorkspaces(true); const { control, handleSubmit, + watch, reset, formState: { isSubmitting } - } = useForm({ resolver: yupResolver(addMemberFormSchema) }); + } = useForm({ resolver: zodResolver(addMemberFormSchema) }); - const onAddMember = async ({ email }: TAddMemberForm) => { + const selectedProjectIds = watch("projectIds", []); + + const onAddMembers = async ({ + emails, + organizationRoleSlug, + projectIds, + projectRoleSlug + }: TAddMemberForm) => { if (!currentOrg?.id) return; + const selectedProjects = projects?.filter((project) => projectIds.includes(String(project.id))); + + if (selectedProjects?.length) { + // eslint-disable-next-line no-restricted-syntax + for (const project of selectedProjects) { + if (project.version !== ProjectVersion.V3) { + createNotification({ + type: "error", + text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.` + }); + return; + } + } + } + try { - const { data } = await addUserMutateAsync({ + const parsedEmails = emails + .replace(/\s/g, "") + .split(",") + .map((email) => { + if (EmailSchema.safeParse(email).success) { + return email.trim(); + } + + return null; + }); + + if (parsedEmails.includes(null)) { + createNotification({ + text: "Invalid email addresses provided.", + type: "error" + }); + return; + } + + const { data } = await addUsersMutateAsync({ organizationId: currentOrg?.id, - inviteeEmail: email + inviteeEmails: emails.split(",").map((email) => email.trim()), + organizationRoleSlug, + projectIds, + projectRoleSlug }); - setCompleteInviteLink(data?.completeInviteLink ?? ""); + setCompleteInviteLinks(data?.completeInviteLinks ?? null); // only show this notification when email is configured. // A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLink) { + if (!data.completeInviteLinks) { createNotification({ text: "Successfully invited user to the organization.", type: "success" @@ -80,47 +159,196 @@ export const AddOrgMemberModal = ({ reset(); }; - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(completeInviteLink as string); - setInviteLinkCopied.on(); - }; - return ( { handlePopUpToggle("addMember", isOpen); - setCompleteInviteLink(""); + setCompleteInviteLinks(null); }} > - {!completeInviteLink && ( -
- An invite is specific to an email address and expires after 1 day. -
- For security reasons, you will need to separately add members to projects. -
+ {!completeInviteLinks && ( +
An invite is specific to an email address and expires after 1 day.
)} - {completeInviteLink && + {completeInviteLinks && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} } > - {!completeInviteLink && ( -
+ {!completeInviteLinks && ( + ( - - + +