From 54fcc23a6c82abe465ab0d0f04a1a52760691781 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 19 Apr 2024 16:16:16 -0700 Subject: [PATCH 01/14] Begin groups phase 2b --- .../20240419200953_email-confirmation.ts | 15 ++ backend/src/db/schemas/users.ts | 3 +- .../services/license/__mocks__/licence-fns.ts | 4 +- .../src/ee/services/license/licence-fns.ts | 4 +- .../src/ee/services/license/license-types.ts | 4 +- backend/src/ee/services/scim/scim-service.ts | 24 ++- backend/src/server/routes/index.ts | 2 +- backend/src/server/routes/v2/user-router.ts | 63 +++++++- .../services/auth-token/auth-token-service.ts | 6 + .../services/auth-token/auth-token-types.ts | 1 + .../src/services/auth/auth-signup-service.ts | 2 +- backend/src/services/smtp/smtp-service.ts | 1 + .../templates/emailVerification.handlebars | 16 +- .../signupEmailVerification.handlebars | 17 ++ backend/src/services/user/user-service.ts | 73 ++++++++- frontend/src/hooks/api/auth/index.tsx | 5 +- frontend/src/hooks/api/auth/queries.tsx | 2 +- frontend/src/hooks/api/users/index.tsx | 7 +- frontend/src/hooks/api/users/mutation.tsx | 20 +++ frontend/src/pages/signup/index.tsx | 4 +- frontend/src/views/Signup/SignupSSO.tsx | 4 +- .../EmailConfirmationStep.tsx | 149 ++++++++++++++++++ .../EmailConfirmationStep/index.tsx | 1 + .../src/views/Signup/components/index.tsx | 1 + 24 files changed, 394 insertions(+), 34 deletions(-) create mode 100644 backend/src/db/migrations/20240419200953_email-confirmation.ts create mode 100644 backend/src/services/smtp/templates/signupEmailVerification.handlebars create mode 100644 frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx create mode 100644 frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx diff --git a/backend/src/db/migrations/20240419200953_email-confirmation.ts b/backend/src/db/migrations/20240419200953_email-confirmation.ts new file mode 100644 index 0000000000..59d7b3d41e --- /dev/null +++ b/backend/src/db/migrations/20240419200953_email-confirmation.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isEmailVerified"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isEmailVerified"); + }); +} diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 86ee2fb74e..3eee2683f0 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -21,7 +21,8 @@ export const UsersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), isGhost: z.boolean().default(false), - username: z.string() + username: z.string(), + isEmailVerified: z.boolean().nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index b5cbf103ee..20186718d3 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -17,8 +17,8 @@ export const getDefaultOnPremFeatures = () => { customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, - samlSSO: false, - scim: false, + samlSSO: true, + scim: true, ldap: false, groups: false, status: null, diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8a4de57f1e..9179fde325 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -24,8 +24,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, - samlSSO: false, - scim: false, + samlSSO: true, + scim: true, ldap: false, groups: false, status: null, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 1cea39a834..2cc321373a 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -40,8 +40,8 @@ export type TFeatureSet = { customAlerts: false; auditLogs: false; auditLogsRetentionDays: 0; - samlSSO: false; - scim: false; + samlSSO: true; + scim: true; ldap: false; groups: false; status: null; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 15ca67a109..120b41bd16 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -57,6 +57,8 @@ type TScimServiceFactoryDep = { export type TScimServiceFactory = ReturnType; +// TODO: finish updating all userId refs to orgMembershipId + export const scimServiceFactory = ({ licenseService, scimDAL, @@ -145,6 +147,7 @@ export const scimServiceFactory = ({ // SCIM server endpoints const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise => { + console.log("listScimUsers"); // done const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -178,9 +181,11 @@ export const scimServiceFactory = ({ findOpts ); - const scimUsers = users.map(({ userId, username, firstName, lastName, email }) => + console.log("orgDAL.findMembership users: ", users); + + const scimUsers = users.map(({ id, username, firstName, lastName, email }) => buildScimUser({ - userId: userId ?? "", + userId: id ?? "", username, firstName: firstName ?? "", lastName: lastName ?? "", @@ -197,6 +202,7 @@ export const scimServiceFactory = ({ }; const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { + console.log("getScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -221,8 +227,10 @@ export const scimServiceFactory = ({ status: 403 }); + console.log("getScimUser membership: ", membership); + return buildScimUser({ - userId: membership.userId as string, + userId: membership.id, username: membership.username, email: membership.email ?? "", firstName: membership.firstName as string, @@ -232,6 +240,7 @@ export const scimServiceFactory = ({ }; const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + console.log("createScimUser"); // TODO: update implementation to always create a new user and be based on orgMembershipId const org = await orgDAL.findById(orgId); if (!org) @@ -331,6 +340,7 @@ export const scimServiceFactory = ({ }; const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { + console.log("updateScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -380,7 +390,7 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: membership.userId as string, + userId: membership.id, username: membership.username, email: membership.email, firstName: membership.firstName as string, @@ -390,6 +400,7 @@ export const scimServiceFactory = ({ }; const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => { + console.log("replaceScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -426,7 +437,7 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: membership.userId as string, + userId: membership.id, username: membership.username, email: membership.email, firstName: membership.firstName as string, @@ -436,6 +447,7 @@ export const scimServiceFactory = ({ }; const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { + console.log("deleteScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -489,7 +501,7 @@ export const scimServiceFactory = ({ buildScimGroup({ groupId: group.id, name: group.name, - members: [] + members: [] // does this need to be populated? }) ); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5d77c340b9..893f36770b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -315,7 +315,7 @@ export const registerRoutes = async ( }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const userService = userServiceFactory({ userDAL }); + const userService = userServiceFactory({ userDAL, tokenService, smtpService }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index d1e80702f2..dd49f6d5ee 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,11 +2,72 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; export const registerUserRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/me/emails/code", + config: { + rateLimit: authRateLimit + }, + schema: { + response: { + 200: z.object({}) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.user.sendEmailVerificationCode(req.permission.id); + return {}; + } + }); + + server.route({ + method: "POST", + url: "/me/emails/verify", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + code: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.user.verifyEmailVerificationCode(req.permission.id, req.body.code); + return {}; + } + }); + + server.route({ + method: "GET", + url: "/me/users/same-email", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + users: UsersSchema.array() + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const users = await server.services.user.listUsersWithSameEmail(req.permission.id); + return { + users + }; + } + }); + server.route({ method: "PATCH", url: "/me/mfa", diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 59f336e5a2..bd35f3ae19 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -27,6 +27,12 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_EMAIL_VERIFICATION: { + // generate random 6-digit code + const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const expiresAt = new Date(new Date().getTime() + 86400000); + return { token, expiresAt }; + } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 74787f4acb..630e363101 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,5 +1,6 @@ export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", + TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 3db935769f..b997e2293b 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -60,7 +60,7 @@ export const authSignupServiceFactory = ({ }); await smtpService.sendMail({ - template: SmtpTemplates.EmailVerification, + template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", recipients: [email], substitutions: { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 7ebeaa227a..0b43ffb908 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -17,6 +17,7 @@ export type TSmtpSendMail = { export type TSmtpService = ReturnType; export enum SmtpTemplates { + SignupEmailVerification = "signupEmailVerification.handlebars", EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index fc738d2023..ad9694d5c5 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -1,17 +1,15 @@ - - - - + + + Code - + - +

Confirm your email address

-

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

Your confirmation code is below — enter it in the browser window where you've started confirming your email.

{{code}}

-

Questions about setting up Infisical? Email us at support@infisical.com

- + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars new file mode 100644 index 0000000000..fc738d2023 --- /dev/null +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -0,0 +1,17 @@ + + + + + + + Code + + + +

Confirm your email address

+

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

{{code}}

+

Questions about setting up Infisical? Email us at support@infisical.com

+ + + \ No newline at end of file diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index c85e40eb3c..453848bf77 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,15 +1,83 @@ import { BadRequestError } from "@app/lib/errors"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { AuthMethod } from "../auth/auth-type"; import { TUserDALFactory } from "./user-dal"; type TUserServiceFactoryDep = { userDAL: TUserDALFactory; + tokenService: TAuthTokenServiceFactory; + smtpService: TSmtpService; }; export type TUserServiceFactory = ReturnType; -export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { +export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUserServiceFactoryDep) => { + const sendEmailVerificationCode = async (userId: string) => { + console.log("sendEmailVerificationCode userId: ", userId); + const user = await userDAL.findById(userId); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); + + console.log("sendEmailVerificationCode user: ", user); + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + console.log("sendEmailVerificationCode 2"); + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + }; + + const verifyEmailVerificationCode = async (userId: string, code: string) => { + console.log("verifyEmailVerificationCode args: ", { + userId, + code + }); + + const user = await userDAL.findById(userId); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id, + code + }); + + await userDAL.updateById(userId, { isEmailVerified: true }); + }; + + // lists users with same verified email only + const listUsersWithSameEmail = async (userId: string) => { + const user = await userDAL.findById(userId); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to list users with same email due to no email on user" }); + if (!user.isEmailVerified) + throw new BadRequestError({ name: "Failed to list users with same email due to email not verified" }); + + const users = await userDAL.find({ + email: user.email, + isEmailVerified: true + }); + + return users; + }; + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const user = await userDAL.findById(userId); @@ -72,6 +140,9 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { }; return { + sendEmailVerificationCode, + verifyEmailVerificationCode, + listUsersWithSameEmail, toggleUserMfa, updateUserName, updateAuthMethods, diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 8b918c7ab5..505f7b05f0 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -5,7 +5,6 @@ export { useSendMfaToken, useSendPasswordResetEmail, useSendVerificationEmail, - useVerifyEmailVerificationCode, useVerifyMfaToken, - useVerifyPasswordResetCode -} from "./queries"; + useVerifyPasswordResetCode, + useVerifySignupEmailVerificationCode} from "./queries"; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 4d05fb9639..20209df710 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -164,7 +164,7 @@ export const useSendVerificationEmail = () => { }); }; -export const useVerifyEmailVerificationCode = () => { +export const useVerifySignupEmailVerificationCode = () => { return useMutation({ mutationFn: async ({ email, code }: { email: string; code: string }) => { const { data } = await apiRequest.post("/api/v3/signup/email/verify", { diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 9c51948f27..a8ad89f4cf 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,4 +1,9 @@ -export { useAddUserToWsE2EE, useAddUserToWsNonE2EE } from "./mutation"; +export { + useAddUserToWsE2EE, + useAddUserToWsNonE2EE, + useSendEmailVerificationCode, + useVerifyEmailVerificationCode +} from "./mutation"; export { fetchOrgUsers, useAddUserToOrg, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index a5c77b15fb..e1e9391056 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -61,3 +61,23 @@ export const useAddUserToWsNonE2EE = () => { } }); }; + +export const useSendEmailVerificationCode = () => { + return useMutation({ + mutationFn: async () => { + await apiRequest.post("/api/v2/users/me/emails/code"); + return {}; + } + }); +}; + +export const useVerifyEmailVerificationCode = () => { + return useMutation({ + mutationFn: async ({ code }: { code: string }) => { + await apiRequest.post("/api/v2/users/me/emails/verify", { + code + }); + return {}; + } + }); +}; diff --git a/frontend/src/pages/signup/index.tsx b/frontend/src/pages/signup/index.tsx index 17316e1e6a..0719111d6f 100644 --- a/frontend/src/pages/signup/index.tsx +++ b/frontend/src/pages/signup/index.tsx @@ -13,7 +13,7 @@ import TeamInviteStep from "@app/components/signup/TeamInviteStep"; import UserInfoStep from "@app/components/signup/UserInfoStep"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { useServerConfig } from "@app/context"; -import { useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { useVerifySignupEmailVerificationCode } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; @@ -34,7 +34,7 @@ export default function SignUp() { const [isSignupWithEmail, setIsSignupWithEmail] = useState(false); const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false); const { t } = useTranslation(); - const { mutateAsync } = useVerifyEmailVerificationCode(); + const { mutateAsync } = useVerifySignupEmailVerificationCode(); const { config } = useServerConfig(); useEffect(() => { diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index 141865026e..d74da3639b 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import jwt_decode from "jwt-decode"; -import { BackupPDFStep, UserInfoSSOStep } from "./components"; +import { BackupPDFStep, EmailConfirmationStep,UserInfoSSOStep } from "./components"; type Props = { providerAuthToken: string; @@ -28,6 +28,8 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { /> ); case 1: + return ; + case 2: return ( ); diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx new file mode 100644 index 0000000000..e9b0a6e84d --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -0,0 +1,149 @@ +// confirm email +// if same email exists, then trigger fn to merge automatically +import { useState } from "react"; +import ReactCodeInput from "react-code-input"; + +// import Error from "@app/components/basic/Error"; +import { createNotification } from "@app/components/notifications"; +import { Button } from "@app/components/v2"; +import { useUser } from "@app/context"; +import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api"; + +// The style for the verification code input +const props = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "55px", + borderRadius: "5px", + fontSize: "24px", + height: "55px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; +const propsPhone = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "40px", + borderRadius: "5px", + fontSize: "24px", + height: "40px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + +export const EmailConfirmationStep = () => { + const { user } = useUser(); + const [code, setCode] = useState(""); + // const [codeError, setCodeError] = useState(false); + const [isResendingVerificationEmail] = useState(false); + const [isLoading] = useState(false); + + const { mutateAsync: sendEmailVerificationCode } = useSendEmailVerificationCode(); + const { mutateAsync: verifyEmailVerificationCode } = useVerifyEmailVerificationCode(); + + const checkCode = async () => { + try { + console.log("checkCode code: ", code); + await verifyEmailVerificationCode({ code }); + console.log("checkCode 2"); + } catch (err) { + createNotification({ + text: "Failed to verify code", + type: "error" + }); + } + }; + + const resendCode = async () => { + try { + console.log("resendCode"); + await sendEmailVerificationCode(); + console.log("resendCode"); + } catch (err) { + createNotification({ + text: "Failed to resend code", + type: "error" + }); + } + }; + + return ( +
+

+ We've sent a verification code to +

+

+ {user?.email} +

+
+ +
+
+ +
+ {/* {codeError && } */} +
+
+ +
+
+
+
+ Don't see the code? +
+ +
+
+

Make sure to check your spam inbox.

+
+
+ ); +}; diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx new file mode 100644 index 0000000000..32f3a636e1 --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx @@ -0,0 +1 @@ +export { EmailConfirmationStep } from "./EmailConfirmationStep"; diff --git a/frontend/src/views/Signup/components/index.tsx b/frontend/src/views/Signup/components/index.tsx index a4628de355..7ab3d853cc 100644 --- a/frontend/src/views/Signup/components/index.tsx +++ b/frontend/src/views/Signup/components/index.tsx @@ -1,2 +1,3 @@ export { BackupPDFStep } from "./BackupPDFStep"; +export { EmailConfirmationStep } from "./EmailConfirmationStep"; export { UserInfoSSOStep } from "./UserInfoSSOStep"; From 8ff407927cfd94896ef089536d8a727df55db478 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 25 Apr 2024 17:02:55 -0700 Subject: [PATCH 02/14] Continue merge user --- .../src/ee/services/license/licence-fns.ts | 4 +- .../saml-config/saml-config-service.ts | 4 + backend/src/server/routes/index.ts | 11 +- backend/src/server/routes/v2/user-router.ts | 33 ++++++ .../services/auth-token/auth-token-service.ts | 5 +- .../org-membership/org-membership-dal.ts | 13 +++ backend/src/services/user/user-service.ts | 79 +++++++++++++- frontend/src/hooks/api/users/index.tsx | 9 +- frontend/src/hooks/api/users/mutation.tsx | 18 +++- frontend/src/hooks/api/users/queries.tsx | 20 +++- frontend/src/views/Signup/SignupSSO.tsx | 15 ++- .../EmailConfirmationStep.tsx | 38 +++++-- .../MergeUsersStep/MergeUsersStep.tsx | 102 ++++++++++++++++++ .../components/MergeUsersStep/index.tsx | 1 + .../UserInfoSSOStep/UserInfoSSOStep.tsx | 10 +- .../src/views/Signup/components/index.tsx | 1 + 16 files changed, 337 insertions(+), 26 deletions(-) create mode 100644 backend/src/services/org-membership/org-membership-dal.ts create mode 100644 frontend/src/views/Signup/components/MergeUsersStep/MergeUsersStep.tsx create mode 100644 frontend/src/views/Signup/components/MergeUsersStep/index.tsx diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 9179fde325..5046ad125b 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -26,8 +26,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogsRetentionDays: 0, samlSSO: true, scim: true, - ldap: false, - groups: false, + ldap: true, + groups: true, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index f88182e614..c1d270ee1c 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -23,6 +23,7 @@ import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -33,6 +34,7 @@ import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } f type TSamlConfigServiceFactoryDep = { samlConfigDAL: TSamlConfigDALFactory; userDAL: Pick; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" @@ -360,6 +362,7 @@ export const samlConfigServiceFactory = ({ { username, email, + isEmailVerified: false, firstName, lastName, authMethods: [AuthMethod.EMAIL], @@ -382,6 +385,7 @@ export const samlConfigServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + ...(user.email && { email: user.email }), firstName, lastName, organizationName: organization.name, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 31a13aaebe..70ba31ac89 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -86,6 +86,7 @@ import { orgDALFactory } from "@app/services/org/org-dal"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; +import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -153,6 +154,7 @@ export const registerRoutes = async ( const authDAL = authDALFactory(db); const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); + const orgMembershipDAL = orgMembershipDALFactory(db); const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); const orgRoleDAL = orgRoleDALFactory(db); @@ -328,7 +330,14 @@ export const registerRoutes = async ( }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const userService = userServiceFactory({ userDAL, tokenService, smtpService }); + const userService = userServiceFactory({ + userDAL, + userAliasDAL, + orgDAL, + orgMembershipDAL, + tokenService, + smtpService + }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index dd49f6d5ee..5760fb593d 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; +import { getConfig } from "@app/lib/config/env"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; @@ -68,6 +69,38 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/me/users/merge-user", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + username: z.string().trim() + }), + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req, res) => { + const appCfg = getConfig(); + const user = await server.services.user.mergeUsers(req.permission.id, req.body.username); + void res.cookie("jid", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + return { + user + }; + } + }); + server.route({ method: "PATCH", url: "/me/mfa", diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index bd35f3ae19..5d68a4e947 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -30,13 +30,14 @@ export const getTokenConfig = (tokenType: TokenType) => { case TokenType.TOKEN_EMAIL_VERIFICATION: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const triesLeft = 3; const expiresAt = new Date(new Date().getTime() + 86400000); - return { token, expiresAt }; + return { token, triesLeft, expiresAt }; } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); - const triesLeft = 5; + const triesLeft = 3; const expiresAt = new Date(new Date().getTime() + 300000); return { token, triesLeft, expiresAt }; } diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts new file mode 100644 index 0000000000..9990d9c3dd --- /dev/null +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgMembershipDALFactory = ReturnType; + +export const orgMembershipDALFactory = (db: TDbClient) => { + const orgMembershipOrm = ormify(db, TableName.OrgMembership); + + return { + ...orgMembershipOrm + }; +}; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 453848bf77..552ddff118 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,20 +1,34 @@ import { BadRequestError } from "@app/lib/errors"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; import { TUserDALFactory } from "./user-dal"; +// TODO: Pick all of these type TUserServiceFactoryDep = { userDAL: TUserDALFactory; + userAliasDAL: TUserAliasDALFactory; + orgDAL: TOrgDALFactory; + orgMembershipDAL: TOrgMembershipDALFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; }; export type TUserServiceFactory = ReturnType; -export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUserServiceFactoryDep) => { +export const userServiceFactory = ({ + userDAL, + userAliasDAL, + // orgDAL, + orgMembershipDAL, + tokenService, + smtpService +}: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (userId: string) => { console.log("sendEmailVerificationCode userId: ", userId); const user = await userDAL.findById(userId); @@ -78,6 +92,68 @@ export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUser return users; }; + /** + * Merges two users with the same email. Specifically: + * - Deletes the current user with id [userId] and transfers any resources to the user with username [username] + * @param userId + * @param username + */ + const mergeUsers = async (userId: string, username: string) => { + const targetUser = await userDAL.transaction(async (tx) => { + const myUser = await userDAL.findById(userId, tx); + if (!myUser || !myUser.isEmailVerified) throw new BadRequestError({}); + + const mergeUser = await userDAL.findOne( + { + username + }, + tx + ); + if (!mergeUser || !mergeUser.isEmailVerified) throw new BadRequestError({}); + + if (myUser.email !== mergeUser.email) throw new BadRequestError({}); + + const mergeUserOrgMembershipSet = new Set( + (await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId) + ); + const myOrgMemberships = (await orgMembershipDAL.find({ userId: myUser.id }, { tx })).filter( + (m) => !mergeUserOrgMembershipSet.has(m.orgId) + ); + + const userAliases = await userAliasDAL.find( + { + userId: myUser.id + }, + { tx } + ); + await userDAL.deleteById(myUser.id, tx); + + if (myOrgMemberships.length) { + await orgMembershipDAL.insertMany( + myOrgMemberships.map((orgMembership) => ({ + ...orgMembership, + userId: mergeUser.id + })), + tx + ); + } + + if (userAliases.length) { + await userAliasDAL.insertMany( + userAliases.map((userAlias) => ({ + ...userAlias, + userId: mergeUser.id + })), + tx + ); + } + + return mergeUser; + }); + + return targetUser; + }; + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const user = await userDAL.findById(userId); @@ -143,6 +219,7 @@ export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUser sendEmailVerificationCode, verifyEmailVerificationCode, listUsersWithSameEmail, + mergeUsers, toggleUserMfa, updateUserName, updateAuthMethods, diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index a8ad89f4cf..27f4d30e46 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,11 +1,12 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useMergeUsers, useSendEmailVerificationCode, - useVerifyEmailVerificationCode -} from "./mutation"; + useVerifyEmailVerificationCode} from "./mutation"; export { fetchOrgUsers, + fetchUsersWithMyEmail, useAddUserToOrg, useCreateAPIKey, useDeleteAPIKey, @@ -19,10 +20,10 @@ export { useGetOrgUsers, useGetUser, useGetUserAction, + useListUsersWithMyEmail, useLogoutUser, useRegisterUserAction, useRevokeMySessions, useUpdateMfaEnabled, useUpdateOrgUserRole, - useUpdateUserAuthMethods -} from "./queries"; + useUpdateUserAuthMethods} from "./queries"; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index e1e9391056..9bebd84cdc 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -7,7 +7,8 @@ import { import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; -import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types"; +import { userKeys } from "./queries"; +import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE, User } from "./types"; export const useAddUserToWsE2EE = () => { const queryClient = useQueryClient(); @@ -72,12 +73,27 @@ export const useSendEmailVerificationCode = () => { }; export const useVerifyEmailVerificationCode = () => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ code }: { code: string }) => { await apiRequest.post("/api/v2/users/me/emails/verify", { code }); return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries(userKeys.usersWithMyEmail); + } + }); +}; + +export const useMergeUsers = () => { + return useMutation({ + mutationFn: async ({ username }: { username: string }) => { + const { data } = await apiRequest.post<{ user: User }>("/api/v2/users/me/users/merge-user", { + username + }); + return data; } }); }; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index a443c67500..9bec3f19ea 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -26,7 +26,8 @@ export const userKeys = { myAPIKeys: ["api-keys"] as const, myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, - myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const + myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const, + usersWithMyEmail: ["users-with-my-email"] as const }; export const fetchUserDetails = async () => { @@ -351,3 +352,20 @@ export const useGetMyOrganizationProjects = (orgId: string) => { enabled: true }); }; + +export const fetchUsersWithMyEmail = async () => { + const { + data: { users } + } = await apiRequest.get<{ users: User[] }>("/api/v2/users/me/users/same-email"); + return users; +}; + +export const useListUsersWithMyEmail = () => { + return useQuery({ + queryKey: userKeys.usersWithMyEmail, + queryFn: async () => { + return fetchUsersWithMyEmail(); + }, + enabled: true + }); +}; diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index d74da3639b..d56a263475 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -1,7 +1,11 @@ import { useState } from "react"; import jwt_decode from "jwt-decode"; -import { BackupPDFStep, EmailConfirmationStep,UserInfoSSOStep } from "./components"; +import { + BackupPDFStep, + EmailConfirmationStep, + MergeUsersStep, + UserInfoSSOStep} from "./components"; type Props = { providerAuthToken: string; @@ -11,7 +15,9 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { const [step, setStep] = useState(0); const [password, setPassword] = useState(""); - const { username, organizationName, firstName, lastName } = jwt_decode(providerAuthToken) as any; + const { username, email, organizationName, firstName, lastName } = jwt_decode( + providerAuthToken + ) as any; const renderView = () => { switch (step) { @@ -19,6 +25,7 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { return ( { /> ); case 1: - return ; + return ; case 2: + return ; + case 3: return ( ); diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx index e9b0a6e84d..c22db1ee0b 100644 --- a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -3,11 +3,19 @@ import { useState } from "react"; import ReactCodeInput from "react-code-input"; -// import Error from "@app/components/basic/Error"; +import Error from "@app/components/basic/Error"; import { createNotification } from "@app/components/notifications"; import { Button } from "@app/components/v2"; import { useUser } from "@app/context"; -import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { + fetchUsersWithMyEmail, + useSendEmailVerificationCode, + useVerifyEmailVerificationCode} from "@app/hooks/api"; + +type Props = { + email: string; + setStep: (step: number) => void; +}; // The style for the verification code input const props = { @@ -47,10 +55,10 @@ const propsPhone = { } } as const; -export const EmailConfirmationStep = () => { +export const EmailConfirmationStep = ({ email, setStep }: Props) => { const { user } = useUser(); const [code, setCode] = useState(""); - // const [codeError, setCodeError] = useState(false); + const [codeError, setCodeError] = useState(false); const [isResendingVerificationEmail] = useState(false); const [isLoading] = useState(false); @@ -59,22 +67,32 @@ export const EmailConfirmationStep = () => { const checkCode = async () => { try { - console.log("checkCode code: ", code); await verifyEmailVerificationCode({ code }); - console.log("checkCode 2"); + setCodeError(false); + + const usersWithSameEmail = await fetchUsersWithMyEmail(); + + if (usersWithSameEmail.length > 1) { + setStep(2); + } + + createNotification({ + text: "Successfully verified code", + type: "success" + }); } catch (err) { createNotification({ text: "Failed to verify code", type: "error" }); } + + setCode(""); }; const resendCode = async () => { try { - console.log("resendCode"); await sendEmailVerificationCode(); - console.log("resendCode"); } catch (err) { createNotification({ text: "Failed to resend code", @@ -86,7 +104,7 @@ export const EmailConfirmationStep = () => { return (

- We've sent a verification code to + We've sent a verification code to {email}

{user?.email} @@ -113,7 +131,7 @@ export const EmailConfirmationStep = () => { className="mt-2 mb-2" />

- {/* {codeError && } */} + {codeError && }
@@ -122,7 +122,7 @@ export const LDAPStep = ({ setStep }: Props) => {
)} diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index d56a263475..09c707629e 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -5,7 +5,8 @@ import { BackupPDFStep, EmailConfirmationStep, MergeUsersStep, - UserInfoSSOStep} from "./components"; + UserInfoSSOStep +} from "./components"; type Props = { providerAuthToken: string; @@ -15,9 +16,8 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { const [step, setStep] = useState(0); const [password, setPassword] = useState(""); - const { username, email, organizationName, firstName, lastName } = jwt_decode( - providerAuthToken - ) as any; + const { username, email, organizationName, organizationSlug, firstName, lastName, authType } = + jwt_decode(providerAuthToken) as any; const renderView = () => { switch (step) { @@ -37,7 +37,13 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { case 1: return ; case 2: - return ; + return ( + + ); case 3: return ( diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx index c22db1ee0b..f3a36c8dcd 100644 --- a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -6,11 +6,11 @@ import ReactCodeInput from "react-code-input"; import Error from "@app/components/basic/Error"; import { createNotification } from "@app/components/notifications"; import { Button } from "@app/components/v2"; -import { useUser } from "@app/context"; import { fetchUsersWithMyEmail, useSendEmailVerificationCode, - useVerifyEmailVerificationCode} from "@app/hooks/api"; + useVerifyEmailVerificationCode +} from "@app/hooks/api"; type Props = { email: string; @@ -56,7 +56,6 @@ const propsPhone = { } as const; export const EmailConfirmationStep = ({ email, setStep }: Props) => { - const { user } = useUser(); const [code, setCode] = useState(""); const [codeError, setCodeError] = useState(false); const [isResendingVerificationEmail] = useState(false); @@ -106,9 +105,6 @@ export const EmailConfirmationStep = ({ email, setStep }: Props) => {

We've sent a verification code to {email}

-

- {user?.email} -

{ +export const MergeUsersStep = ({ username, authType, organizationSlug }: Props) => { const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [targetUsername, setTargetUsername] = useState(""); const { data: users, isLoading: isLoadingUsers } = useListUsersWithMyEmail(); const { mutateAsync: mergeUser, isLoading: isLoadingMerge } = useMergeUsers(); - const handleMergeUser = async (targetUsername: string) => { + const handleMergeUser = async (mergeWithUsername: string) => { try { - console.log("merge A"); - await mergeUser({ username: targetUsername }); - // TODO: logout, make user re-login - console.log("merge B"); + if (!mergeWithUsername) return; + await mergeUser({ username: mergeWithUsername }); createNotification({ text: "Successfully merged user", type: "success" }); - router.push("/login"); + setIsOpen(false); + + switch (authType) { + case UserAliasType.SAML: { + window.open(`/api/v1/sso/redirect/saml2/organizations/${organizationSlug}`); + window.close(); + break; + } + case UserAliasType.LDAP: { + router.push(`/login/ldap?organizationSlug=${organizationSlug}`); + break; + } + default: { + router.push("/login"); + break; + } + } + + setTargetUsername(""); } catch (err) { console.error(err); createNotification({ @@ -72,14 +96,16 @@ export const MergeUsersStep = ({ username }: Props) => { return ( {`${user.firstName ?? ""} ${user.lastName ?? ""}`} - {username} + {user.username} @@ -97,6 +123,35 @@ export const MergeUsersStep = ({ username }: Props) => { + + +

+ The merge operation will transfer / consolidate your existing organization membership to + the target user you're merging with. +

+

+ If the target user is not yet part of the same organization, then they will be added to + it under your current organization membership. Conversely, if the target user is already + part of the organization, then their existing organization membership will remain. +

+

+ Once the merge operation is complete, you'll be prompted to re-login. +

+
+ + +
+
+
); }; diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index d15e57f471..6255ae386e 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -17,6 +17,7 @@ import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, Input } from "@app/components/v2"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; +import { sendEmailVerificationCode } from "@app/hooks/api/users/mutation"; import ProjectService from "@app/services/ProjectService"; // eslint-disable-next-line new-cap @@ -205,10 +206,11 @@ export const UserInfoSSOStep = ({ if (email) { // move to verify email + await sendEmailVerificationCode(); setStep(1); } else { // move to backup PDF step - setStep(2); + setStep(3); } } catch (error) { setIsLoading(false); From 80da2a19aae84202ede1b8ffe32d3f38395a7ec6 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 26 Apr 2024 22:30:07 -0700 Subject: [PATCH 05/14] Add TRUST_SAML_EMAILS and TRUST_LDAP_EMAILS opts --- .../ldap-config/ldap-config-service.ts | 4 +- .../saml-config/saml-config-service.ts | 4 +- backend/src/lib/config/env.ts | 3 + .../src/services/auth/auth-login-service.ts | 3 + .../src/services/auth/auth-signup-service.ts | 5 - docs/self-hosting/configuration/envars.mdx | 316 ++++++++++-------- .../OrgMembersSection/OrgMembersTable.tsx | 2 - frontend/src/views/Signup/SignupSSO.tsx | 14 +- .../UserInfoSSOStep/UserInfoSSOStep.tsx | 12 +- 9 files changed, 204 insertions(+), 159 deletions(-) diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index d6fa72c279..ce74147474 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -437,7 +437,7 @@ export const ldapConfigServiceFactory = ({ { username: uniqueUsername, email: emails[0], - isEmailVerified: false, + isEmailVerified: appCfg.TRUST_LDAP_EMAILS, firstName, lastName, authMethods: [], @@ -557,7 +557,7 @@ export const ldapConfigServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email }), + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, organizationName: organization.name, diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index a4785132b1..4d824613f3 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -374,7 +374,7 @@ export const samlConfigServiceFactory = ({ { username: uniqueUsername, email, - isEmailVerified: false, + isEmailVerified: appCfg.TRUST_SAML_EMAILS, firstName, lastName, authMethods: [], @@ -414,7 +414,7 @@ export const samlConfigServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email }), + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, organizationName: organization.name, diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 4d3d55ffd2..8a365f2847 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -98,6 +98,9 @@ const envSchema = z CLIENT_ID_GITLAB: zpStr(z.string().optional()), CLIENT_SECRET_GITLAB: zpStr(z.string().optional()), URL_GITLAB_URL: zpStr(z.string().optional().default(GITLAB_URL)), + // email verification + TRUST_SAML_EMAILS: zodStrBool.default("false"), + TRUST_LDAP_EMAILS: zodStrBool.default("false"), // SECRET-SCANNING SECRET_SCANNING_WEBHOOK_PROXY: zpStr(z.string().optional()), SECRET_SCANNING_WEBHOOK_SECRET: zpStr(z.string().optional()), diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 5d81eaae14..4d2a302c60 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -361,6 +361,7 @@ export const authLoginServiceFactory = ({ user = await userDAL.create({ username: email, email, + isEmailVerified: true, firstName, lastName, authMethods: [authMethod], @@ -374,6 +375,8 @@ export const authLoginServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + email: user.email, + isEmailVerified: user.isEmailVerified, firstName: user.firstName, lastName: user.lastName, authMethod, diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 86693df8d0..31ca5552fa 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -135,11 +135,6 @@ export const authSignupServiceFactory = ({ userAgent, authorization }: TCompleteAccountSignupDTO) => { - console.log("completeEmailAccountSignup args: ", { - email, - firstName, - lastName - }); const user = await userDAL.findOne({ username: email }); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 4c1456d3b5..e9f4431a4e 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -3,30 +3,34 @@ title: "Configurations" description: "Read how to configure environment variables for self-hosted Infisical." --- - -Infisical accepts all configurations via environment variables. For a minimal self-hosted instance, at least `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI` and `REDIS_URL` must be defined. +Infisical accepts all configurations via environment variables. For a minimal self-hosted instance, at least `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI` and `REDIS_URL` must be defined. However, you can configure additional settings to activate more features as needed. -## General platform +## General platform + Used to configure platform-specific security and operational settings - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` + Must be a random 16 byte hex string. Can be generated with `openssl rand -hex + 16` - Must be a random 32 byte base64 string. Can be generated with `openssl rand -base64 32` + Must be a random 32 byte base64 string. Can be generated with `openssl rand + -base64 32` - Must be an absolute URL including the protocol (e.g. https://app.infisical.com). + Must be an absolute URL including the protocol (e.g. + https://app.infisical.com). -## Data Layer +## Data Layer + The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks - Postgres database connection string. + Postgres database connection string. @@ -39,9 +43,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching Redis connection string. - - ## Email service + Without email configuration, Infisical's core functions like sign-up/login and secret operations work, but this disables multi-factor authentication, email invites for projects, alerts for suspicious logins, and all other email-dependent features. @@ -49,25 +52,36 @@ Without email configuration, Infisical's core functions like sign-up/login and s Hostname to connect to for establishing SMTP connections - - Credential to connect to host (e.g. team@infisical.com) - +{" "} - - Credential to connect to host - + + Credential to connect to host (e.g. team@infisical.com) + - - Port to connect to for establishing SMTP connections - +{" "} - - If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported - + + Credential to connect to host + - - Email address to be used for sending emails - +{" "} + + + Port to connect to for establishing SMTP connections + + +{" "} + + + If true, use TLS when connecting to host. If false, TLS will be used if + STARTTLS is supported + + +{" "} + + + Email address to be used for sending emails + Name label to be used in From field (e.g. Team) @@ -76,25 +90,25 @@ Without email configuration, Infisical's core functions like sign-up/login and s - 1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. - 2. Create a SendGrid API Key under Settings > [API Keys](https://app.sendgrid.com/settings/api_keys) - 3. Set a name for your API Key, we recommend using "Infisical," and select the "Restricted Key" option. You will need to enable the "Mail Send" permission as shown below: +1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. +2. Create a SendGrid API Key under Settings > [API Keys](https://app.sendgrid.com/settings/api_keys) +3. Set a name for your API Key, we recommend using "Infisical," and select the "Restricted Key" option. You will need to enable the "Mail Send" permission as shown below: - ![creating sendgrid api key](../../images/self-hosting/configuration/email/email-sendgrid-create-key.png) +![creating sendgrid api key](../../images/self-hosting/configuration/email/email-sendgrid-create-key.png) - ![setting sendgrid api key restriction](../../images/self-hosting/configuration/email/email-sendgrid-restrictions.png) +![setting sendgrid api key restriction](../../images/self-hosting/configuration/email/email-sendgrid-restrictions.png) - 4. With the API Key, you can now set your SMTP environment variables: +4. With the API Key, you can now set your SMTP environment variables: - ``` - SMTP_HOST=smtp.sendgrid.net - SMTP_USERNAME=apikey - SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.sendgrid.net +SMTP_USERNAME=apikey +SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` Remember that you will need to restart Infisical for this to work properly. @@ -105,19 +119,20 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [Mailgun](https://www.mailgun.com) to send emails. 2. Obtain your Mailgun credentials in Sending > Overview > SMTP - ![obtain mailhog api key estriction](../../images/self-hosting/configuration/email/email-mailhog-credentials.png) +![obtain mailhog api key estriction](../../images/self-hosting/configuration/email/email-mailhog-credentials.png) - 3. With your Mailgun credentials, you can now set up your SMTP environment variables: +3. With your Mailgun credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.mailgun.org # obtained from credentials page +SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page +SMTP_PASSWORD=password # obtained from credentials page +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.mailgun.org # obtained from credentials page - SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page - SMTP_PASSWORD=password # obtained from credentials page - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` @@ -149,6 +164,7 @@ Without email configuration, Infisical's core functions like sign-up/login and s SMTP_FROM_NAME=Infisical ``` + @@ -160,30 +176,32 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [SocketLabs](https://www.socketlabs.com/) to send emails. 2. From the dashboard, navigate to SMTP Credentials > SMTP & APIs > SMTP Credentials to obtain your SocketLabs SMTP credentials. - ![opening SocketLabs dashboard](../../images/self-hosting/configuration/email/email-socketlabs-dashboard.png) +![opening SocketLabs dashboard](../../images/self-hosting/configuration/email/email-socketlabs-dashboard.png) - ![obtaining SocketLabs credentials](../../images/self-hosting/configuration/email/email-socketlabs-credentials.png) +![obtaining SocketLabs credentials](../../images/self-hosting/configuration/email/email-socketlabs-credentials.png) - 3. With your SocketLabs SMTP credentials, you can now set up your SMTP environment variables: +3. With your SocketLabs SMTP credentials, you can now set up your SMTP environment variables: - ``` - SMTP_HOST=smtp.socketlabs.com - SMTP_USERNAME=username # obtained from your credentials - SMTP_PASSWORD=password # obtained from your credentials - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.socketlabs.com +SMTP_USERNAME=username # obtained from your credentials +SMTP_PASSWORD=password # obtained from your credentials +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - - The `SMTP_FROM_ADDRESS` environment variable should be an email for an - authenticated domain under Configuration > Domain Management in SocketLabs. - For example, if you're using SocketLabs in sandbox mode, then you may use an - email like `team@sandbox.socketlabs.dev`. - +{" "} - ![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) + + The `SMTP_FROM_ADDRESS` environment variable should be an email for an + authenticated domain under Configuration > Domain Management in SocketLabs. + For example, if you're using SocketLabs in sandbox mode, then you may use an + email like `team@sandbox.socketlabs.dev`. + + +![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) Remember that you will need to restart Infisical for this to work properly. @@ -194,55 +212,57 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account on [Resend](https://resend.com). 2. Add a [Domain](https://resend.com/domains). - ![adding resend domain](../../images/self-hosting/configuration/email/email-resend-create-domain.png) +![adding resend domain](../../images/self-hosting/configuration/email/email-resend-create-domain.png) - 3. Create an [API Key](https://resend.com/api-keys). +3. Create an [API Key](https://resend.com/api-keys). - ![creating resend api key](../../images/self-hosting/configuration/email/email-resend-create-key.png) +![creating resend api key](../../images/self-hosting/configuration/email/email-resend-create-key.png) - 4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. +4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. - ![go to resend smtp settings](../../images/self-hosting/configuration/email/email-resend-smtp-settings.png) +![go to resend smtp settings](../../images/self-hosting/configuration/email/email-resend-smtp-settings.png) - 5. With the API Key, you can now set your SMTP environment variables variables: +5. With the API Key, you can now set your SMTP environment variables variables: + +``` +SMTP_HOST=smtp.resend.com +SMTP_USERNAME=resend +SMTP_PASSWORD=YOUR_API_KEY +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.resend.com - SMTP_USERNAME=resend - SMTP_PASSWORD=YOUR_API_KEY - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` Remember that you will need to restart Infisical for this to work properly. + Create an account and enable "less secure app access" in Gmail Account Settings > Security. This will allow applications like Infisical to authenticate with Gmail via your username and password. - ![Gmail secure app access](../../images/self-hosting/configuration/email/email-gmail-app-access.png) +![Gmail secure app access](../../images/self-hosting/configuration/email/email-gmail-app-access.png) - With your Gmail username and password, you can set your SMTP environment variables: +With your Gmail username and password, you can set your SMTP environment variables: - ``` - SMTP_HOST=smtp.gmail.com - SMTP_USERNAME=hey@gmail.com # your email - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@gmail.com - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.gmail.com +SMTP_USERNAME=hey@gmail.com # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@gmail.com +SMTP_FROM_NAME=Infisical +``` As per the [notice](https://support.google.com/accounts/answer/6010255?hl=en) by Google, you should note that using Gmail credentials for SMTP configuration will only work for Google Workspace or Google Cloud Identity customers as of May 30, 2022. - Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. +Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. @@ -250,51 +270,51 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [Office365](https://www.office.com/) to send emails. - 2. With your login credentials, you can now set up your SMTP environment variables: +2. With your login credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.office365.com +SMTP_USERNAME=username@yourdomain.com # your username +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=username@yourdomain.com +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.office365.com - SMTP_USERNAME=username@yourdomain.com # your username - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=username@yourdomain.com - SMTP_FROM_NAME=Infisical - ``` 1. Create an account and configure [Zoho Mail](https://www.zoho.com/mail/) to send emails. - 2. With your email credentials, you can now set up your SMTP environment variables: +2. With your email credentials, you can now set up your SMTP environment variables: - ``` - SMTP_HOST=smtp.zoho.com - SMTP_USERNAME=username # your email - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.zoho.com +SMTP_USERNAME=username # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail +SMTP_FROM_NAME=Infisical +``` - - You can use either your personal Zoho email address like `you@zohomail.com` or - a domain-based email address like `you@yourdomain.com`. If using a - domain-based email address, then please make sure that you've configured and - verified it with Zoho Mail. - +{" "} + + + You can use either your personal Zoho email address like `you@zohomail.com` or + a domain-based email address like `you@yourdomain.com`. If using a + domain-based email address, then please make sure that you've configured and + verified it with Zoho Mail. + Remember that you will need to restart Infisical for this to work properly. +## Authentication - - - -## SSO based login By default, users can only login via email/password based login method. To login into Infisical with OAuth providers such as Google, configure the associated variables. @@ -335,33 +355,49 @@ To login into Infisical with OAuth providers such as Google, configure the assoc - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - - Configure SAML organization slug to automatically redirect all users of your Infisical instance to the identity provider. + + Whether or not to trust emails from external SAML identity providers. If set + to `false` then users will be prompted to verify their email address upon + first login. + + + Whether or not to trust emails from external LDAP servers. If set to `false` + then users will be prompted to verify their email address upon first login. - - - + + Configure SAML organization slug to automatically redirect all users of your + Infisical instance to the identity provider. + ## Native secret integrations + To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. OAuth2 client ID for Heroku integration - + OAuth2 client secret for Heroku integration @@ -371,9 +407,11 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I OAuth2 client ID for Vercel integration - - OAuth2 client secret for Vercel integration - +{" "} + + + OAuth2 client secret for Vercel integration + OAuth2 slug for Vercel integration diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 7941687293..a14e09a67e 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -149,8 +149,6 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop [members, searchMemberFilter] ); - console.log("filterdUser: ", filterdUser); - return (
{ const [step, setStep] = useState(0); const [password, setPassword] = useState(""); - const { username, email, organizationName, organizationSlug, firstName, lastName, authType } = - jwt_decode(providerAuthToken) as any; + const { + username, + email, + organizationName, + organizationSlug, + firstName, + lastName, + authType, + isEmailVerified + } = jwt_decode(providerAuthToken) as any; const renderView = () => { switch (step) { @@ -25,7 +33,7 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { return ( void; username: string; - email?: string; + isEmailVerified?: boolean; password: string; setPassword: (value: string) => void; name: string; @@ -60,7 +60,7 @@ type Errors = { */ export const UserInfoSSOStep = ({ username, - email, + isEmailVerified, name, providerOrganizationName, password, @@ -204,13 +204,13 @@ export const UserInfoSSOStep = ({ localStorage.setItem("orgData.id", orgId); localStorage.setItem("projectData.id", project.id); - if (email) { + if (isEmailVerified) { + // move to backup PDF step + setStep(3); + } else { // move to verify email await sendEmailVerificationCode(); setStep(1); - } else { - // move to backup PDF step - setStep(3); } } catch (error) { setIsLoading(false); From a7af3a48d91f2d0f1b1bf8fce6b99d3ff4ef2aea Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 28 Apr 2024 19:09:12 -0700 Subject: [PATCH 06/14] Continue moving SCIM userId refs to orgMembershipId --- .infisicalignore | 3 +- backend/src/ee/routes/v1/scim-router.ts | 32 ++- .../ldap-config/ldap-config-service.ts | 8 +- .../services/license/__mocks__/licence-fns.ts | 4 +- .../src/ee/services/license/licence-fns.ts | 8 +- .../src/ee/services/license/license-types.ts | 4 +- .../saml-config/saml-config-service.ts | 8 +- backend/src/ee/services/scim/scim-fns.ts | 12 +- backend/src/ee/services/scim/scim-service.ts | 246 +++++++++++------- backend/src/ee/services/scim/scim-types.ts | 8 +- backend/src/server/routes/index.ts | 2 + backend/src/services/org/org-fns.ts | 2 + .../services/user-alias/user-alias-types.ts | 2 +- 13 files changed, 205 insertions(+), 134 deletions(-) diff --git a/.infisicalignore b/.infisicalignore index 348f9e3277..d5cc9f15df 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -2,4 +2,5 @@ frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx:generic-api-key:206 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:304 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206 -frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 \ No newline at end of file +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 +docs/self-hosting/configuration/envars.mdx:generic-api-key:106 diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index dea0e3d70a..ed2ce92db9 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -152,8 +152,9 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log("GET /Users req.query: ", req.query); const users = await req.server.services.scim.listScimUsers({ - offset: req.query.startIndex, + startIndex: req.query.startIndex, limit: req.query.count, filter: req.query.filter, orgId: req.permission.orgId @@ -163,11 +164,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "GET", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), response: { 201: z.object({ @@ -192,8 +193,9 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log(`GET /Users/${req.params.orgMembershipId}`); const user = await req.server.services.scim.getScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId }); return user; @@ -246,6 +248,8 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log("POST /Users req.body: ", req.body); + const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; const user = await req.server.services.scim.createScimUser({ @@ -261,11 +265,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "DELETE", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), response: { 200: z.object({}) @@ -273,8 +277,9 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log(`DELETE /Users/${req.params.orgMembershipId}`); const user = await req.server.services.scim.deleteScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId }); @@ -319,6 +324,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log("POST /Groups req.body: ", req.body); const group = await req.server.services.scim.createScimGroup({ orgId: req.permission.orgId, ...req.body @@ -359,6 +365,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log("GET /Groups req.query: ", req.query); const groups = await req.server.services.scim.listScimGroups({ orgId: req.permission.orgId, offset: req.query.startIndex, @@ -395,6 +402,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log(`GET /Groups/${req.params.groupId}`); const group = await req.server.services.scim.getScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId @@ -440,6 +448,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log(`PUT /Groups/${req.params.groupId} req.body: `, req.body); const group = await req.server.services.scim.updateScimGroupNamePut({ groupId: req.params.groupId, orgId: req.permission.orgId, @@ -501,6 +510,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log(`PATCH /Groups/${req.params.groupId} req.body: `, req.body); const group = await req.server.services.scim.updateScimGroupNamePatch({ groupId: req.params.groupId, orgId: req.permission.orgId, @@ -524,6 +534,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log(`DELETE /Groups/${req.params.groupId}`); const group = await req.server.services.scim.deleteScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId @@ -534,11 +545,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "PUT", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), body: z.object({ schemas: z.array(z.string()), @@ -574,8 +585,9 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { + console.log(`PUT /Users/${req.params.orgMembershipId} req.body: `, req.body); const user = await req.server.services.scim.replaceScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId, active: req.body.active }); diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index ce74147474..f712a6ea40 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -31,7 +31,7 @@ import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; -import { TUserAliasType } from "@app/services/user-alias/user-alias-types"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -395,7 +395,7 @@ export const ldapConfigServiceFactory = ({ let userAlias = await userAliasDAL.findOne({ externalId, orgId, - aliasType: TUserAliasType.LDAP + aliasType: UserAliasType.LDAP }); const organization = await orgDAL.findOrgById(orgId); @@ -449,7 +449,7 @@ export const ldapConfigServiceFactory = ({ { userId: newUser.id, username, - aliasType: TUserAliasType.LDAP, + aliasType: UserAliasType.LDAP, externalId, emails, orgId @@ -564,7 +564,7 @@ export const ldapConfigServiceFactory = ({ organizationId: organization.id, organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, - authType: TUserAliasType.LDAP, + authType: UserAliasType.LDAP, isUserCompleted, ...(relayState ? { diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index 20186718d3..b5cbf103ee 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -17,8 +17,8 @@ export const getDefaultOnPremFeatures = () => { customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, - samlSSO: true, - scim: true, + samlSSO: false, + scim: false, ldap: false, groups: false, status: null, diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 5046ad125b..8a4de57f1e 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -24,10 +24,10 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, - samlSSO: true, - scim: true, - ldap: true, - groups: true, + samlSSO: false, + scim: false, + ldap: false, + groups: false, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 2cc321373a..1cea39a834 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -40,8 +40,8 @@ export type TFeatureSet = { customAlerts: false; auditLogs: false; auditLogsRetentionDays: 0; - samlSSO: true; - scim: true; + samlSSO: false; + scim: false; ldap: false; groups: false; status: null; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 4d824613f3..2a7a6a60a9 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -27,7 +27,7 @@ import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membe import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; -import { TUserAliasType } from "@app/services/user-alias/user-alias-types"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -326,7 +326,7 @@ export const samlConfigServiceFactory = ({ const userAlias = await userAliasDAL.findOne({ externalId, orgId, - aliasType: TUserAliasType.SAML + aliasType: UserAliasType.SAML }); const organization = await orgDAL.findOrgById(orgId); @@ -385,7 +385,7 @@ export const samlConfigServiceFactory = ({ await userAliasDAL.create( { userId: newUser.id, - aliasType: TUserAliasType.SAML, + aliasType: UserAliasType.SAML, externalId, emails: email ? [email] : [], orgId @@ -421,7 +421,7 @@ export const samlConfigServiceFactory = ({ organizationId: organization.id, organizationSlug: organization.slug, authMethod: authProvider, - authType: TUserAliasType.SAML, + authType: UserAliasType.SAML, isUserCompleted, ...(relayState ? { diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index e816cffcf5..8668ed5e1d 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -2,31 +2,31 @@ import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-t export const buildScimUserList = ({ scimUsers, - offset, + startIndex, limit }: { scimUsers: TScimUser[]; - offset: number; + startIndex: number; limit: number; }): TListScimUsers => { return { Resources: scimUsers, itemsPerPage: limit, schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - startIndex: offset, + startIndex, totalResults: scimUsers.length }; }; export const buildScimUser = ({ - userId, + orgMembershipId, username, email, firstName, lastName, active }: { - userId: string; + orgMembershipId: string; username: string; email?: string | null; firstName: string; @@ -35,7 +35,7 @@ export const buildScimUser = ({ }): TScimUser => { const scimUser = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], - id: userId, + id: orgMembershipId, userName: username, displayName: `${firstName} ${lastName}`, name: { diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 605003e4f4..6700e7f054 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups, TOrgMemberships, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -11,16 +11,20 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TOrgPermission } from "@app/lib/types"; -import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { deleteOrgMembership } from "@app/services/org/org-fns"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -47,11 +51,16 @@ import { type TScimServiceFactoryDep = { scimDAL: Pick; - userDAL: Pick; + userDAL: Pick< + TUserDALFactory, + "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" + >; + userAliasDAL: TUserAliasDALFactory; // TODO: pick orgDAL: Pick< TOrgDALFactory, - "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" + "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" >; + orgMembershipDAL: TOrgMembershipDALFactory; // TODO: Pick projectDAL: Pick; projectMembershipDAL: Pick; groupDAL: Pick< @@ -64,7 +73,7 @@ type TScimServiceFactoryDep = { projectBotDAL: Pick; licenseService: Pick; permissionService: Pick; - smtpService: TSmtpService; + smtpService: Pick; }; export type TScimServiceFactory = ReturnType; @@ -75,7 +84,9 @@ export const scimServiceFactory = ({ licenseService, scimDAL, userDAL, + userAliasDAL, orgDAL, + orgMembershipDAL, projectDAL, projectMembershipDAL, groupDAL, @@ -162,8 +173,13 @@ export const scimServiceFactory = ({ }; // SCIM server endpoints - const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise => { - console.log("listScimUsers"); // done + const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise => { + console.log("listScimUsers args: ", { + startIndex, + limit, + filter, + orgId + }); // done const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -181,11 +197,11 @@ export const scimServiceFactory = ({ attributeName = "email"; } - return { [attributeName]: parsedValue }; + return { [attributeName]: parsedValue.replace(/"/g, "") }; }; const findOpts = { - ...(offset && { offset }), + ...(startIndex && { offset: startIndex - 1 }), ...(limit && { limit }) }; @@ -197,11 +213,9 @@ export const scimServiceFactory = ({ findOpts ); - console.log("orgDAL.findMembership users: ", users); - const scimUsers = users.map(({ id, username, firstName, lastName, email }) => buildScimUser({ - userId: id ?? "", + orgMembershipId: id ?? "", username, firstName: firstName ?? "", lastName: lastName ?? "", @@ -212,17 +226,20 @@ export const scimServiceFactory = ({ return buildScimUserList({ scimUsers, - offset, + startIndex, limit }); }; - const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { - console.log("getScimUser"); // done + const getScimUser = async ({ orgMembershipId, orgId }: TGetScimUserDTO) => { + console.log("getScimUser args: ", { + orgMembershipId, + orgId + }); // done const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -246,7 +263,7 @@ export const scimServiceFactory = ({ console.log("getScimUser membership: ", membership); return buildScimUser({ - userId: membership.id, + orgMembershipId: membership.id, username: membership.username, email: membership.email ?? "", firstName: membership.firstName as string, @@ -256,7 +273,14 @@ export const scimServiceFactory = ({ }; const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { - console.log("createScimUser"); // TODO: update implementation to always create a new user and be based on orgMembershipId + // do we get external ID or not? + console.log("createScimUser args: ", { + username, + email, + firstName, + lastName, + orgId + }); const org = await orgDAL.findById(orgId); if (!org) @@ -271,67 +295,85 @@ export const scimServiceFactory = ({ status: 403 }); - let user = await userDAL.findOne({ - username + const appCfg = getConfig(); + + const userAlias = await userAliasDAL.findOne({ + externalId: username, + orgId, + aliasType: UserAliasType.SAML }); - if (user) { - await userDAL.transaction(async (tx) => { - const [orgMembership] = await orgDAL.findMembership( + const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { + let user: TUsers; + let orgMembership: TOrgMemberships; + if (userAlias) { + user = await userDAL.findById(userAlias.userId, tx); + orgMembership = await orgMembershipDAL.findOne( { userId: user.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId - }, - { tx } - ); - if (orgMembership) - throw new ScimRequestError({ - detail: "User already exists in the database", - status: 409 - }); - - if (!orgMembership) { - await orgDAL.createMembership( - { - userId: user.id, - orgId, - inviteEmail: email, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited - }, - tx - ); - } - }); - } else { - user = await userDAL.transaction(async (tx) => { - const newUser = await userDAL.create( - { - username, - email, - firstName, - lastName, - authMethods: [AuthMethod.EMAIL], - isGhost: false + orgId }, tx ); - await orgDAL.createMembership( + if (!orgMembership) { + orgMembership = await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgMembershipDAL.updateById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + } else { + const uniqueUsername = await normalizeUsername(username, userDAL); + user = await userDAL.create( { + username: uniqueUsername, + email, + isEmailVerified: appCfg.TRUST_SAML_EMAILS, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + await userAliasDAL.create( + { + userId: user.id, + aliasType: UserAliasType.SAML, + externalId: username, + emails: email ? [email] : [], + orgId + }, + tx + ); + orgMembership = await orgMembershipDAL.create( + { + userId: user.id, inviteEmail: email, orgId, - userId: newUser.id, role: OrgMembershipRole.Member, status: OrgMembershipStatus.Invited }, tx ); - return newUser; - }); - } + } - const appCfg = getConfig(); + return { user, orgMembership }; + }); if (email) { await smtpService.sendMail({ @@ -346,11 +388,11 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: user.id, - username: user.username, - firstName: user.firstName as string, - lastName: user.lastName as string, - email: user.email ?? "", + orgMembershipId: createdOrgMembership.id, + username: createdUser.username, + firstName: createdUser.firstName as string, + lastName: createdUser.lastName as string, + email: createdUser.email ?? "", active: true }); }; @@ -406,7 +448,7 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: membership.id, + orgMembershipId: membership.id, username: membership.username, email: membership.email, firstName: membership.firstName as string, @@ -415,12 +457,16 @@ export const scimServiceFactory = ({ }); }; - const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => { - console.log("replaceScimUser"); // done + const replaceScimUser = async ({ orgMembershipId, active, orgId }: TReplaceScimUserDTO) => { + console.log("replaceScimUser args: ", { + orgMembershipId, + orgId, + active + }); // done const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -453,7 +499,7 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: membership.id, + orgMembershipId: membership.id, username: membership.username, email: membership.email, firstName: membership.firstName as string, @@ -462,19 +508,15 @@ export const scimServiceFactory = ({ }); }; - const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { - console.log("deleteScimUser"); // done - const [membership] = await orgDAL - .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId - }) - .catch(() => { - throw new ScimRequestError({ - detail: "User not found", - status: 404 - }); - }); + const deleteScimUser = async ({ orgMembershipId, orgId }: TDeleteScimUserDTO) => { + console.log("deleteScimUser args: ", { + orgMembershipId, + orgId + }); // done + const [membership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + }); if (!membership) throw new ScimRequestError({ @@ -593,12 +635,19 @@ export const scimServiceFactory = ({ return { group, newMembers: [] }; }); + const orgMemberships = await orgDAL.findMembership({ + orgId, + $in: { + userId: newGroup.newMembers.map((member) => member.id) + } + }); + return buildScimGroup({ groupId: newGroup.group.id, name: newGroup.group.name, - members: newGroup.newMembers.map((member) => ({ - value: member.id, - display: `${member.firstName} ${member.lastName}` + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` })) }); }; @@ -627,15 +676,20 @@ export const scimServiceFactory = ({ groupId: group.id }); + const orgMemberships = await orgDAL.findMembership({ + orgId, + $in: { + userId: users.filter((user) => user.isPartOfGroup).map((user) => user.id) + } + }); + return buildScimGroup({ groupId: group.id, name: group.name, - members: users - .filter((user) => user.isPartOfGroup) - .map((user) => ({ - value: user.id, - display: `${user.firstName} ${user.lastName}` - })) + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` + })) }); }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 73d0ebe786..a82f086561 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -12,7 +12,7 @@ export type TDeleteScimTokenDTO = { // SCIM server endpoint types export type TListScimUsersDTO = { - offset: number; + startIndex: number; limit: number; filter?: string; orgId: string; @@ -27,7 +27,7 @@ export type TListScimUsers = { }; export type TGetScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; }; @@ -54,13 +54,13 @@ export type TUpdateScimUserDTO = { }; export type TReplaceScimUserDTO = { - userId: string; + orgMembershipId: string; active: boolean; orgId: string; }; export type TDeleteScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 299319e411..b944eb37e5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -292,7 +292,9 @@ export const registerRoutes = async ( licenseService, scimDAL, userDAL, + userAliasDAL, orgDAL, + orgMembershipDAL, projectDAL, projectMembershipDAL, groupDAL, diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index ec6d4cb2d0..69eac5e077 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -34,6 +34,8 @@ export const deleteOrgMembership = async ({ tx ); + // TODO: delete associated aliases + return orgMembership; }); diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts index 6188732c6b..09204644f4 100644 --- a/backend/src/services/user-alias/user-alias-types.ts +++ b/backend/src/services/user-alias/user-alias-types.ts @@ -1,4 +1,4 @@ -export enum TUserAliasType { +export enum UserAliasType { LDAP = "ldap", SAML = "saml" } From b2a976f3d4aa49129cccee3800fe90cba5265a6b Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 28 Apr 2024 21:58:24 -0700 Subject: [PATCH 07/14] Update groups CRUD SCIM to use orgMembershipId --- backend/src/ee/routes/v1/scim-router.ts | 18 +--- .../saml-config/saml-config-service.ts | 2 +- backend/src/ee/services/scim/scim-fns.ts | 6 +- backend/src/ee/services/scim/scim-service.ts | 98 +++++++++---------- backend/src/ee/services/scim/scim-types.ts | 2 +- backend/src/server/routes/index.ts | 1 + backend/src/services/org/org-fns.ts | 69 +++++++++---- backend/src/services/org/org-service.ts | 53 +++------- backend/src/services/user/user-fns.ts | 2 +- 9 files changed, 120 insertions(+), 131 deletions(-) diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index ed2ce92db9..7283b8fe1a 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -152,7 +152,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log("GET /Users req.query: ", req.query); const users = await req.server.services.scim.listScimUsers({ startIndex: req.query.startIndex, limit: req.query.count, @@ -193,7 +192,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log(`GET /Users/${req.params.orgMembershipId}`); const user = await req.server.services.scim.getScimUser({ orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId @@ -248,8 +246,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log("POST /Users req.body: ", req.body); - const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; const user = await req.server.services.scim.createScimUser({ @@ -277,7 +273,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log(`DELETE /Users/${req.params.orgMembershipId}`); const user = await req.server.services.scim.deleteScimUser({ orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId @@ -324,7 +319,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log("POST /Groups req.body: ", req.body); const group = await req.server.services.scim.createScimGroup({ orgId: req.permission.orgId, ...req.body @@ -365,10 +359,9 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log("GET /Groups req.query: ", req.query); const groups = await req.server.services.scim.listScimGroups({ orgId: req.permission.orgId, - offset: req.query.startIndex, + startIndex: req.query.startIndex, limit: req.query.count }); @@ -402,7 +395,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log(`GET /Groups/${req.params.groupId}`); const group = await req.server.services.scim.getScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId @@ -424,10 +416,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { displayName: z.string().trim(), members: z.array( z.object({ - value: z.string(), // infisical userId + value: z.string(), // infisical orgMembershipId display: z.string() }) - ) // note: is this where members are added to group? + ) }), response: { 200: z.object({ @@ -448,7 +440,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log(`PUT /Groups/${req.params.groupId} req.body: `, req.body); const group = await req.server.services.scim.updateScimGroupNamePut({ groupId: req.params.groupId, orgId: req.permission.orgId, @@ -510,7 +501,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log(`PATCH /Groups/${req.params.groupId} req.body: `, req.body); const group = await req.server.services.scim.updateScimGroupNamePatch({ groupId: req.params.groupId, orgId: req.permission.orgId, @@ -534,7 +524,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log(`DELETE /Groups/${req.params.groupId}`); const group = await req.server.services.scim.deleteScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId @@ -585,7 +574,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - console.log(`PUT /Users/${req.params.orgMembershipId} req.body: `, req.body); const user = await req.server.services.scim.replaceScimUser({ orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId, diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 2a7a6a60a9..1dddffa29c 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -369,7 +369,7 @@ export const samlConfigServiceFactory = ({ }); } else { user = await userDAL.transaction(async (tx) => { - const uniqueUsername = await normalizeUsername(externalId, userDAL); + const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); const newUser = await userDAL.create( { username: uniqueUsername, diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index 8668ed5e1d..ec54a4d1fc 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -65,18 +65,18 @@ export const buildScimUser = ({ export const buildScimGroupList = ({ scimGroups, - offset, + startIndex, limit }: { scimGroups: TScimGroup[]; - offset: number; + startIndex: number; limit: number; }): TListScimGroups => { return { Resources: scimGroups, itemsPerPage: limit, schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - startIndex: offset, + startIndex, totalResults: scimGroups.length }; }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 6700e7f054..a9a78c8865 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -14,7 +14,7 @@ import { TOrgPermission } from "@app/lib/types"; import { AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { deleteOrgMembership } from "@app/services/org/org-fns"; +import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -62,7 +62,7 @@ type TScimServiceFactoryDep = { >; orgMembershipDAL: TOrgMembershipDALFactory; // TODO: Pick projectDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; groupDAL: Pick< TGroupDALFactory, "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction" @@ -71,7 +71,7 @@ type TScimServiceFactoryDep = { userGroupMembershipDAL: TUserGroupMembershipDALFactory; // TODO: Pick projectKeyDAL: Pick; projectBotDAL: Pick; - licenseService: Pick; + licenseService: Pick; permissionService: Pick; smtpService: Pick; }; @@ -174,12 +174,6 @@ export const scimServiceFactory = ({ // SCIM server endpoints const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise => { - console.log("listScimUsers args: ", { - startIndex, - limit, - filter, - orgId - }); // done const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -232,10 +226,6 @@ export const scimServiceFactory = ({ }; const getScimUser = async ({ orgMembershipId, orgId }: TGetScimUserDTO) => { - console.log("getScimUser args: ", { - orgMembershipId, - orgId - }); // done const [membership] = await orgDAL .findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, @@ -260,8 +250,6 @@ export const scimServiceFactory = ({ status: 403 }); - console.log("getScimUser membership: ", membership); - return buildScimUser({ orgMembershipId: membership.id, username: membership.username, @@ -273,14 +261,6 @@ export const scimServiceFactory = ({ }; const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { - // do we get external ID or not? - console.log("createScimUser args: ", { - username, - email, - firstName, - lastName, - orgId - }); const org = await orgDAL.findById(orgId); if (!org) @@ -337,7 +317,7 @@ export const scimServiceFactory = ({ ); } } else { - const uniqueUsername = await normalizeUsername(username, userDAL); + const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); user = await userDAL.create( { username: uniqueUsername, @@ -398,7 +378,6 @@ export const scimServiceFactory = ({ }; const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { - console.log("updateScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -438,12 +417,14 @@ export const scimServiceFactory = ({ }); if (!active) { - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); } @@ -458,11 +439,6 @@ export const scimServiceFactory = ({ }; const replaceScimUser = async ({ orgMembershipId, active, orgId }: TReplaceScimUserDTO) => { - console.log("replaceScimUser args: ", { - orgMembershipId, - orgId, - active - }); // done const [membership] = await orgDAL .findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, @@ -489,12 +465,14 @@ export const scimServiceFactory = ({ if (!active) { // tx - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); } @@ -509,10 +487,6 @@ export const scimServiceFactory = ({ }; const deleteScimUser = async ({ orgMembershipId, orgId }: TDeleteScimUserDTO) => { - console.log("deleteScimUser args: ", { - orgMembershipId, - orgId - }); // done const [membership] = await orgDAL.findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId @@ -531,18 +505,20 @@ export const scimServiceFactory = ({ }); } - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); return {}; // intentionally return empty object upon success }; - const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => { + const listScimGroups = async ({ orgId, startIndex, limit }: TListScimGroupsDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -563,9 +539,15 @@ export const scimServiceFactory = ({ status: 403 }); - const groups = await groupDAL.findGroups({ - orgId - }); + const groups = await groupDAL.findGroups( + { + orgId + }, + { + offset: startIndex - 1, + limit + } + ); const scimGroups = groups.map((group) => buildScimGroup({ @@ -577,7 +559,7 @@ export const scimServiceFactory = ({ return buildScimGroupList({ scimGroups, - offset, + startIndex, limit }); }; @@ -616,9 +598,15 @@ export const scimServiceFactory = ({ ); if (members && members.length) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + const newMembers = await addUsersToGroupByUserIds({ group, - userIds: members.map((member) => member.value), + userIds: orgMemberships.map((membership) => membership.userId as string), userDAL, userGroupMembershipDAL, orgDAL, @@ -733,7 +721,13 @@ export const scimServiceFactory = ({ } if (members) { - const membersIdsSet = new Set(members.map((member) => member.value)); + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + + const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); const directMemberUserIds = ( await userGroupMembershipDAL.find({ @@ -752,13 +746,13 @@ export const scimServiceFactory = ({ const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); const allMembersUserIdsSet = new Set(allMembersUserIds); - const toAddUserIds = members.filter((member) => !allMembersUserIdsSet.has(member.value)); + const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); if (toAddUserIds.length) { await addUsersToGroupByUserIds({ group, - userIds: toAddUserIds.map((member) => member.value), + userIds: toAddUserIds.map((member) => member.userId as string), userDAL, userGroupMembershipDAL, orgDAL, diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index a82f086561..e804d51a54 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -65,7 +65,7 @@ export type TDeleteScimUserDTO = { }; export type TListScimGroupsDTO = { - offset: number; + startIndex: number; limit: number; orgId: string; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b944eb37e5..31a7777164 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -349,6 +349,7 @@ export const registerRoutes = async ( userDAL }); const orgService = orgServiceFactory({ + userAliasDAL, licenseService, samlConfigDAL, orgRoleDAL, diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index 69eac5e077..a63ffabee8 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,43 +1,78 @@ +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; type TDeleteOrgMembership = { orgMembershipId: string; orgId: string; orgDAL: Pick; - projectDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + userAliasDAL: Pick; + licenseService: Pick; }; -export const deleteOrgMembership = async ({ +export const deleteOrgMembershipFn = async ({ orgMembershipId, orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }: TDeleteOrgMembership) => { - const membership = await orgDAL.transaction(async (tx) => { - // delete org membership + const deletedMembership = await orgDAL.transaction(async (tx) => { const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); - const projects = await projectDAL.find({ orgId }, { tx }); + if (!orgMembership.userId) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + } - // delete associated project memberships - await projectMembershipDAL.delete( + await userAliasDAL.delete( { - $in: { - projectId: projects.map((project) => project.id) - }, - userId: orgMembership.userId as string + userId: orgMembership.userId, + orgId }, tx ); - // TODO: delete associated aliases + // Get all the project memberships of the user in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); + // Delete all the project memberships of the user in the organization + await projectMembershipDAL.delete( + { + $in: { + id: projectMemberships.map((membership) => membership.id) + } + }, + tx + ); + + // Get all the project keys of the user in the organization + const projectKeys = await projectKeyDAL.find({ + $in: { + projectId: projectMemberships.map((membership) => membership.projectId) + }, + receiverId: orgMembership.userId + }); + + // Delete all the project keys of the user in the organization + await projectKeyDAL.delete( + { + $in: { + id: projectKeys.map((key) => key.id) + } + }, + tx + ); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); return orgMembership; }); - return membership; + return deletedMembership; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 996a08c4d9..a666e6a03c 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -18,6 +18,7 @@ import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +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"; @@ -30,6 +31,7 @@ import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; +import { deleteOrgMembershipFn } from "./org-fns"; import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, @@ -43,6 +45,7 @@ import { } from "./org-types"; type TOrgServiceFactoryDep = { + userAliasDAL: Pick; orgDAL: TOrgDALFactory; orgBotDAL: TOrgBotDALFactory; orgRoleDAL: TOrgRoleDALFactory; @@ -65,6 +68,7 @@ type TOrgServiceFactoryDep = { export type TOrgServiceFactory = ReturnType; export const orgServiceFactory = ({ + userAliasDAL, orgDAL, userDAL, groupDAL, @@ -572,47 +576,14 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - const deletedMembership = await orgDAL.transaction(async (tx) => { - const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx); - - if (!orgMembership.userId) { - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return orgMembership; - } - - // Get all the project memberships of the user in the organization - const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); - - // Delete all the project memberships of the user in the organization - await projectMembershipDAL.delete( - { - $in: { - id: projectMemberships.map((membership) => membership.id) - } - }, - tx - ); - - // Get all the project keys of the user in the organization - const projectKeys = await projectKeyDAL.find({ - $in: { - projectId: projectMemberships.map((membership) => membership.projectId) - }, - receiverId: orgMembership.userId - }); - - // Delete all the project keys of the user in the organization - await projectKeyDAL.delete( - { - $in: { - id: projectKeys.map((key) => key.id) - } - }, - tx - ); - - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return orgMembership; + const deletedMembership = await deleteOrgMembershipFn({ + orgMembershipId: membershipId, + orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); return deletedMembership; diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts index 23789df1bd..639320e243 100644 --- a/backend/src/services/user/user-fns.ts +++ b/backend/src/services/user/user-fns.ts @@ -4,7 +4,7 @@ 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); + let attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); let user = await userDAL.findOne({ username: attempt }); if (!user) return attempt; From 519403023a01d0523fd8a77680539ce75982fb54 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 28 Apr 2024 22:04:22 -0700 Subject: [PATCH 08/14] Pick --- .../src/ee/services/saml-config/saml-config-service.ts | 4 ++-- backend/src/ee/services/scim/scim-service.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 1dddffa29c..ff296103be 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -36,14 +36,14 @@ import { TSamlConfigDALFactory } from "./saml-config-dal"; import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } from "./saml-config-types"; type TSamlConfigServiceFactoryDep = { - samlConfigDAL: TSamlConfigDALFactory; // TODO: Pick + samlConfigDAL: Pick; userDAL: Pick; userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; - orgMembershipDAL: TOrgMembershipDALFactory; // TODO: Pick + orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; licenseService: Pick; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index a9a78c8865..db0413fded 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -55,12 +55,12 @@ type TScimServiceFactoryDep = { TUserDALFactory, "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" >; - userAliasDAL: TUserAliasDALFactory; // TODO: pick + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" >; - orgMembershipDAL: TOrgMembershipDALFactory; // TODO: Pick + orgMembershipDAL: Pick; projectDAL: Pick; projectMembershipDAL: Pick; groupDAL: Pick< @@ -68,7 +68,10 @@ type TScimServiceFactoryDep = { "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction" >; groupProjectDAL: Pick; - userGroupMembershipDAL: TUserGroupMembershipDALFactory; // TODO: Pick + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete" + >; projectKeyDAL: Pick; projectBotDAL: Pick; licenseService: Pick; From 69c50af14ebbf65c25d12d5cb6b491f4b94cf490 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 29 Apr 2024 11:53:28 -0700 Subject: [PATCH 09/14] Move trust saml/ldap emails to server config --- ...0426162819_user-alias-optional-username.ts | 31 +++++++-- backend/src/db/schemas/super-admin.ts | 4 +- .../ldap-config/ldap-config-service.ts | 4 +- .../saml-config/saml-config-service.ts | 4 +- backend/src/ee/services/scim/scim-service.ts | 6 +- backend/src/lib/config/env.ts | 3 - backend/src/server/routes/v1/admin-router.ts | 4 +- docs/self-hosting/configuration/envars.mdx | 10 --- frontend/src/hooks/api/admin/types.ts | 2 + .../admin/DashboardPage/DashboardPage.tsx | 68 ++++++++++++++++--- 10 files changed, 102 insertions(+), 34 deletions(-) diff --git a/backend/src/db/migrations/20240426162819_user-alias-optional-username.ts b/backend/src/db/migrations/20240426162819_user-alias-optional-username.ts index 3806601494..8e7fd8b8b3 100644 --- a/backend/src/db/migrations/20240426162819_user-alias-optional-username.ts +++ b/backend/src/db/migrations/20240426162819_user-alias-optional-username.ts @@ -3,9 +3,32 @@ import { Knex } from "knex"; import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { - await knex.schema.alterTable(TableName.UserAliases, (t) => { - t.string("username").nullable().alter(); - }); + const isUserAliasTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + if (isUserAliasTablePresent) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.string("username").nullable().alter(); + }); + } + + const isSuperAdminTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + if (isSuperAdminTablePresent) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.boolean("trustSamlEmails").defaultTo(false); + t.boolean("trustLdapEmails").defaultTo(false); + }); + } } -export async function down(): Promise {} +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustSamlEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustSamlEmails"); + }); + } + + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustLdapEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustLdapEmails"); + }); + } +} diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 958fed0abf..417d4e05e6 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -14,7 +14,9 @@ export const SuperAdminSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), allowedSignUpDomain: z.string().nullable().optional(), - instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000") + instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000"), + trustSamlEmails: z.boolean().default(false).nullable().optional(), + trustLdapEmails: z.boolean().default(false).nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index f712a6ea40..d799b17544 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -28,6 +28,7 @@ import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -392,6 +393,7 @@ export const ldapConfigServiceFactory = ({ relayState }: TLdapLoginDTO) => { const appCfg = getConfig(); + const serverCfg = await getServerCfg(); let userAlias = await userAliasDAL.findOne({ externalId, orgId, @@ -437,7 +439,7 @@ export const ldapConfigServiceFactory = ({ { username: uniqueUsername, email: emails[0], - isEmailVerified: appCfg.TRUST_LDAP_EMAILS, + isEmailVerified: serverCfg.trustLdapEmails, firstName, lastName, authMethods: [], diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index ff296103be..9719b434ba 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -24,6 +24,7 @@ import { AuthTokenType } from "@app/services/auth/auth-type"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -323,6 +324,7 @@ export const samlConfigServiceFactory = ({ relayState }: TSamlLoginDTO) => { const appCfg = getConfig(); + const serverCfg = await getServerCfg(); const userAlias = await userAliasDAL.findOne({ externalId, orgId, @@ -374,7 +376,7 @@ export const samlConfigServiceFactory = ({ { username: uniqueUsername, email, - isEmailVerified: appCfg.TRUST_SAML_EMAILS, + isEmailVerified: serverCfg.trustSamlEmails, firstName, lastName, authMethods: [], diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index db0413fded..c9cc6e1ed7 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -21,6 +21,7 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -81,8 +82,6 @@ type TScimServiceFactoryDep = { export type TScimServiceFactory = ReturnType; -// TODO: finish updating all userId refs to orgMembershipId - export const scimServiceFactory = ({ licenseService, scimDAL, @@ -279,6 +278,7 @@ export const scimServiceFactory = ({ }); const appCfg = getConfig(); + const serverCfg = await getServerCfg(); const userAlias = await userAliasDAL.findOne({ externalId: username, @@ -325,7 +325,7 @@ export const scimServiceFactory = ({ { username: uniqueUsername, email, - isEmailVerified: appCfg.TRUST_SAML_EMAILS, + isEmailVerified: serverCfg.trustSamlEmails, firstName, lastName, authMethods: [], diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8a365f2847..4d3d55ffd2 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -98,9 +98,6 @@ const envSchema = z CLIENT_ID_GITLAB: zpStr(z.string().optional()), CLIENT_SECRET_GITLAB: zpStr(z.string().optional()), URL_GITLAB_URL: zpStr(z.string().optional().default(GITLAB_URL)), - // email verification - TRUST_SAML_EMAILS: zodStrBool.default("false"), - TRUST_LDAP_EMAILS: zodStrBool.default("false"), // SECRET-SCANNING SECRET_SCANNING_WEBHOOK_PROXY: zpStr(z.string().optional()), SECRET_SCANNING_WEBHOOK_SECRET: zpStr(z.string().optional()), diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index e70822128c..4882411d81 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -42,7 +42,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ allowSignUp: z.boolean().optional(), - allowedSignUpDomain: z.string().optional().nullable() + allowedSignUpDomain: z.string().optional().nullable(), + trustSamlEmails: z.boolean().optional(), + trustLdapEmails: z.boolean().optional() }), response: { 200: z.object({ diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index e9f4431a4e..5233ae9105 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -369,16 +369,6 @@ To login into Infisical with OAuth providers such as Google, configure the assoc information. - - Whether or not to trust emails from external SAML identity providers. If set - to `false` then users will be prompted to verify their email address upon - first login. - - - Whether or not to trust emails from external LDAP servers. If set to `false` - then users will be prompted to verify their email address upon first login. - - Configure SAML organization slug to automatically redirect all users of your Infisical instance to the identity provider. diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index c7022a1d72..f5aaabc83e 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -3,6 +3,8 @@ export type TServerConfig = { allowSignUp: boolean; allowedSignUpDomain?: string | null; isMigrationModeOn?: boolean; + trustSamlEmails: boolean; + trustLdapEmails: boolean; }; export type TCreateAdminUserDTO = { diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index d5bc4097eb..ce1b117f3b 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -14,11 +14,11 @@ import { Input, Select, SelectItem, + Switch, Tab, TabList, TabPanel, - Tabs -} from "@app/components/v2"; + Tabs} from "@app/components/v2"; import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useUpdateServerConfig } from "@app/hooks/api"; @@ -33,7 +33,9 @@ enum SignUpModes { const formSchema = z.object({ signUpMode: z.nativeEnum(SignUpModes), - allowedSignUpDomain: z.string().optional().nullable() + allowedSignUpDomain: z.string().optional().nullable(), + trustSamlEmails: z.boolean(), + trustLdapEmails: z.boolean() }); type TDashboardForm = z.infer; @@ -52,7 +54,9 @@ export const AdminDashboardPage = () => { values: { // eslint-disable-next-line signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled, - allowedSignUpDomain: config.allowedSignUpDomain + allowedSignUpDomain: config.allowedSignUpDomain, + trustSamlEmails: config.trustSamlEmails, + trustLdapEmails: config.trustLdapEmails } }); @@ -62,8 +66,6 @@ export const AdminDashboardPage = () => { const { orgs } = useOrganization(); const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); - - const isNotAllowed = !user?.superAdmin; // TODO(akhilmhdh): on nextjs 14 roadmap this will be properly addressed with context split @@ -78,10 +80,13 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { signUpMode, allowedSignUpDomain } = formData; + const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails } = formData; + await updateServerConfig({ allowSignUp: signUpMode !== SignUpModes.Disabled, - allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null + allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, + trustSamlEmails, + trustLdapEmails }); createNotification({ text: "Successfully changed sign up setting.", @@ -123,8 +128,9 @@ export const AdminDashboardPage = () => {
Allow user signups
-
- Select if you want users to be able to signup freely into your Infisical instance. +
+ Select if you want users to be able to signup freely into your Infisical + instance.
{ />
)} +
+
Trust emails
+
+ Select if you want Infisical to trust external emails from SAML/LDAP identity + providers. If set to false, then Infisical will prompt SAML/LDAP provisioned + users to verify their email upon their first login. +
+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust SAML emails

+
+
+ ); + }} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust LDAP emails

+
+
+ ); + }} + /> +
- -
- - -
- ); -}; diff --git a/frontend/src/views/Signup/components/MergeUsersStep/index.tsx b/frontend/src/views/Signup/components/MergeUsersStep/index.tsx deleted file mode 100644 index 2cc931b0a2..0000000000 --- a/frontend/src/views/Signup/components/MergeUsersStep/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MergeUsersStep } from "./MergeUsersStep"; diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 0fadee48bb..69168e0aff 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -17,7 +17,6 @@ import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, Input } from "@app/components/v2"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { sendEmailVerificationCode } from "@app/hooks/api/users/mutation"; import ProjectService from "@app/services/ProjectService"; // eslint-disable-next-line new-cap @@ -26,7 +25,6 @@ const client = new jsrp.client(); type Props = { setStep: (step: number) => void; username: string; - isEmailVerified?: boolean; password: string; setPassword: (value: string) => void; name: string; @@ -60,7 +58,6 @@ type Errors = { */ export const UserInfoSSOStep = ({ username, - isEmailVerified, name, providerOrganizationName, password, @@ -204,14 +201,7 @@ export const UserInfoSSOStep = ({ localStorage.setItem("orgData.id", orgId); localStorage.setItem("projectData.id", project.id); - if (isEmailVerified) { - // move to backup PDF step - setStep(3); - } else { - // move to verify email - await sendEmailVerificationCode(); - setStep(1); - } + setStep(2); } catch (error) { setIsLoading(false); console.error(error); diff --git a/frontend/src/views/Signup/components/index.tsx b/frontend/src/views/Signup/components/index.tsx index 4362a6f4fc..7ab3d853cc 100644 --- a/frontend/src/views/Signup/components/index.tsx +++ b/frontend/src/views/Signup/components/index.tsx @@ -1,4 +1,3 @@ export { BackupPDFStep } from "./BackupPDFStep"; export { EmailConfirmationStep } from "./EmailConfirmationStep"; -export { MergeUsersStep } from "./MergeUsersStep"; export { UserInfoSSOStep } from "./UserInfoSSOStep"; From 3b88a2759b97bb1e7dcd69bef3771b40266e3046 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 18:27:36 -0700 Subject: [PATCH 12/14] Patch unsynchronized username/email for saml/scim --- ...0240506163405_trusted-saml-ldap-emails.ts} | 0 backend/src/ee/routes/v1/ldap-router.ts | 4 +- backend/src/ee/routes/v1/saml-router.ts | 2 +- backend/src/ee/routes/v1/scim-router.ts | 2 +- backend/src/ee/services/group/group-fns.ts | 6 +- .../ldap-config/ldap-config-service.ts | 84 +++++++++---- .../services/ldap-config/ldap-config-types.ts | 2 +- .../saml-config/saml-config-service.ts | 75 ++++++++---- .../services/saml-config/saml-config-types.ts | 2 +- backend/src/ee/services/scim/scim-service.ts | 114 ++++++++++++------ backend/src/ee/services/scim/scim-types.ts | 4 +- backend/src/server/routes/index.ts | 1 + .../src/services/auth/auth-signup-service.ts | 8 +- backend/src/services/org/org-dal.ts | 8 +- backend/src/services/org/org-service.ts | 14 ++- .../project-membership-service.ts | 10 +- backend/src/services/project/project-queue.ts | 6 +- backend/src/services/user/user-service.ts | 15 ++- docs/documentation/platform/ldap/general.mdx | 4 + .../documentation/platform/ldap/jumpcloud.mdx | 4 + docs/documentation/platform/ldap/overview.mdx | 21 +++- docs/documentation/platform/sso/okta.mdx | 39 +++--- docs/documentation/platform/sso/overview.mdx | 27 ++++- frontend/src/views/Signup/SignupSSO.tsx | 8 -- 24 files changed, 322 insertions(+), 138 deletions(-) rename backend/src/db/migrations/{20240429185709_trusted-saml-ldap-emails.ts => 20240506163405_trusted-saml-ldap-emails.ts} (100%) diff --git a/backend/src/db/migrations/20240429185709_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts similarity index 100% rename from backend/src/db/migrations/20240429185709_trusted-saml-ldap-emails.ts rename to backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 6730e9101b..e146668c24 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -18,6 +18,7 @@ import { LdapConfigsSchema, LdapGroupMapsSchema } from "@app/db/schemas"; import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types"; import { isValidLdapFilter, searchGroups } from "@app/ee/services/ldap-config/ldap-fns"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -52,6 +53,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line async (req: IncomingMessage, user, cb) => { try { + if (!user.email) throw new BadRequestError({ message: "Invalid request. Missing email." }); const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; let groups: { dn: string; cn: string }[] | undefined; @@ -74,7 +76,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { username: user.uid, firstName: user.givenName ?? user.cn ?? "", lastName: user.sn ?? "", - emails: user.mail ? [user.mail] : [], + email: user.mail, groups, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, orgId: (req as unknown as FastifyRequest).ldapConfig.organization diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 81543c85e5..6001b8b6ec 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -102,7 +102,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { if (!profile) throw new BadRequestError({ message: "Missing profile" }); const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved - if (!profile.email || !profile.firstName) { + if (!email || !profile.firstName) { throw new BadRequestError({ message: "Invalid request. Missing email or first name" }); } diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 7283b8fe1a..8965c28f3b 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -249,7 +249,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; const user = await req.server.services.scim.createScimUser({ - username: req.body.userName, + externalId: req.body.userName, email: primaryEmail, firstName: req.body.name.givenName, lastName: req.body.name.familyName, diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index e308891f92..4f96ddbf09 100644 --- a/backend/src/ee/services/group/group-fns.ts +++ b/backend/src/ee/services/group/group-fns.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { SecretKeyEncoding, TUsers } from "@app/db/schemas"; +import { SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ScimRequestError } from "@app/lib/errors"; @@ -188,9 +188,9 @@ export const addUsersToGroupByUserIds = async ({ // check if all user(s) are part of the organization const existingUserOrgMemberships = await orgDAL.findMembership( { - orgId: group.orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: group.orgId, $in: { - userId: userIds + [`${TableName.OrgMembership}.userId` as "userId"]: userIds } }, { tx } diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index d799b17544..6773c9486f 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -6,7 +6,8 @@ import { OrgMembershipStatus, SecretKeyEncoding, TableName, - TLdapConfigsUpdate + TLdapConfigsUpdate, + TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; @@ -25,6 +26,7 @@ import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; @@ -54,6 +56,7 @@ import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { ldapConfigDAL: Pick; ldapGroupMapDAL: Pick; + orgMembershipDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" @@ -83,6 +86,7 @@ export const ldapConfigServiceFactory = ({ ldapConfigDAL, ldapGroupMapDAL, orgDAL, + orgMembershipDAL, orgBotDAL, groupDAL, groupProjectDAL, @@ -387,7 +391,7 @@ export const ldapConfigServiceFactory = ({ username, firstName, lastName, - emails, + email, groups, orgId, relayState @@ -407,7 +411,7 @@ export const ldapConfigServiceFactory = ({ await userDAL.transaction(async (tx) => { const [orgMembership] = await orgDAL.findMembership( { - userId: userAlias.userId, + [`${TableName.OrgMembership}.userId` as "userId"]: userAlias.userId, [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, { tx } @@ -434,41 +438,75 @@ export const ldapConfigServiceFactory = ({ }); } else { userAlias = await userDAL.transaction(async (tx) => { - const uniqueUsername = await normalizeUsername(username, userDAL); - const newUser = await userDAL.create( - { - username: uniqueUsername, - email: emails[0], - isEmailVerified: serverCfg.trustLdapEmails, - firstName, - lastName, - authMethods: [], - isGhost: false - }, - tx - ); + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(username, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustLdapEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustLdapEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + const newUserAlias = await userAliasDAL.create( { userId: newUser.id, username, aliasType: UserAliasType.LDAP, externalId, - emails, + emails: [email], orgId }, tx ); - await orgDAL.createMembership( + const [orgMembership] = await orgDAL.findMembership( { - userId: newUser.id, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, - tx + { tx } ); + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + return newUserAlias; }); } diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts index b7e9feb7b8..aa4aa8da70 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -51,7 +51,7 @@ export type TLdapLoginDTO = { username: string; firstName: string; lastName: string; - emails: string[]; + email: string; orgId: string; groups?: { dn: string; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 42e77e4310..7dfd211e13 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -347,7 +347,7 @@ export const samlConfigServiceFactory = ({ const foundUser = await userDAL.findById(userAlias.userId, tx); const [orgMembership] = await orgDAL.findMembership( { - userId: foundUser.id, + [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id, [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, { tx } @@ -378,19 +378,33 @@ export const samlConfigServiceFactory = ({ }); } else { user = await userDAL.transaction(async (tx) => { - const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); - const newUser = await userDAL.create( - { - username: uniqueUsername, - email, - isEmailVerified: serverCfg.trustSamlEmails, - firstName, - lastName, - authMethods: [], - isGhost: false - }, - tx - ); + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + await userAliasDAL.create( { userId: newUser.id, @@ -402,17 +416,36 @@ export const samlConfigServiceFactory = ({ tx ); - await orgMembershipDAL.create( + const [orgMembership] = await orgDAL.findMembership( { - userId: newUser.id, - inviteEmail: email, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, - tx + { tx } ); + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: newUser.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + return newUser; }); } diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 0e84ff6666..92ee32b5c6 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -46,7 +46,7 @@ export type TGetSamlCfgDTO = export type TSamlLoginDTO = { externalId: string; - email?: string; + email: string; firstName: string; lastName?: string; authProvider: string; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index c9cc6e1ed7..9a084c6d71 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -209,10 +209,10 @@ export const scimServiceFactory = ({ findOpts ); - const scimUsers = users.map(({ id, username, firstName, lastName, email }) => + const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email }) => buildScimUser({ orgMembershipId: id ?? "", - username, + username: externalId ?? username, firstName: firstName ?? "", lastName: lastName ?? "", email, @@ -254,7 +254,7 @@ export const scimServiceFactory = ({ return buildScimUser({ orgMembershipId: membership.id, - username: membership.username, + username: membership.externalId ?? membership.username, email: membership.email ?? "", firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -262,7 +262,9 @@ export const scimServiceFactory = ({ }); }; - const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 }); + const org = await orgDAL.findById(orgId); if (!org) @@ -281,13 +283,13 @@ export const scimServiceFactory = ({ const serverCfg = await getServerCfg(); const userAlias = await userAliasDAL.findOne({ - externalId: username, + externalId, orgId, aliasType: UserAliasType.SAML }); const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { - let user: TUsers; + let user: TUsers | undefined; let orgMembership: TOrgMemberships; if (userAlias) { user = await userDAL.findById(userAlias.userId, tx); @@ -320,39 +322,74 @@ export const scimServiceFactory = ({ ); } } else { - const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); - user = await userDAL.create( - { - username: uniqueUsername, - email, - isEmailVerified: serverCfg.trustSamlEmails, - firstName, - lastName, - authMethods: [], - isGhost: false - }, - tx - ); + if (serverCfg.trustSamlEmails) { + user = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!user) { + const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); + user = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + await userAliasDAL.create( { userId: user.id, aliasType: UserAliasType.SAML, - externalId: username, + externalId, emails: email ? [email] : [], orgId }, tx ); - orgMembership = await orgMembershipDAL.create( + + const [foundOrgMembership] = await orgDAL.findMembership( { - userId: user.id, - inviteEmail: email, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, - tx + { tx } ); + + orgMembership = foundOrgMembership; + + if (!orgMembership) { + orgMembership = await orgMembershipDAL.create( + { + userId: user.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } } return { user, orgMembership }; @@ -372,7 +409,7 @@ export const scimServiceFactory = ({ return buildScimUser({ orgMembershipId: createdOrgMembership.id, - username: createdUser.username, + username: externalId, firstName: createdUser.firstName as string, lastName: createdUser.lastName as string, email: createdUser.email ?? "", @@ -380,11 +417,11 @@ export const scimServiceFactory = ({ }); }; - const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { + const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -433,7 +470,7 @@ export const scimServiceFactory = ({ return buildScimUser({ orgMembershipId: membership.id, - username: membership.username, + username: membership.externalId ?? membership.username, email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -467,7 +504,6 @@ export const scimServiceFactory = ({ }); if (!active) { - // tx await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, @@ -481,7 +517,7 @@ export const scimServiceFactory = ({ return buildScimUser({ orgMembershipId: membership.id, - username: membership.username, + username: membership.externalId ?? membership.username, email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -627,9 +663,9 @@ export const scimServiceFactory = ({ }); const orgMemberships = await orgDAL.findMembership({ - orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, $in: { - userId: newGroup.newMembers.map((member) => member.id) + [`${TableName.OrgMembership}.userId` as "userId"]: newGroup.newMembers.map((member) => member.id) } }); @@ -668,9 +704,11 @@ export const scimServiceFactory = ({ }); const orgMemberships = await orgDAL.findMembership({ - orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, $in: { - userId: users.filter((user) => user.isPartOfGroup).map((user) => user.id) + [`${TableName.OrgMembership}.userId` as "userId"]: users + .filter((user) => user.isPartOfGroup) + .map((user) => user.id) } }); diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index e804d51a54..46ab90b8f0 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -32,7 +32,7 @@ export type TGetScimUserDTO = { }; export type TCreateScimUserDTO = { - username: string; + externalId: string; email?: string; firstName: string; lastName: string; @@ -40,7 +40,7 @@ export type TCreateScimUserDTO = { }; export type TUpdateScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; operations: { op: string; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index fd095389a4..aeb66d93f9 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -322,6 +322,7 @@ export const registerRoutes = async ( ldapConfigDAL, ldapGroupMapDAL, orgDAL, + orgMembershipDAL, orgBotDAL, groupDAL, groupProjectDAL, diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 5aea537869..be7f5777db 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,6 +1,6 @@ import jwt from "jsonwebtoken"; -import { OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipStatus, 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"; @@ -102,6 +102,8 @@ export const authSignupServiceFactory = ({ code }); + await userDAL.updateById(user.id, { isEmailVerified: true }); + // generate jwt token this is a temporary token const jwtToken = jwt.sign( { @@ -171,9 +173,9 @@ export const authSignupServiceFactory = ({ // 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 === AuthMethod.LDAP) && organizationId) { const [pendingOrgMembership] = await orgDAL.findMembership({ - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, - orgId: organizationId + [`${TableName.OrgMembership}.orgId` as "orgId"]: organizationId }); if (pendingOrgMembership) { diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 4dc76b6128..1e52053b2a 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -262,13 +262,19 @@ export const orgDALFactory = (db: TDbClient) => { .where(buildFindFilter(filter)) .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.UserAliases, function joinUserAlias() { + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); + }) .select( selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), - db.ref("scimEnabled").withSchema(TableName.Organization) + db.ref("scimEnabled").withSchema(TableName.Organization), + db.ref("externalId").withSchema(TableName.UserAliases) ) .where({ isGhost: false }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index a666e6a03c..d7ee1ce933 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -4,7 +4,7 @@ import crypto from "crypto"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -431,7 +431,13 @@ export const orgServiceFactory = ({ 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({ orgId, userId: inviteeUser.id }, { tx }); + 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", @@ -523,9 +529,9 @@ export const orgServiceFactory = ({ throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" }); } const [orgMembership] = await orgDAL.findMembership({ - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, - orgId + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }); if (!orgMembership) throw new BadRequestError({ diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6d148d03b6..e12114feff 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -110,7 +110,7 @@ export const projectMembershipServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); const orgMembers = await orgDAL.findMembership({ - orgId: project.orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId, $in: { [`${TableName.OrgMembership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId) } @@ -119,7 +119,7 @@ export const projectMembershipServiceFactory = ({ const existingMembers = await projectMembershipDAL.find({ projectId, - $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) as string[] } + $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) } }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); @@ -134,7 +134,7 @@ export const projectMembershipServiceFactory = ({ const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ userId }) => ({ projectId, - userId: userId as string + userId })), tx ); @@ -145,12 +145,12 @@ export const projectMembershipServiceFactory = ({ const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( orgMembers - .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId as string)) + .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId)) .map(({ userId, id }) => ({ encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, senderId: actorId, - receiverId: userId as string, + receiverId: userId, projectId })), tx diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index 81ecd6da12..8f1e3fc3f0 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -8,6 +8,7 @@ import { SecretKeyEncoding, SecretsSchema, SecretVersionsSchema, + TableName, TIntegrationAuths, TSecretApprovalRequestsSecrets, TSecrets, @@ -273,7 +274,10 @@ export const projectQueueFactory = ({ for (const key of existingProjectKeys) { const user = await userDAL.findUserEncKeyByUserId(key.receiverId); - const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId }); + const [orgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: key.receiverId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId + }); if (!user) { throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`); diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 6d6bf274ac..089f3b8c68 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -63,6 +63,8 @@ export const userServiceFactory = ({ const verifyEmailVerificationCode = async (username: string, code: string) => { const user = await userDAL.findOne({ username }); if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); if (user.isEmailVerified) throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); @@ -72,6 +74,8 @@ export const userServiceFactory = ({ code }); + const { email } = user; + await userDAL.transaction(async (tx) => { await userDAL.updateById( user.id, @@ -84,7 +88,7 @@ export const userServiceFactory = ({ // check if there are users with the same email. const users = await userDAL.find( { - email: user.email, + email, isEmailVerified: true }, { tx } @@ -129,6 +133,15 @@ export const userServiceFactory = ({ tx ); } + } else { + // update current user's username to [email] + await userDAL.updateById( + user.id, + { + username: email + }, + tx + ); } }); }; diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index aa4841625b..5e4253a344 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -12,6 +12,10 @@ description: "Learn how to log in to Infisical with LDAP." You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index 0b40d8b3af..b92b52bb90 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -10,6 +10,10 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi it. +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the **Manual user entry** option. This user diff --git a/docs/documentation/platform/ldap/overview.mdx b/docs/documentation/platform/ldap/overview.mdx index 2423be8c06..4d6c75e153 100644 --- a/docs/documentation/platform/ldap/overview.mdx +++ b/docs/documentation/platform/ldap/overview.mdx @@ -3,11 +3,13 @@ title: "LDAP Overview" sidebarTitle: "Overview" description: "Learn how to authenticate into Infisical with LDAP." --- + LDAP is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. +If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, +then you should contact sales@infisical.com to purchase an enterprise license to use it. + You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol). @@ -25,3 +27,18 @@ Read the general instructions for configuring LDAP [here](/documentation/platfor If the documentation for your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index c81141c92b..b0ac046d03 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -4,10 +4,10 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." --- - Okta SAML SSO is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. + Okta SAML SSO is a paid feature. If you're using Infisical Cloud, then it is + available under the **Pro Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. @@ -22,24 +22,24 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." button. ![SAML Okta create app integration](../../../images/sso/okta/create-app-integration.png) - + In the Create a New Application Integration dialog, select the **SAML 2.0** radio button: ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/create-saml-app.png) - + On the General Settings screen, give the application a unique name like Infisical and select **Next**. - + ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/general-settings.png) - + On the Configure SAML screen, set the **Single sign-on URL** and **Audience URI (SP Entity ID)** from step 1. ![SAML Okta configure IdP fields](../../../images/sso/okta/configure-saml.png) - + If you're self-hosting Infisical, then you will want to replace `https://app.infisical.com` with your own domain. - + Also on the Configure SAML screen, configure the **Attribute Statements** to map: - `id -> user.id`, @@ -50,6 +50,7 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." ![SAML Okta attribute statements](../../../images/sso/okta/attribute-statements.png) Once configured, select **Next** to proceed to the Feedback screen and select **Finish**. + Once your application is created, select the **Sign On** tab for the app and select the **View Setup Instructions** button located on the right side of the screen: @@ -59,12 +60,14 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." Copy the **Identity Provider Single Sign-On URL**, the **Identity Provider Issuer**, and the **X.509 Certificate** to use when finishing configuring Okta SAML in Infisical. ![SAML Okta IdP values](../../../images/sso/okta/idp-values.png) + Back in Infisical, set **Identity Provider Single Sign-On URL**, **Identity Provider Issuer**, and **Certificate** to **X.509 Certificate** from step 3. Once you've done that, press **Update** to complete the required configuration. ![SAML Okta paste values into Infisical](../../../images/sso/okta/idp-values-2.png) + Back in Okta, navigate to the **Assignments** tab and select **Assign**. You can assign access to the application on a user-by-user basis using the Assign to People option, or in-bulk using the Assign to Groups option. @@ -72,11 +75,13 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." ![SAML Okta assignment](../../../images/sso/okta/assignment.png) At this point, you have configured everything you need within the context of the Okta Admin Portal. + Enabling SAML SSO allows members in your organization to log into Infisical via Okta. ![SAML Okta enable SAML](../../../images/sso/okta/enable-saml.png) + Enforcing SAML SSO ensures that members in your organization can only access Infisical @@ -89,13 +94,15 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." We recommend ensuring that your account is provisioned the application in Okta prior to enforcing SAML SSO to prevent any unintended issues. + - If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to - set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: - - - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. - - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) - \ No newline at end of file + If you're configuring SAML SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should + be an absolute URL including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 6064f26e8a..9ab0acc3ae 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -5,11 +5,12 @@ description: "Learn how to log in to Infisical via SSO protocols." --- - Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted. - - Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier - or via enterprise license on self-hosted instances of Infisical. On this front, we support industry-leading providers including - Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com. + Infisical offers Google SSO and GitHub SSO for free across both Infisical + Cloud and Infisical Self-hosted. Infisical also offers SAML SSO authentication + but as paid features that can be unlocked on Infisical Cloud's **Pro** tier or + via enterprise license on self-hosted instances of Infisical. On this front, + we support industry-leading providers including Okta, Azure AD, and JumpCloud; + with any questions, please reach out to team@infisical.com. You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0). @@ -31,3 +32,19 @@ Infisical supports these and many other identity providers: - [Google SAML](/documentation/platform/sso/google-saml) If your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. + +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index 88021ab449..2510c9fa43 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -54,14 +54,6 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { providerAuthToken={providerAuthToken} /> ); - // case 2: - // return ( - // - // ); case 2: return ( From ba1b223655819996b2509e17de7f8e0585c01602 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 19:44:43 -0700 Subject: [PATCH 13/14] Patch migration file hasTable ref --- .../db/migrations/20240506163405_trusted-saml-ldap-emails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts index 8e7fd8b8b3..950f78e582 100644 --- a/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts +++ b/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { - const isUserAliasTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases); if (isUserAliasTablePresent) { await knex.schema.alterTable(TableName.UserAliases, (t) => { t.string("username").nullable().alter(); From 0357e7c80e8d9ae942a37a0cae4c01d838019d19 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 19:58:58 -0700 Subject: [PATCH 14/14] Put email-confirmation migration into trusted-saml-ldap-emails file --- .../20240419200953_email-confirmation.ts | 15 --------------- .../20240506163405_trusted-saml-ldap-emails.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+), 15 deletions(-) delete mode 100644 backend/src/db/migrations/20240419200953_email-confirmation.ts diff --git a/backend/src/db/migrations/20240419200953_email-confirmation.ts b/backend/src/db/migrations/20240419200953_email-confirmation.ts deleted file mode 100644 index 59d7b3d41e..0000000000 --- a/backend/src/db/migrations/20240419200953_email-confirmation.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; - -export async function up(knex: Knex): Promise { - await knex.schema.alterTable(TableName.Users, (t) => { - t.boolean("isEmailVerified"); - }); -} - -export async function down(knex: Knex): Promise { - await knex.schema.alterTable(TableName.Users, (t) => { - t.dropColumn("isEmailVerified"); - }); -} diff --git a/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts index 950f78e582..63aa75ad8f 100644 --- a/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts +++ b/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts @@ -3,6 +3,13 @@ import { Knex } from "knex"; import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { + const isUsersTablePresent = await knex.schema.hasTable(TableName.Users); + if (isUsersTablePresent) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isEmailVerified"); + }); + } + const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases); if (isUserAliasTablePresent) { await knex.schema.alterTable(TableName.UserAliases, (t) => { @@ -20,6 +27,12 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Users, "isEmailVerified")) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustSamlEmails")) { await knex.schema.alterTable(TableName.SuperAdmin, (t) => { t.dropColumn("trustSamlEmails");