From 54fcc23a6c82abe465ab0d0f04a1a52760691781 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 19 Apr 2024 16:16:16 -0700 Subject: [PATCH 001/244] 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 000000000..59d7b3d41 --- /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 86ee2fb74..3eee2683f 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 b5cbf103e..20186718d 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 8a4de57f1..9179fde32 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 1cea39a83..2cc321373 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 15ca67a10..120b41bd1 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 5d77c340b..893f36770 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 d1e80702f..dd49f6d5e 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 59f336e5a..bd35f3ae1 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 74787f4ac..630e36310 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 3db935769..b997e2293 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 7ebeaa227..0b43ffb90 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 fc738d202..ad9694d5c 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 000000000..fc738d202 --- /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 c85e40eb3..453848bf7 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 8b918c7ab..505f7b05f 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 4d05fb963..20209df71 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 9c51948f2..a8ad89f4c 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 a5c77b15f..e1e939105 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 17316e1e6..0719111d6 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 141865026..d74da3639 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 000000000..e9b0a6e84 --- /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 000000000..32f3a636e --- /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 a4628de35..7ab3d853c 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 c88923e0c6cab7b04513db878f3a5756e3914df8 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Mon, 22 Apr 2024 17:59:21 +0000 Subject: [PATCH 002/244] fix: backend/package.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-MYSQL2-6670046 --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index 0f7b5a590..06f6241bc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -109,7 +109,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.4", + "mysql2": "^3.9.7", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", From 8ff407927cfd94896ef089536d8a727df55db478 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 25 Apr 2024 17:02:55 -0700 Subject: [PATCH 003/244] 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 9179fde32..5046ad125 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 f88182e61..c1d270ee1 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 31a13aaeb..70ba31ac8 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 dd49f6d5e..5760fb593 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 bd35f3ae1..5d68a4e94 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 000000000..9990d9c3d --- /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 453848bf7..552ddff11 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 a8ad89f4c..27f4d30e4 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 e1e939105..9bebd84cd 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 a443c6750..9bec3f19e 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 d74da3639..d56a26347 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 e9b0a6e84..c22db1ee0 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 d56a26347..09c707629 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 c22db1ee0..f3a36c8dc 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 d15e57f47..6255ae386 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 006/244] 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 d6fa72c27..ce7414747 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 a4785132b..4d824613f 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 4d3d55ffd..8a365f284 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 5d81eaae1..4d2a302c6 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 86693df8d..31ca5552f 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 4c1456d3b..e9f4431a4 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 794168729..a14e09a67 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 007/244] 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 348f9e327..d5cc9f15d 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 dea0e3d70..ed2ce92db 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 ce7414747..f712a6ea4 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 20186718d..b5cbf103e 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 5046ad125..8a4de57f1 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 2cc321373..1cea39a83 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 4d824613f..2a7a6a60a 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 e816cffcf..8668ed5e1 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 605003e4f..6700e7f05 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 73d0ebe78..a82f08656 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 299319e41..b944eb37e 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 ec6d4cb2d..69eac5e07 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 6188732c6..09204644f 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 008/244] 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 ed2ce92db..7283b8fe1 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 2a7a6a60a..1dddffa29 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 8668ed5e1..ec54a4d1f 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 6700e7f05..a9a78c886 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 a82f08656..e804d51a5 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 b944eb37e..31a777716 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 69eac5e07..a63ffabee 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 996a08c4d..a666e6a03 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 23789df1b..639320e24 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 009/244] 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 1dddffa29..ff296103b 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 a9a78c886..db0413fde 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 010/244] 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 380660149..8e7fd8b8b 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 958fed0ab..417d4e05e 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 f712a6ea4..d799b1754 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 ff296103b..9719b434b 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 db0413fde..c9cc6e1ed 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 8a365f284..4d3d55ffd 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 e70822128..4882411d8 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 e9f4431a4..5233ae910 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 c7022a1d7..f5aaabc83 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 d5bc4097e..ce1b117f3 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 2cc931b0a..000000000 --- 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 0fadee48b..69168e0af 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 4362a6f4f..7ab3d853c 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 9c002ad64512d67fd4acdc1adafaea5ef5b99ca3 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 2 May 2024 22:42:02 -0700 Subject: [PATCH 013/244] Finish preliminary AWS IAM Auth method --- .../src/hooks/api/identities/constants.tsx | 3 +- frontend/src/hooks/api/identities/enums.tsx | 3 +- frontend/src/hooks/api/identities/index.tsx | 10 +- .../src/hooks/api/identities/mutations.tsx | 77 +++- frontend/src/hooks/api/identities/queries.tsx | 28 +- frontend/src/hooks/api/identities/types.ts | 63 +++- .../IdentityAuthMethodModal.tsx | 61 ++- .../IdentityAwsIamAuthForm.tsx | 348 ++++++++++++++++++ .../IdentitySection/IdentityModal.tsx | 45 +-- .../IdentitySection/IdentityTable.tsx | 3 - .../IdentityUniversalAuthForm.tsx | 3 +- 11 files changed, 575 insertions(+), 69 deletions(-) create mode 100644 frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 53fceb7d5..5e8ae069f 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -1,5 +1,6 @@ import { IdentityAuthMethod } from "./enums"; export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { - [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth" + [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", + [IdentityAuthMethod.AWS_IAM_AUTH]: "AWS IAM Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 524c3a20c..614549da3 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -1,3 +1,4 @@ export enum IdentityAuthMethod { - UNIVERSAL_AUTH = "universal-auth" + UNIVERSAL_AUTH = "universal-auth", + AWS_IAM_AUTH = "aws-iam-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 684a00b6f..16fc56782 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -1,12 +1,16 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { + useAddIdentityAwsIamAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, useDeleteIdentity, useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, - useUpdateIdentityUniversalAuth -} from "./mutations"; -export { useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "./queries"; + useUpdateIdentityAwsIamAuth, + useUpdateIdentityUniversalAuth} from "./mutations"; +export { + useGetIdentityAwsIamAuth, + useGetIdentityUniversalAuth, + useGetIdentityUniversalAuthClientSecrets} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index ec418659d..fa956af13 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { + AddIdentityAwsIamAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, @@ -13,10 +14,11 @@ import { DeleteIdentityDTO, DeleteIdentityUniversalAuthClientSecretDTO, Identity, + IdentityAwsIamAuth, IdentityUniversalAuth, + UpdateIdentityAwsIamAuthDTO, UpdateIdentityDTO, - UpdateIdentityUniversalAuthDTO -} from "./types"; + UpdateIdentityUniversalAuthDTO} from "./types"; export const useCreateIdentity = () => { const queryClient = useQueryClient(); @@ -169,3 +171,74 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { } }); }; + +export const useAddIdentityAwsIamAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAwsIamAuth } + } = await apiRequest.post<{ identityAwsIamAuth: IdentityAwsIamAuth }>( + `/api/v1/auth/aws-iam-auth/identities/${identityId}`, + { + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAwsIamAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityAwsIamAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAwsIamAuth } + } = await apiRequest.patch<{ identityAwsIamAuth: IdentityAwsIamAuth }>( + `/api/v1/auth/aws-iam-auth/identities/${identityId}`, + { + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + return identityAwsIamAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 92d2a432f..5d4a4aeef 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -2,27 +2,26 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ClientSecretData, IdentityUniversalAuth } from "./types"; +import { ClientSecretData, IdentityAwsIamAuth,IdentityUniversalAuth } from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, getIdentityUniversalAuthClientSecrets: (identityId: string) => - [{ identityId }, "identity-universal-auth-client-secrets"] as const + [{ identityId }, "identity-universal-auth-client-secrets"] as const, + getIdentityAwsIamAuth: (identityId: string) => [{ identityId }, "identity-aws-iam-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { return useQuery({ + enabled: Boolean(identityId), queryKey: identitiesKeys.getIdentityUniversalAuth(identityId), queryFn: async () => { - if (identityId === "") throw new Error("Identity ID is required"); - const { data: { identityUniversalAuth } } = await apiRequest.get<{ identityUniversalAuth: IdentityUniversalAuth }>( `/api/v1/auth/universal-auth/identities/${identityId}` ); - return identityUniversalAuth; } }); @@ -30,17 +29,30 @@ export const useGetIdentityUniversalAuth = (identityId: string) => { export const useGetIdentityUniversalAuthClientSecrets = (identityId: string) => { return useQuery({ + enabled: Boolean(identityId), queryKey: identitiesKeys.getIdentityUniversalAuthClientSecrets(identityId), queryFn: async () => { - if (identityId === "") return []; - const { data: { clientSecretData } } = await apiRequest.get<{ clientSecretData: ClientSecretData[] }>( `/api/v1/auth/universal-auth/identities/${identityId}/client-secrets` ); - return clientSecretData; } }); }; + +export const useGetIdentityAwsIamAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityAwsIamAuth(identityId), + queryFn: async () => { + const { + data: { identityAwsIamAuth } + } = await apiRequest.get<{ identityAwsIamAuth: IdentityAwsIamAuth }>( + `/api/v1/auth/aws-iam-auth/identities/${identityId}` + ); + return identityAwsIamAuth; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 4ac19c351..02b0e9f53 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -38,19 +38,19 @@ export type IdentityMembership = { customRoleSlug: string; } & ( | { - isTemporary: false; - temporaryRange: null; - temporaryMode: null; - temporaryAccessEndTime: null; - temporaryAccessStartTime: null; - } + isTemporary: false; + temporaryRange: null; + temporaryMode: null; + temporaryAccessEndTime: null; + temporaryAccessStartTime: null; + } | { - isTemporary: true; - temporaryRange: string; - temporaryMode: string; - temporaryAccessEndTime: string; - temporaryAccessStartTime: string; - } + isTemporary: true; + temporaryRange: string; + temporaryMode: string; + temporaryAccessEndTime: string; + temporaryAccessStartTime: string; + } ) >; createdAt: string; @@ -113,6 +113,45 @@ export type UpdateIdentityUniversalAuthDTO = { }[]; }; +export type IdentityAwsIamAuth = { + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityAwsIamAuthDTO = { + organizationId: string; + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityAwsIamAuthDTO = { + organizationId: string; + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type CreateIdentityUniversalAuthClientSecretDTO = { identityId: string; description?: string; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx index 7ec17dd31..2cd4cc334 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; @@ -13,6 +14,7 @@ import { import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { IdentityAwsIamAuthForm } from "./IdentityAwsIamAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; type Props = { @@ -24,22 +26,25 @@ type Props = { ) => void; }; -const identityAuthMethods = [{ label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }]; +const identityAuthMethods = [ + { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, + { label: "AWS IAM Auth", value: IdentityAuthMethod.AWS_IAM_AUTH } +]; const schema = yup .object({ - authMethod: yup.string().required("Auth method is required") // TODO: better enforcement here + authMethod: yup.string().required("Auth method is required") }) .required(); export type FormData = yup.InferType; export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { - const { - control - // watch, - } = useForm({ - resolver: yupResolver(schema) + const { control, watch, setValue } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH + } }); const identityAuthMethodData = popUp?.identityAuthMethod?.data as { @@ -48,16 +53,41 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog authMethod?: IdentityAuthMethod; }; - // const authMethod = watch("authMethod"); + useEffect(() => { + if (identityAuthMethodData?.authMethod) { + setValue("authMethod", identityAuthMethodData.authMethod); + return; + } + + setValue("authMethod", IdentityAuthMethod.UNIVERSAL_AUTH); + }, [identityAuthMethodData?.authMethod]); + + const authMethod = watch("authMethod"); const renderIdentityAuthForm = () => { - return ( - - ); + switch (identityAuthMethodData?.authMethod ?? authMethod) { + case IdentityAuthMethod.AWS_IAM_AUTH: { + return ( + + ); + } + case IdentityAuthMethod.UNIVERSAL_AUTH: { + return ( + + ); + } + default: { + return
; + } + } }; return ( @@ -83,6 +113,7 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog {...field} onValueChange={(e) => onChange(e)} className="w-full" + isDisabled={!!identityAuthMethodData?.authMethod} > {identityAuthMethods.map(({ label, value }) => ( diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx new file mode 100644 index 000000000..416d70482 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx @@ -0,0 +1,348 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityAwsIamAuth, + useGetIdentityAwsIamAuth, + useUpdateIdentityAwsIamAuth +} from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup + .object({ + stsEndpoint: yup.string(), + allowedPrincipalArns: yup.string(), + allowedAccountIds: yup.string(), + accessTokenTTL: yup.string().required("Access Token TTL is required"), + accessTokenMaxTTL: yup.string().required("Access Max Token TTL is required"), + accessTokenNumUsesLimit: yup.string().required("Access Token Max Number of Uses is required"), + accessTokenTrustedIps: yup + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Access Token Trusted IP") + }) + .required(); + +export type FormData = yup.InferType; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityAwsIamAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityAwsIamAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityAwsIamAuth(); + + const { data } = useGetIdentityAwsIamAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + stsEndpoint: "https://sts.amazonaws.com/", + allowedPrincipalArns: "", + allowedAccountIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + stsEndpoint: data.stsEndpoint, + allowedPrincipalArns: data.allowedPrincipalArns, + allowedAccountIds: data.allowedAccountIds, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + stsEndpoint: "https://sts.amazonaws.com/", + allowedPrincipalArns: "", + allowedAccountIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + allowedPrincipalArns, + allowedAccountIds, + stsEndpoint, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + identityId: identityAuthMethodData.identityId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + stsEndpoint: stsEndpoint || "", + allowedPrincipalArns: allowedPrincipalArns || "", + allowedAccountIds: allowedAccountIds || "", + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 4ab44e947..e8b16cb5d 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -15,7 +15,10 @@ import { } from "@app/components/v2"; import { useOrganization } from "@app/context"; import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; -import { IdentityAuthMethod, useAddIdentityUniversalAuth } from "@app/hooks/api/identities"; +import { + IdentityAuthMethod + // useAddIdentityUniversalAuth +} from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = yup @@ -40,9 +43,7 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; }; -export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle }: Props) => { - - +export const IdentityModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; @@ -50,7 +51,7 @@ export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle const { mutateAsync: createMutateAsync } = useCreateIdentity(); const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); - const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); + // const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); const { control, @@ -113,31 +114,31 @@ export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle // create const { - id: createdId - // name: createdName, - // authMethod + id: createdId, + name: createdName, + authMethod } = await createMutateAsync({ name, role: role || undefined, organizationId: orgId }); - await addMutateAsync({ - organizationId: orgId, - identityId: createdId, - clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenTTL: 2592000, - accessTokenMaxTTL: 2592000, - accessTokenNumUsesLimit: 0 - }); + // await addMutateAsync({ + // organizationId: orgId, + // identityId: createdId, + // clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + // accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + // accessTokenTTL: 2592000, + // accessTokenMaxTTL: 2592000, + // accessTokenNumUsesLimit: 0 + // }); handlePopUpToggle("identity", false); - // handlePopUpOpen("identityAuthMethod", { - // identityId: createdId, - // name: createdName, - // authMethod - // }); + handlePopUpOpen("identityAuthMethod", { + identityId: createdId, + name: createdName, + authMethod + }); } createNotification({ diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 804c2d054..0ed9cd9a4 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -23,8 +23,6 @@ import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; -// TODO: some kind of map - type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState< @@ -44,7 +42,6 @@ type Props = { }; export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx index d8b4ac042..af82082a5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx @@ -63,7 +63,6 @@ export const IdentityUniversalAuthForm = ({ handlePopUpToggle, identityAuthMethodData }: Props) => { - const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); @@ -384,7 +383,7 @@ export const IdentityUniversalAuthForm = ({ variant="plain" onClick={() => handlePopUpToggle("identityAuthMethod", false)} > - Cancel + {identityAuthMethodData?.authMethod ? "Cancel" : "Skip"}
From d5c74d558afb284bbf95392a88a2d47582edaa1d Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 2 May 2024 22:52:37 -0700 Subject: [PATCH 014/244] Start docs for AWS IAM auth --- backend/package-lock.json | 664 +++++++++++------- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 8 + .../20240502044531_identity-aws-iam-auth.ts | 29 + .../src/db/schemas/identity-aws-iam-auths.ts | 26 + backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 4 +- .../ee/services/audit-log/audit-log-types.ts | 52 ++ backend/src/lib/api-docs/constants.ts | 12 + backend/src/server/routes/index.ts | 12 + .../routes/v1/identity-aws-iam-auth-router.ts | 269 +++++++ backend/src/server/routes/v1/index.ts | 2 + .../identity-aws-iam-auth-dal.ts | 11 + .../identity-aws-iam-auth-service.ts | 315 +++++++++ .../identity-aws-iam-auth-types.ts | 54 ++ .../identity-aws-iam-auth-validators.ts | 58 ++ .../identity-aws-iam-auth.fns.ts | 67 ++ .../platform/identities/aws-iam-auth.mdx | 12 + .../platform/identities/universal-auth.mdx | 25 +- docs/mint.json | 5 +- 20 files changed, 1355 insertions(+), 273 deletions(-) create mode 100644 backend/src/db/migrations/20240502044531_identity-aws-iam-auth.ts create mode 100644 backend/src/db/schemas/identity-aws-iam-auths.ts create mode 100644 backend/src/server/routes/v1/identity-aws-iam-auth-router.ts create mode 100644 backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts create mode 100644 backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts create mode 100644 backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-types.ts create mode 100644 backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts create mode 100644 backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth.fns.ts create mode 100644 docs/documentation/platform/identities/aws-iam-auth.mdx diff --git a/backend/package-lock.json b/backend/package-lock.json index b51573688..5928f64a5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1207,6 +1207,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, "node_modules/@aws-sdk/client-secrets-manager/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -1314,7 +1366,7 @@ "@aws-sdk/credential-provider-node": "^3.504.0" } }, - "node_modules/@aws-sdk/client-sts": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/client-sts": { "version": "3.504.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", @@ -1436,6 +1488,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.504.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.504.0.tgz", @@ -1505,6 +1609,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.502.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.502.0.tgz", @@ -3657,60 +3813,60 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.1.3.tgz", - "integrity": "sha512-c2aYH2Wu1RVE3rLlVgg2kQOBJGM0WbjReQi5DnPTm2Zb7F0gk7J2aeQeaX2u/lQZoHl6gv8Oac7mt9alU3+f4A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/config-resolver": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.1.4.tgz", - "integrity": "sha512-AW2WUZmBAzgO3V3ovKtsUbI3aBNMeQKFDumoqkNxaVDWF/xfnxAWqBKDr/NuG7c06N2Rm4xeZLPiJH/d+na0HA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.2.0.tgz", + "integrity": "sha512-fsiMgd8toyUba6n1WRmr+qACzXltpdDkPTAaDqc8QqPBUzO+/JKwL6bUBseHVi8tu9l+3JOK+tSf7cay+4B3LA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/types": "^2.10.1", - "@smithy/util-config-provider": "^2.2.1", - "@smithy/util-middleware": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/types": "^2.12.0", + "@smithy/util-config-provider": "^2.3.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/core": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.3.5.tgz", - "integrity": "sha512-Rrc+e2Jj6Gu7Xbn0jvrzZlSiP2CZocIOfZ9aNUA82+1sa6GBnxqL9+iZ9EKHeD9aqD1nU8EK4+oN2EiFpSv7Yw==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.4.2.tgz", + "integrity": "sha512-2fek3I0KZHWJlRLvRTqxTEri+qV0GRHrJIoLFuBMZB4EMg4WgeBGfF0X6abnrNYpq55KJ6R4D6x4f0vLnhzinA==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.4", - "@smithy/middleware-retry": "^2.1.4", - "@smithy/middleware-serde": "^2.1.3", - "@smithy/protocol-http": "^3.2.1", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", - "@smithy/util-middleware": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", + "@smithy/middleware-serde": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/credential-provider-imds": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.2.4.tgz", - "integrity": "sha512-DdatjmBZQnhGe1FhI8gO98f7NmvQFSDiZTwC3WMvLTCKQUY+Y1SVkhJqIuLu50Eb7pTheoXQmK+hKYUgpUWsNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.3.0.tgz", + "integrity": "sha512-BWB9mIukO1wjEOo1Ojgl6LrG4avcaC7T/ZP6ptmAaW4xluhSIPZhY+/PI5YKzlk+jsm+4sQZB45Bt1OfMeQa3w==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/property-provider": "^2.1.3", - "@smithy/types": "^2.10.1", - "@smithy/url-parser": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/property-provider": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/url-parser": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" @@ -3779,459 +3935,451 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.4.3.tgz", - "integrity": "sha512-Fn/KYJFo6L5I4YPG8WQb2hOmExgRmNpVH5IK2zU3JKrY5FKW7y9ar5e0BexiIC9DhSKqKX+HeWq/Y18fq7Dkpw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.5.0.tgz", + "integrity": "sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==", "dependencies": { - "@smithy/protocol-http": "^3.2.1", - "@smithy/querystring-builder": "^2.1.3", - "@smithy/types": "^2.10.1", - "@smithy/util-base64": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/protocol-http": "^3.3.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/util-base64": "^2.3.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/hash-node": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.1.3.tgz", - "integrity": "sha512-FsAPCUj7VNJIdHbSxMd5uiZiF20G2zdSDgrgrDrHqIs/VMxK85Vqk5kMVNNDMCZmMezp6UKnac0B4nAyx7HJ9g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.2.0.tgz", + "integrity": "sha512-zLWaC/5aWpMrHKpoDF6nqpNtBhlAYKF/7+9yMN7GpdR8CzohnWfGtMznPybnwSS8saaXBMxIGwJqR4HmRp6b3g==", "dependencies": { - "@smithy/types": "^2.10.1", - "@smithy/util-buffer-from": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/invalid-dependency": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.1.3.tgz", - "integrity": "sha512-wkra7d/G4CbngV4xsjYyAYOvdAhahQje/WymuQdVEnXFExJopEu7fbL5AEAlBPgWHXwu94VnCSG00gVzRfExyg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.2.0.tgz", + "integrity": "sha512-nEDASdbKFKPXN2O6lOlTgrEEOO9NHIeO+HVvZnkqc8h5U9g3BIhWsvzFo+UcUbliMHvKNPD/zVxDrkP1Sbgp8Q==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/is-array-buffer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.1.1.tgz", - "integrity": "sha512-xozSQrcUinPpNPNPds4S7z/FakDTh1MZWtRP/2vQtYB/u3HYrX2UXuZs+VhaKBd6Vc7g2XPr2ZtwGBNDN6fNKQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-content-length": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.1.3.tgz", - "integrity": "sha512-aJduhkC+dcXxdnv5ZpM3uMmtGmVFKx412R1gbeykS5HXDmRU6oSsyy2SoHENCkfOGKAQOjVE2WVqDJibC0d21g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.2.0.tgz", + "integrity": "sha512-5bl2LG1Ah/7E5cMSC+q+h3IpVHMeOkG0yLRyQT1p2aMJkSrZG7RlXHPuAgb7EyaFeidKEnnd/fNaLLaKlHGzDQ==", "dependencies": { - "@smithy/protocol-http": "^3.2.1", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/protocol-http": "^3.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-endpoint": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.4.4.tgz", - "integrity": "sha512-4yjHyHK2Jul4JUDBo2sTsWY9UshYUnXeb/TAK/MTaPEb8XQvDmpwSFnfIRDU45RY1a6iC9LCnmJNg/yHyfxqkw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.5.1.tgz", + "integrity": "sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==", "dependencies": { - "@smithy/middleware-serde": "^2.1.3", - "@smithy/node-config-provider": "^2.2.4", - "@smithy/shared-ini-file-loader": "^2.3.4", - "@smithy/types": "^2.10.1", - "@smithy/url-parser": "^2.1.3", - "@smithy/util-middleware": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/middleware-serde": "^2.3.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/shared-ini-file-loader": "^2.4.0", + "@smithy/types": "^2.12.0", + "@smithy/url-parser": "^2.2.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-retry": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.1.4.tgz", - "integrity": "sha512-Cyolv9YckZTPli1EkkaS39UklonxMd08VskiuMhURDjC0HHa/AD6aK/YoD21CHv9s0QLg0WMLvk9YeLTKkXaFQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.3.1.tgz", + "integrity": "sha512-P2bGufFpFdYcWvqpyqqmalRtwFUNUA8vHjJR5iGqbfR6mp65qKOLcUd6lTr4S9Gn/enynSrSf3p3FVgVAf6bXA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/protocol-http": "^3.2.1", - "@smithy/service-error-classification": "^2.1.3", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", - "@smithy/util-middleware": "^2.1.3", - "@smithy/util-retry": "^2.1.3", - "tslib": "^2.5.0", - "uuid": "^8.3.2" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/service-error-classification": "^2.1.5", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-middleware": "^2.2.0", + "@smithy/util-retry": "^2.2.0", + "tslib": "^2.6.2", + "uuid": "^9.0.1" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@smithy/middleware-serde": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.1.3.tgz", - "integrity": "sha512-s76LId+TwASrHhUa9QS4k/zeXDUAuNuddKklQzRgumbzge5BftVXHXIqL4wQxKGLocPwfgAOXWx+HdWhQk9hTg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.3.0.tgz", + "integrity": "sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-stack": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.1.3.tgz", - "integrity": "sha512-opMFufVQgvBSld/b7mD7OOEBxF6STyraVr1xel1j0abVILM8ALJvRoFbqSWHGmaDlRGIiV9Q5cGbWi0sdiEaLQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.2.0.tgz", + "integrity": "sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/node-config-provider": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.2.4.tgz", - "integrity": "sha512-nqazHCp8r4KHSFhRQ+T0VEkeqvA0U+RhehBSr1gunUuNW3X7j0uDrWBxB2gE9eutzy6kE3Y7L+Dov/UXT871vg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.3.0.tgz", + "integrity": "sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==", "dependencies": { - "@smithy/property-provider": "^2.1.3", - "@smithy/shared-ini-file-loader": "^2.3.4", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/property-provider": "^2.2.0", + "@smithy/shared-ini-file-loader": "^2.4.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/node-http-handler": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.4.1.tgz", - "integrity": "sha512-HCkb94soYhJMxPCa61wGKgmeKpJ3Gftx1XD6bcWEB2wMV1L9/SkQu/6/ysKBnbOzWRE01FGzwrTxucHypZ8rdg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.5.0.tgz", + "integrity": "sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==", "dependencies": { - "@smithy/abort-controller": "^2.1.3", - "@smithy/protocol-http": "^3.2.1", - "@smithy/querystring-builder": "^2.1.3", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/abort-controller": "^2.2.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/property-provider": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.1.3.tgz", - "integrity": "sha512-bMz3se+ySKWNrgm7eIiQMa2HO/0fl2D0HvLAdg9pTMcpgp4SqOAh6bz7Ik6y7uQqSrk4rLjIKgbQ6yzYgGehCQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.2.0.tgz", + "integrity": "sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/protocol-http": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.2.1.tgz", - "integrity": "sha512-KLrQkEw4yJCeAmAH7hctE8g9KwA7+H2nSJwxgwIxchbp/L0B5exTdOQi9D5HinPLlothoervGmhpYKelZ6AxIA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.3.0.tgz", + "integrity": "sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/querystring-builder": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.1.3.tgz", - "integrity": "sha512-kFD3PnNqKELe6m9GRHQw/ftFFSZpnSeQD4qvgDB6BQN6hREHELSosVFUMPN4M3MDKN2jAwk35vXHLoDrNfKu0A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.2.0.tgz", + "integrity": "sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==", "dependencies": { - "@smithy/types": "^2.10.1", - "@smithy/util-uri-escape": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "@smithy/util-uri-escape": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/querystring-parser": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.1.3.tgz", - "integrity": "sha512-3+CWJoAqcBMR+yvz6D+Fc5VdoGFtfenW6wqSWATWajrRMGVwJGPT3Vy2eb2bnMktJc4HU4bpjeovFa566P3knQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.2.0.tgz", + "integrity": "sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/service-error-classification": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.3.tgz", - "integrity": "sha512-iUrpSsem97bbXHHT/v3s7vaq8IIeMo6P6cXdeYHrx0wOJpMeBGQF7CB0mbJSiTm3//iq3L55JiEm8rA7CTVI8A==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.5.tgz", + "integrity": "sha512-uBDTIBBEdAQryvHdc5W8sS5YX7RQzF683XrHePVdFmAgKiMofU15FLSM0/HU03hKTnazdNRFa0YHS7+ArwoUSQ==", "dependencies": { - "@smithy/types": "^2.10.1" + "@smithy/types": "^2.12.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.3.4.tgz", - "integrity": "sha512-CiZmPg9GeDKbKmJGEFvJBsJcFnh0AQRzOtQAzj1XEa8N/0/uSN/v1LYzgO7ry8hhO8+9KB7+DhSW0weqBra4Aw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.4.0.tgz", + "integrity": "sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/signature-v4": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.1.3.tgz", - "integrity": "sha512-Jq4iPPdCmJojZTsPePn4r1ULShh6ONkokLuxp1Lnk4Sq7r7rJp4HlA1LbPBq4bD64TIzQezIpr1X+eh5NYkNxw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.3.0.tgz", + "integrity": "sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==", "dependencies": { - "@smithy/eventstream-codec": "^2.1.3", - "@smithy/is-array-buffer": "^2.1.1", - "@smithy/types": "^2.10.1", - "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-middleware": "^2.1.3", - "@smithy/util-uri-escape": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/is-array-buffer": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/util-hex-encoding": "^2.2.0", + "@smithy/util-middleware": "^2.2.0", + "@smithy/util-uri-escape": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/smithy-client": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.4.2.tgz", - "integrity": "sha512-ntAFYN51zu3N3mCd95YFcFi/8rmvm//uX+HnK24CRbI6k5Rjackn0JhgKz5zOx/tbNvOpgQIwhSX+1EvEsBLbA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.5.1.tgz", + "integrity": "sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.4", - "@smithy/middleware-stack": "^2.1.3", - "@smithy/protocol-http": "^3.2.1", - "@smithy/types": "^2.10.1", - "@smithy/util-stream": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-stack": "^2.2.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/types": "^2.12.0", + "@smithy/util-stream": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/types": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.10.1.tgz", - "integrity": "sha512-hjQO+4ru4cQ58FluQvKKiyMsFg0A6iRpGm2kqdH8fniyNd2WyanoOsYJfMX/IFLuLxEoW6gnRkNZy1y6fUUhtA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/url-parser": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.1.3.tgz", - "integrity": "sha512-X1NRA4WzK/ihgyzTpeGvI9Wn45y8HmqF4AZ/FazwAv8V203Ex+4lXqcYI70naX9ETqbqKVzFk88W6WJJzCggTQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.2.0.tgz", + "integrity": "sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==", "dependencies": { - "@smithy/querystring-parser": "^2.1.3", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/querystring-parser": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/util-base64": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-2.1.1.tgz", - "integrity": "sha512-UfHVpY7qfF/MrgndI5PexSKVTxSZIdz9InghTFa49QOvuu9I52zLPLUHXvHpNuMb1iD2vmc6R+zbv/bdMipR/g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-2.3.0.tgz", + "integrity": "sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==", "dependencies": { - "@smithy/util-buffer-from": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-body-length-browser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-2.1.1.tgz", - "integrity": "sha512-ekOGBLvs1VS2d1zM2ER4JEeBWAvIOUKeaFch29UjjJsxmZ/f0L3K3x0dEETgh3Q9bkZNHgT+rkdl/J/VUqSRag==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-2.2.0.tgz", + "integrity": "sha512-dtpw9uQP7W+n3vOtx0CfBD5EWd7EPdIdsQnWTDoFf77e3VUf05uA7R7TGipIo8e4WL2kuPdnsr3hMQn9ziYj5w==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" } }, "node_modules/@smithy/util-body-length-node": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-2.2.1.tgz", - "integrity": "sha512-/ggJG+ta3IDtpNVq4ktmEUtOkH1LW64RHB5B0hcr5ZaWBmo96UX2cIOVbjCqqDickTXqBWZ4ZO0APuaPrD7Abg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-2.3.0.tgz", + "integrity": "sha512-ITWT1Wqjubf2CJthb0BuT9+bpzBfXeMokH/AAa5EJQgbv9aPMVfnM76iFIZVFf50hYXGbtiV71BHAthNWd6+dw==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-buffer-from": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.1.1.tgz", - "integrity": "sha512-clhNjbyfqIv9Md2Mg6FffGVrJxw7bgK7s3Iax36xnfVj6cg0fUG7I4RH0XgXJF8bxi+saY5HR21g2UPKSxVCXg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "dependencies": { - "@smithy/is-array-buffer": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-config-provider": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-2.2.1.tgz", - "integrity": "sha512-50VL/tx9oYYcjJn/qKqNy7sCtpD0+s8XEBamIFo4mFFTclKMNp+rsnymD796uybjiIquB7VCB/DeafduL0y2kw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-2.3.0.tgz", + "integrity": "sha512-HZkzrRcuFN1k70RLqlNK4FnPXKOpkik1+4JaBoHNJn+RnJGYqaa3c5/+XtLOXhlKzlRgNvyaLieHTW2VwGN0VQ==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.1.4.tgz", - "integrity": "sha512-J6XAVY+/g7jf03QMnvqPyU+8jqGrrtXoKWFVOS+n1sz0Lg8HjHJ1ANqaDN+KTTKZRZlvG8nU5ZrJOUL6VdwgcQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.2.1.tgz", + "integrity": "sha512-RtKW+8j8skk17SYowucwRUjeh4mCtnm5odCL0Lm2NtHQBsYKrNW0od9Rhopu9wF1gHMfHeWF7i90NwBz/U22Kw==", "dependencies": { - "@smithy/property-provider": "^2.1.3", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", + "@smithy/property-provider": "^2.2.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", "bowser": "^2.11.0", - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.2.3.tgz", - "integrity": "sha512-ttUISrv1uVOjTlDa3nznX33f0pthoUlP+4grhTvOzcLhzArx8qHB94/untGACOG3nlf8vU20nI2iWImfzoLkYA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.3.1.tgz", + "integrity": "sha512-vkMXHQ0BcLFysBMWgSBLSk3+leMpFSyyFj8zQtv5ZyUBx8/owVh1/pPEkzmW/DR/Gy/5c8vjLDD9gZjXNKbrpA==", "dependencies": { - "@smithy/config-resolver": "^2.1.4", - "@smithy/credential-provider-imds": "^2.2.4", - "@smithy/node-config-provider": "^2.2.4", - "@smithy/property-provider": "^2.1.3", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/config-resolver": "^2.2.0", + "@smithy/credential-provider-imds": "^2.3.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/property-provider": "^2.2.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/@smithy/util-endpoints": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.1.4.tgz", - "integrity": "sha512-/qAeHmK5l4yQ4/bCIJ9p49wDe9rwWtOzhPHblu386fwPNT3pxmodgcs9jDCV52yK9b4rB8o9Sj31P/7Vzka1cg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.2.0.tgz", + "integrity": "sha512-BuDHv8zRjsE5zXd3PxFXFknzBG3owCpjq8G3FcsXW3CykYXuEqM3nTSsmLzw5q+T12ZYuDlVUZKBdpNbhVtlrQ==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@smithy/util-hex-encoding": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.1.1.tgz", - "integrity": "sha512-3UNdP2pkYUUBGEXzQI9ODTDK+Tcu1BlCyDBaRHwyxhA+8xLP8agEKQq4MGmpjqb4VQAjq9TwlCQX0kP6XDKYLg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.2.0.tgz", + "integrity": "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-middleware": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.1.3.tgz", - "integrity": "sha512-/+2fm7AZ2ozl5h8wM++ZP0ovE9/tiUUAHIbCfGfb3Zd3+Dyk17WODPKXBeJ/TnK5U+x743QmA0xHzlSm8I/qhw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.2.0.tgz", + "integrity": "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-retry": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.1.3.tgz", - "integrity": "sha512-Kbvd+GEMuozbNUU3B89mb99tbufwREcyx2BOX0X2+qHjq6Gvsah8xSDDgxISDwcOHoDqUWO425F0Uc/QIRhYkg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.2.0.tgz", + "integrity": "sha512-q9+pAFPTfftHXRytmZ7GzLFFrEGavqapFc06XxzZFcSIGERXMerXxCitjOG1prVDR9QdjqotF40SWvbqcCpf8g==", "dependencies": { - "@smithy/service-error-classification": "^2.1.3", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/service-error-classification": "^2.1.5", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@smithy/util-stream": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.1.3.tgz", - "integrity": "sha512-HvpEQbP8raTy9n86ZfXiAkf3ezp1c3qeeO//zGqwZdrfaoOpGKQgF2Sv1IqZp7wjhna7pvczWaGUHjcOPuQwKw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.2.0.tgz", + "integrity": "sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==", "dependencies": { - "@smithy/fetch-http-handler": "^2.4.3", - "@smithy/node-http-handler": "^2.4.1", - "@smithy/types": "^2.10.1", - "@smithy/util-base64": "^2.1.1", - "@smithy/util-buffer-from": "^2.1.1", - "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/fetch-http-handler": "^2.5.0", + "@smithy/node-http-handler": "^2.5.0", + "@smithy/types": "^2.12.0", + "@smithy/util-base64": "^2.3.0", + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-hex-encoding": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-uri-escape": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.1.1.tgz", - "integrity": "sha512-saVzI1h6iRBUVSqtnlOnc9ssU09ypo7n+shdQ8hBTZno/9rZ3AuRYvoHInV57VF7Qn7B+pFJG7qTzFiHxWlWBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.2.0.tgz", + "integrity": "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-utf8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.1.1.tgz", - "integrity": "sha512-BqTpzYEcUMDwAKr7/mVRUtHDhs6ZoXDi9NypMvMfOr/+u1NW7JgqodPDECiiLboEm6bobcPcECxzjtQh865e9A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "dependencies": { - "@smithy/util-buffer-from": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a4c3eea7b..c218e952d 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -29,6 +29,7 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { TIdentityAwsIamAuthServiceFactory } from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; @@ -112,6 +113,7 @@ declare module "fastify" { identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; + identityAwsIamAuth: TIdentityAwsIamAuthServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; secretApprovalRequest: TSecretApprovalRequestServiceFactory; secretRotation: TSecretRotationServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 8845c1d01..448a1110f 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -44,6 +44,9 @@ import { TIdentityAccessTokens, TIdentityAccessTokensInsert, TIdentityAccessTokensUpdate, + TIdentityAwsIamAuths, + TIdentityAwsIamAuthsInsert, + TIdentityAwsIamAuthsUpdate, TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, @@ -311,6 +314,11 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; + [TableName.IdentityAwsIamAuth]: Knex.CompositeTableType< + TIdentityAwsIamAuths, + TIdentityAwsIamAuthsInsert, + TIdentityAwsIamAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20240502044531_identity-aws-iam-auth.ts b/backend/src/db/migrations/20240502044531_identity-aws-iam-auth.ts new file mode 100644 index 000000000..0728d4f28 --- /dev/null +++ b/backend/src/db/migrations/20240502044531_identity-aws-iam-auth.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityAwsIamAuth))) { + await knex.schema.createTable(TableName.IdentityAwsIamAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("stsEndpoint").notNullable(); + t.string("allowedPrincipalArns").notNullable(); + t.string("allowedAccountIds").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAwsIamAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAwsIamAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAwsIamAuth); +} diff --git a/backend/src/db/schemas/identity-aws-iam-auths.ts b/backend/src/db/schemas/identity-aws-iam-auths.ts new file mode 100644 index 000000000..8912c71b3 --- /dev/null +++ b/backend/src/db/schemas/identity-aws-iam-auths.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAwsIamAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + stsEndpoint: z.string(), + allowedPrincipalArns: z.string(), + allowedAccountIds: z.string() +}); + +export type TIdentityAwsIamAuths = z.infer; +export type TIdentityAwsIamAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAwsIamAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 30d6208b8..83b1afb71 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -12,6 +12,7 @@ export * from "./group-project-memberships"; export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; +export * from "./identity-aws-iam-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ea70dccdb..74c2799f5 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -45,6 +45,7 @@ export enum TableName { IdentityAccessToken = "identity_access_tokens", IdentityUniversalAuth = "identity_universal_auths", IdentityUaClientSecret = "identity_ua_client_secrets", + IdentityAwsIamAuth = "identity_aws_iam_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -137,5 +138,6 @@ export enum ProjectUpgradeStatus { } export enum IdentityAuthMethod { - Univeral = "universal-auth" + Univeral = "universal-auth", + AWS_IAM_AUTH = "aws-iam-auth" } diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 220c25002..1e79984e4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -66,6 +66,10 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + LOGIN_IDENTITY_AWS_IAM_AUTH = "login-identity-aws-iam-auth", + ADD_IDENTITY_AWS_IAM_AUTH = "add-identity-aws-iam-auth", + UPDATE_IDENTITY_AWS_IAM_AUTH = "update-identity-aws-iam-auth", + GET_IDENTITY_AWS_IAM_AUTH = "get-identity-aws-iam-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -406,6 +410,50 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface LoginIdentityAwsIamAuthEvent { + type: EventType.LOGIN_IDENTITY_AWS_IAM_AUTH; + metadata: { + identityId: string; + identityAwsIamAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAwsIamAuthEvent { + type: EventType.ADD_IDENTITY_AWS_IAM_AUTH; + metadata: { + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityAwsIamAuthEvent { + type: EventType.UPDATE_IDENTITY_AWS_IAM_AUTH; + metadata: { + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAwsIamAuthEvent { + type: EventType.GET_IDENTITY_AWS_IAM_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -660,6 +708,10 @@ export type Event = | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | LoginIdentityAwsIamAuthEvent + | AddIdentityAwsIamAuthEvent + | UpdateIdentityAwsIamAuthEvent + | GetIdentityAwsIamAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 04b7509ca..8fa20f920 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -92,6 +92,18 @@ export const UNIVERSAL_AUTH = { } } as const; +export const AWS_IAM_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login.", + iamHttpRequestMethod: "The HTTP request method used in the signed request.", + iamRequestUrl: + "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/", + iamRequestBody: + "The base64-encoded body of the signed request. Most likely, the base64-encoding of Action=GetCallerIdentity&Version=2011-06-15.", + iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." + } +} as const; + export const ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4cb56a222..417bb05f1 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -70,6 +70,8 @@ import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { identityAwsIamAuthDALFactory } from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-dal"; +import { identityAwsIamAuthServiceFactory } from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -191,6 +193,7 @@ export const registerRoutes = async ( const identityUaDAL = identityUaDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); + const identityAwsIamAuthDAL = identityAwsIamAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); @@ -637,6 +640,14 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); + const identityAWSIAMAuthService = identityAwsIamAuthServiceFactory({ + identityAccessTokenDAL, + identityAwsIamAuthDAL, + identityOrgMembershipDAL, + identityDAL, + licenseService, + permissionService + }); const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ @@ -706,6 +717,7 @@ export const registerRoutes = async ( identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityUa: identityUaService, + identityAwsIamAuth: identityAWSIAMAuthService, secretApprovalPolicy: sapService, secretApprovalRequest: sarService, secretRotation: secretRotationService, diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts new file mode 100644 index 000000000..0245b7607 --- /dev/null +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; + +import { IdentityAwsIamAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AWS_IAM_AUTH } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { + validateAccountIds, + validatePrincipalArns +} from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-validators"; + +export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/aws-iam-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Universal Auth", + body: z.object({ + identityId: z.string().describe(AWS_IAM_AUTH.LOGIN.identityId), + iamHttpRequestMethod: z.string().default("POST").describe(AWS_IAM_AUTH.LOGIN.iamHttpRequestMethod), + iamRequestBody: z.string().describe(AWS_IAM_AUTH.LOGIN.iamRequestBody), + iamRequestHeaders: z.string().describe(AWS_IAM_AUTH.LOGIN.iamRequestHeaders) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAwsIamAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAwsIamAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_AWS_IAM_AUTH, + metadata: { + identityId: identityAwsIamAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAwsIamAuthId: identityAwsIamAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAwsIamAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/aws-iam-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach AWS IAM Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + stsEndpoint: z.string().trim().min(1).default("https://sts.amazonaws.com/"), + allowedPrincipalArns: validatePrincipalArns, + allowedAccountIds: validateAccountIds, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityAwsIamAuth: IdentityAwsIamAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsIamAuth = await server.services.identityAwsIamAuth.attachAwsIamAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsIamAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_AWS_IAM_AUTH, + metadata: { + identityId: identityAwsIamAuth.identityId, + stsEndpoint: identityAwsIamAuth.stsEndpoint, + allowedPrincipalArns: identityAwsIamAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsIamAuth.allowedAccountIds, + accessTokenTTL: identityAwsIamAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsIamAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsIamAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAwsIamAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/aws-iam-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update AWS IAM Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + body: z.object({ + stsEndpoint: z.string().trim().min(1).optional(), + allowedPrincipalArns: validatePrincipalArns, + allowedAccountIds: validateAccountIds, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityAwsIamAuth: IdentityAwsIamAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsIamAuth = await server.services.identityAwsIamAuth.updateAwsIamAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsIamAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_AWS_IAM_AUTH, + metadata: { + identityId: identityAwsIamAuth.identityId, + stsEndpoint: identityAwsIamAuth.stsEndpoint, + allowedPrincipalArns: identityAwsIamAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsIamAuth.allowedAccountIds, + accessTokenTTL: identityAwsIamAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsIamAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsIamAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAwsIamAuth }; + } + }); + + server.route({ + method: "GET", + url: "/aws-iam-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve AWS IAM Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityAwsIamAuth: IdentityAwsIamAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsIamAuth = await server.services.identityAwsIamAuth.getAwsIamAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsIamAuth.orgId, + event: { + type: EventType.GET_IDENTITY_AWS_IAM_AUTH, + metadata: { + identityId: identityAwsIamAuth.identityId + } + } + }); + return { identityAwsIamAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fbc68d974..4fbaabdfc 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -2,6 +2,7 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; +import { registerIdentityAwsIamAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; @@ -28,6 +29,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerAuthRoutes); await authRouter.register(registerIdentityUaRouter); await authRouter.register(registerIdentityAccessTokenRouter); + await authRouter.register(registerIdentityAwsIamAuthRouter); }, { prefix: "/auth" } ); diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts new file mode 100644 index 000000000..584ac775f --- /dev/null +++ b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAwsIamAuthDALFactory = ReturnType; + +export const identityAwsIamAuthDALFactory = (db: TDbClient) => { + const awsIamAuthOrm = ormify(db, TableName.IdentityAwsIamAuth); + + return awsIamAuthOrm; +}; diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts new file mode 100644 index 000000000..66dc2c21a --- /dev/null +++ b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts @@ -0,0 +1,315 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import axios from "axios"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { extractPrincipalArn } from "./identity-aws-iam-auth.fns"; +import { TIdentityAwsIamAuthDALFactory } from "./identity-aws-iam-auth-dal"; +import { + TAttachAWSIAMAuthDTO, + TAWSGetCallerIdentityHeaders, + TGetAWSIAMAuthDTO, + TGetCallerIdentityResponse, + TLoginAWSIAMAuthDTO, + TUpdateAWSIAMAuthDTO +} from "./identity-aws-iam-auth-types"; + +type TIdentityAwsIamAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityAwsIamAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityDAL: Pick; + licenseService: Pick; + permissionService: Pick; +}; + +export type TIdentityAwsIamAuthServiceFactory = ReturnType; + +export const identityAwsIamAuthServiceFactory = ({ + identityAccessTokenDAL, + identityAwsIamAuthDAL, + identityOrgMembershipDAL, + identityDAL, + licenseService, + permissionService +}: TIdentityAwsIamAuthServiceFactoryDep) => { + const login = async ({ + identityId, + iamHttpRequestMethod, + iamRequestBody, + iamRequestHeaders + }: TLoginAWSIAMAuthDTO) => { + const identityAwsIamAuth = await identityAwsIamAuthDAL.findOne({ identityId }); + if (!identityAwsIamAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsIamAuth.identityId }); + + const headers: TAWSGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); + const body: string = Buffer.from(iamRequestBody, "base64").toString(); + + const { + data: { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { Account, Arn } + } + } + }: { data: TGetCallerIdentityResponse } = await axios({ + method: iamHttpRequestMethod, + url: identityAwsIamAuth.stsEndpoint, + headers, + data: body + }); + + if (identityAwsIamAuth.allowedAccountIds) { + // validate if Account is in the list of allowed Account IDs + + const isAccountAllowed = identityAwsIamAuth.allowedAccountIds + .split(",") + .map((accountId) => accountId.trim()) + .some((accountId) => accountId === Account); + + if (!isAccountAllowed) throw new UnauthorizedError(); + } + + if (identityAwsIamAuth.allowedPrincipalArns) { + // validate if Arn is in the list of allowed Principal ARNs + + const isArnAllowed = identityAwsIamAuth.allowedPrincipalArns + .split(",") + .map((principalArn) => principalArn.trim()) + .some((principalArn) => { + // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" + // considers exact matches + wildcard matches + const regex = new RegExp(`^${principalArn.replace(/\*/g, ".*")}$`); + return regex.test(extractPrincipalArn(Arn)); + }); + + if (!isArnAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityAwsIamAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAwsIamAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAwsIamAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAwsIamAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAwsIamAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityAwsIamAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachAwsIamAuth = async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachAWSIAMAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add AWS IAM Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityAwsIamAuth = await identityAwsIamAuthDAL.transaction(async (tx) => { + const doc = await identityAwsIamAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.AWS_IAM_AUTH + }, + tx + ); + return doc; + }); + return { ...identityAwsIamAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAwsIamAuth = async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAWSIAMAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_IAM_AUTH) + throw new BadRequestError({ + message: "Failed to update AWS IAM Auth" + }); + + const identityAwsIamAuth = await identityAwsIamAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityAwsIamAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityAwsIamAuth.accessTokenMaxTTL) > + (accessTokenMaxTTL || identityAwsIamAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedAwsIamAuth = await identityAwsIamAuthDAL.updateById(identityAwsIamAuth.id, { + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedAwsIamAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getAwsIamAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAWSIAMAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_IAM_AUTH) + throw new BadRequestError({ + message: "The identity does not have AWS IAM Auth attached" + }); + + const awsIamIdentityAuth = await identityAwsIamAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + return { ...awsIamIdentityAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachAwsIamAuth, + updateAwsIamAuth, + getAwsIamAuth + }; +}; diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-types.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-types.ts new file mode 100644 index 000000000..19f27f430 --- /dev/null +++ b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-types.ts @@ -0,0 +1,54 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAWSIAMAuthDTO = { + identityId: string; + iamHttpRequestMethod: string; + iamRequestBody: string; + iamRequestHeaders: string; +}; + +export type TAttachAWSIAMAuthDTO = { + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateAWSIAMAuthDTO = { + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAWSIAMAuthDTO = { + identityId: string; +} & Omit; + +export type TAWSGetCallerIdentityHeaders = { + "Content-Type": string; + Host: string; + "X-Amz-Date": string; + "Content-Length": number; + "x-amz-security-token": string; + Authorization: string; +}; + +export type TGetCallerIdentityResponse = { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { + Account: string; + Arn: string; + UserId: string; + }; + ResponseMetadata: { RequestId: string }; + }; +}; diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts new file mode 100644 index 000000000..35fb56674 --- /dev/null +++ b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +const twelveDigitRegex = /^\d{12}$/; +const arnRegex = /^arn:aws:iam::\d{12}:(user\/[A-Za-z0-9]+|role\/[A-Za-z0-9]+|\*)$/; + +export const validateAccountIds = z + .string() + .trim() + .default("") + // Custom validation to ensure each part is a 12-digit number + .refine( + (data) => { + if (data === "") return true; + // Split the string by commas to check each supposed number + const accountIds = data.split(",").map((id) => id.trim()); + // Return true only if every item matches the 12-digit requirement + return accountIds.every((id) => twelveDigitRegex.test(id)); + }, + { + message: "Each account ID must be a 12-digit number." + } + ) + // Transform the string to normalize space after commas + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + +export const validatePrincipalArns = z + .string() + .trim() + .default("") + // Custom validation for ARN format + .refine( + (data) => { + // Skip validation if the string is empty + if (data === "") return true; + // Split the string by commas to check each supposed ARN + const arns = data.split(","); + // Return true only if every item matches one of the allowed ARN formats + return arns.every((arn) => arnRegex.test(arn.trim())); + }, + { + message: + "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'." + } + ) + // Transform to normalize the spaces around commas + .transform((data) => + data + .split(",") + .map((arn) => arn.trim()) + .join(", ") + ); diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth.fns.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth.fns.ts new file mode 100644 index 000000000..517e9f613 --- /dev/null +++ b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth.fns.ts @@ -0,0 +1,67 @@ +/** + * Extracts the identity ARN from the GetCallerIdentity response to one of the following formats: + * - arn:aws:iam::123456789012:user/MyUserName + * - arn:aws:iam::123456789012:role/MyRoleName + */ +export const extractPrincipalArn = (arn: string) => { + // split the ARN into parts using ":" as the delimiter + const fullParts = arn.split(":"); + if (fullParts.length !== 6) { + throw new Error(`Unrecognized ARN: contains ${fullParts.length} colon-separated parts, expected 6`); + } + const [prefix, partition, service, , accountNumber, resource] = fullParts; + if (prefix !== "arn") { + throw new Error('Unrecognized ARN: does not begin with "arn:"'); + } + + // structure to hold the parsed data + const entity = { + Partition: partition, + Service: service, + AccountNumber: accountNumber, + Type: "", + Path: "", + FriendlyName: "", + SessionInfo: "" + }; + + // validate the service is either 'iam' or 'sts' + if (entity.Service !== "iam" && entity.Service !== "sts") { + throw new Error(`Unrecognized service: ${entity.Service}, not one of iam or sts`); + } + + // parse the last part of the ARN which describes the resource + const parts = resource.split("/"); + if (parts.length < 2) { + throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 2 slash-separated parts`); + } + + const [type, ...rest] = parts; + entity.Type = type; + entity.FriendlyName = parts[parts.length - 1]; + + // handle different types of resources + switch (entity.Type) { + case "assumed-role": { + if (rest.length < 2) { + throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 3 slash-separated parts`); + } + // assumed roles use a special format where the friendly name is the role name + const [roleName, sessionId] = rest; + entity.Type = "role"; // treat assumed role case as role + entity.FriendlyName = roleName; + entity.SessionInfo = sessionId; + break; + } + case "user": + case "role": + case "instance-profile": + // standard cases: just join back the path if there's any + entity.Path = rest.slice(0, -1).join("/"); + break; + default: + throw new Error(`Unrecognized principal type: "${entity.Type}"`); + } + + return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; +}; diff --git a/docs/documentation/platform/identities/aws-iam-auth.mdx b/docs/documentation/platform/identities/aws-iam-auth.mdx new file mode 100644 index 000000000..1213b82d0 --- /dev/null +++ b/docs/documentation/platform/identities/aws-iam-auth.mdx @@ -0,0 +1,12 @@ +--- +title: AWS IAM Auth +description: "Learn how to authenticate with Infisical from AWS." +--- + +**AWS IAM Auth** is an AWS-native authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) to access Infisical from AWS. + +In this method, each identity is configured to represent one or more IAM principals in AWS with shared privileges. + +## Workflow + +TODO diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index a9f4dffae..474d24476 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -27,18 +27,18 @@ using the Universal Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - + ![identities organization create](/images/platform/identities/identities-org-create.png) Now input a few details for your new identity. Here's some guidance for each field: - Name (required): A friendly name for the identity. - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. - + Once you've created an identity, you'll be prompted to configure the **Universal Auth** authentication method for it. - + ![identities organization create auth method](/images/platform/identities/identities-org-create-auth-method.png) - + Here's some more guidance on each field: - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. @@ -78,8 +78,9 @@ using the Universal Auth authentication method. Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. ![identities project](/images/platform/identities/identities-project.png) - + ![identities project create](/images/platform/identities/identities-project-create.png) + To access the Infisical API as the identity, you should first perform a login operation @@ -94,9 +95,9 @@ using the Universal Auth authentication method. --data-urlencode 'clientSecret=...' \ --data-urlencode 'clientId=...' ``` - + #### Sample response - + ``` { "accessToken": "...", @@ -107,7 +108,7 @@ using the Universal Auth authentication method. ``` Next, you can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) - + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. @@ -115,6 +116,7 @@ using the Universal Auth authentication method. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, a new access token should be obtained by performing another login operation. + @@ -134,7 +136,8 @@ using the Universal Auth authentication method. In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. - A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. - Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation +A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. +Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation + - \ No newline at end of file + diff --git a/docs/mint.json b/docs/mint.json index 26bf3ac5b..8ca170989 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -152,6 +152,7 @@ "documentation/platform/auth-methods/email-password", "documentation/platform/token", "documentation/platform/identities/universal-auth", + "documentation/platform/identities/aws-iam-auth", "documentation/platform/mfa", { "group": "SSO", @@ -210,9 +211,7 @@ }, { "group": "Reference architectures", - "pages": [ - "self-hosting/reference-architectures/aws-ecs" - ] + "pages": ["self-hosting/reference-architectures/aws-ecs"] }, "self-hosting/ee", "self-hosting/faq" From cbf8e041e9ddcce60c24b2935b39a9bf0d2ebb48 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 3 May 2024 17:20:44 -0700 Subject: [PATCH 015/244] Finish docs for AWS IAM Auth, update ARN regex --- .../identity-aws-iam-auth-validators.ts | 2 +- .../getting-started/introduction.mdx | 78 ++--- .../platform/identities/aws-iam-auth.mdx | 274 +++++++++++++++++- .../identities/machine-identities.mdx | 7 +- .../platform/identities/universal-auth.mdx | 21 +- ...ntities-org-create-aws-iam-auth-method.png | Bin 0 -> 550802 bytes .../IdentityAwsIamAuthForm.tsx | 6 +- 7 files changed, 334 insertions(+), 54 deletions(-) create mode 100644 docs/images/platform/identities/identities-org-create-aws-iam-auth-method.png diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts index 35fb56674..2cb7b4ea4 100644 --- a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts +++ b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts @@ -1,7 +1,7 @@ import { z } from "zod"; const twelveDigitRegex = /^\d{12}$/; -const arnRegex = /^arn:aws:iam::\d{12}:(user\/[A-Za-z0-9]+|role\/[A-Za-z0-9]+|\*)$/; +const arnRegex = /^arn:aws:iam::\d{12}:(user\/[\w-]+|role\/[\w-]+|\*)$/; export const validateAccountIds = z .string() diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index 0f414c62a..144691ac4 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -4,59 +4,66 @@ sidebarTitle: "What is Infisical?" description: "An Introduction to the Infisical secret management platform." --- -Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. -It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database -credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure +Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. +It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database +credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure sharing of secrets among engineers. Start managing secrets securely with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. - - Get started with Infisical Cloud in just a few minutes. - - - Self-host Infisical on your own infrastructure. - + + Get started with Infisical Cloud in just a few minutes. + + + Self-host Infisical on your own infrastructure. + -## Why Infisical? +## Why Infisical? + +Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: -Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: - Streamlined **local development** processes (switching .env files to [Infisical CLI](/cli/commands/run) and removing secrets from developer machines). -- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). -- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. -- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. +- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). +- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. +- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. - **Facilitated workflows** around [secret change management](/documentation/platform/pr-workflows), [access requests](/documentation/platform/access-controls/access-requests), [temporary access provisioning](/documentation/platform/access-controls/temporary-access), and more. - **Improved security posture** thanks to [secret scanning](/cli/scanning-overview), [granular access control policies](/documentation/platform/access-controls/overview), [automated secret rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview), and [dynamic secrets](/documentation/platform/dynamic-secrets/overview) capabilities. -## How does Infisical work? +## How does Infisical work? -To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. +To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. -**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. +**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. -As a result, the 3 main concepts that are important to understand are: -- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. +As a result, the 3 main concepts that are important to understand are: + +- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. - **[Clients](/integrations/platforms/kubernetes)**: Infisical-developed tools for managing secrets in various infrastructure components (e.g., [Kubernetes Operator](/integrations/platforms/kubernetes), [Infisical Agent](/integrations/platforms/infisical-agent), [CLI](/cli/usage), [SDKs](/sdks/overview), [API](/api-reference/overview/introduction), [Web Dashboard](/documentation/platform/organization)). -- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, etc.). +- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, AWS IAM Auth etc.). -## How to get started with Infisical? +## How to get started with Infisical? Depending on your use case, it might be helpful to look into some of the resources and guides provided below. - + Inject secrets into any application process/environment. Fetch secrets with any programming language on demand. - + Inject secrets into Docker containers. +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using AWS IAM Auth as they handle the +authentication process including the signed `GetCallerIdentity` query +construction for you. + +Also, note that Infisical needs network-level access to send requests to the AWS STS API +as part of the AWS IAM Auth workflow. + + ## Workflow -TODO +In the following steps, we explore how to create and use identities for your workloads and applications on AWS to +access the Infisical API using the AWS IAM authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **AWS IAM Auth**. + + ![identities create iam auth method](/images/platform/identities/identities-org-create-aws-iam-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Principal ARNs: A comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical. The values should take one of three forms: `arn:aws:iam::123456789012:user/MyUserName`, `arn:aws:iam::123456789012:role/MyRoleName`, or `arn:aws:iam::123456789012:*`. Using a wildcard in this case allows any IAM principal in the account `123456789012` to authenticate with Infisical under the identity. + - Allowed Account IDs: A comma-separated list of trusted AWS account IDs that are allowed to authenticate with Infisical. + - STS Endpoint (default is `https://sts.amazonaws.com/`): The endpoint URL for the AWS STS API. This is useful for AWS GovCloud or other AWS regions that have different STS endpoints. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to construct a signed `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html) and make a request to the `/api/v1/auth/aws-iam-auth/login` endpoint containing the query data + in exchange for an access token. + + We provide a few code examples below of how you can authenticate with Infisical from inside a Lambda function, EC2 instance, etc. and obtain an access token to access the [Infisical API](/api-reference/overview/introduction). + + + + The following query construction is an example of how you can authenticate with Infisical from inside a Lambda function. + + The shown example uses Node.js but you can use other languages supported by AWS Lambda. + + ```javascript + import AWS from "aws-sdk"; + import axios from "axios"; + + export const handler = async (event, context) => { + try { + const region = process.env.AWS_REGION; + AWS.config.update({ region }); + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com`, + }; + + // Create the request + const request = new AWS.HttpRequest(iamRequestURL, region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date + .iso8601(new Date()) + .replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = + Buffer.byteLength(iamRequestBody).toString(); + + // Sign the request + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `${infisicalUrl}/api/v1/auth/aws-iam-auth/login`, + { + identityId, + iamHttpRequestMethod: "POST", + iamRequestUrl: Buffer.from(iamRequestURL).toString("base64"), + iamRequestBody: Buffer.from(iamRequestBody).toString("base64"), + iamRequestHeaders: Buffer.from( + JSON.stringify(iamRequestHeaders) + ).toString("base64"), + } + ); + + console.log("result data: ", data); // access token here + } catch (err) { + console.error(err); + } + }; + ```` + + + The following query construction is an example of how you can authenticate with Infisical from inside a EC2 instance. + + The shown example uses Node.js but you can use other language you wish. + + ```javascript + import AWS from "aws-sdk"; + import axios from "axios"; + + const main = async () => { + try { + // obtain region from EC2 instance metadata + const tokenResponse = await axios.put("http://169.254.169.254/latest/api/token", null, { + headers: { + "X-aws-ec2-metadata-token-ttl-seconds": "21600" + } + }); + + const url = "http://169.254.169.254/latest/dynamic/instance-identity/document"; + const response = await axios.get(url, { + headers: { + "X-aws-ec2-metadata-token": tokenResponse.data + } + }); + + const region = response.data.region; + + AWS.config.update({ + region + }); + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com` + }; + + const request = new AWS.HttpRequest(new AWS.Endpoint(iamRequestURL), AWS.config.region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date.iso8601(new Date()).replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = Buffer.byteLength(iamRequestBody); + + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post(`${infisicalUrl}/api/v1/auth/aws-iam-auth/login`, { + identityId, + iamHttpRequestMethod: "POST", + iamRequestUrl: Buffer.from(iamRequestURL).toString("base64"), + iamRequestBody: Buffer.from(iamRequestBody).toString("base64"), + iamRequestHeaders: Buffer.from(JSON.stringify(iamRequestHeaders)).toString("base64") + }); + + console.log("result data: ", data); // access token here + } catch (err) { + console.error(err); + } + } + + main(); + ```` + + + The following query construction provides a generic example of how you can construct a signed `GetCallerIdentity` query and obtain the required payload components. + + The shown example uses Node.js but you can use any language you wish. + + ```javascript + const AWS = require("aws-sdk"); + + const region = ""; + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com` + }; + + const request = new AWS.HttpRequest(new AWS.Endpoint(iamRequestURL), region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date.iso8601(new Date()).replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = Buffer.byteLength(iamRequestBody); + ```` + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/aws-iam-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'iamHttpRequestMethod=...' \ + --data-urlencode 'iamRequestBody=...' \ + --data-urlencode 'iamRequestHeaders=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using AWS IAM Auth as they handle the authentication process including the signed `GetCallerIdentity` query construction for you. + + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index daa5db7ed..9168cb230 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -7,7 +7,7 @@ description: "Learn how to use Machine Identities to programmatically interact w An Infisical machine identity is an entity that represents a workload or application that require access to various resources in Infisical. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). -Each identity must authenticate with the API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) to get back a short-lived access token to be used in subsequent requests. +Each identity must authenticate using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS IAM Auth](/documentation/platform/identities/aws-iam-auth) to get back a short-lived access token to be used in subsequent requests. ![organization identities](/images/platform/organization/organization-machine-identities.png) @@ -21,7 +21,7 @@ Key Features: A typical workflow for using identities consists of four steps: 1. Creating the identity with a name and [role](/documentation/platform/role-based-access-controls) in Organization Access Control > Machine Identities. - This step also involves configuring an authentication method for it such as [Universal Auth](/documentation/platform/identities/universal-auth). + This step also involves configuring an authentication method for it such as [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS IAM Auth](/documentation/platform/identities/aws-iam-auth). 2. Adding the identity to the project(s) you want it to have access to. 3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. 4. Authenticating subsequent requests with the Infisical API using the short-lived access token. @@ -37,7 +37,8 @@ Machine Identity support for the rest of the clients is planned to be released i To interact with various resources in Infisical, Machine Identities are able to authenticate using: -- [Universal Auth](/documentation/platform/identities/universal-auth): the most versatile authentication method that can be configured on an identity from any platform/environment to access Infisical. +- [Universal Auth](/documentation/platform/identities/universal-auth): A platform-agnostic authentication method that can be configured on an identity suitable to authenticate from any platform/environment. +- [AWS IAM Auth](/documentation/platform/identities/aws-iam-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. ## FAQ diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 474d24476..cd40a9e64 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -3,17 +3,14 @@ title: Universal Auth description: "Learn how to authenticate to Infisical from any platform or environment." --- -**Universal Auth** is the most versatile authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) to access Infisical from any platform or environment. +**Universal Auth** is a platform-agnostic authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) suitable to authenticate from any platform/environment. -In this method, each identity is given a **Client ID** for which you can generate one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for an access token to authenticate with the Infisical API. +## Concept -## Properties +In this method, Infisical authenticates an identity by verifying the credentials issued for it at the `/api/v1/auth/universal-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. -Universal Auth supports many settings that can be beneficial for tightening your workflow security configuration: - -- Support for restrictions on the number of times that the **Client Secret(s)** and access token(s) can be used. -- Support for expiration, so, if specified, the **Client Secret** of the identity will automatically be defunct after a period of time. -- Support for IP allowlisting; this means you can restrict the usage of **Client Secret(s)** and access token to a specific IP or CIDR range. +In Universal Auth, an identity is given a **Client ID** and one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for a short-lived access token to authenticate with the Infisical API. ## Workflow @@ -89,16 +86,16 @@ using the Universal Auth authentication method. #### Sample request - ``` + ```bash Request curl --location --request POST 'https://app.infisical.com/api/v1/auth/universal-auth/login' \ --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'clientSecret=...' \ - --data-urlencode 'clientId=...' + --data-urlencode 'clientId=...' \ + --data-urlencode 'clientSecret=...' ``` #### Sample response - ``` + ```bash Response { "accessToken": "...", "expiresIn": 7200, diff --git a/docs/images/platform/identities/identities-org-create-aws-iam-auth-method.png b/docs/images/platform/identities/identities-org-create-aws-iam-auth-method.png new file mode 100644 index 0000000000000000000000000000000000000000..4b902c048a08b4078f66e53a66f2638724de2f86 GIT binary patch literal 550802 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%0DnUs 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

4@AM2#F?>zF{P4vMAUW)SNTLb_RZ( ztvRpYl)gj^=_wvdmC&$>`PFuA*;Fo8Nq;Khh4}t;!WgR`@;BCogf}_~^_tH#BA_w@ zdKaej*YNh;ri*V15uNc54I(({uSwQi6XkFEcfgm1tH$jWXnV3G>RKw@REiW?Xr8Bu zJmui9ks(@5ou)w4;~fHT!YxrwVRxL=FPi}cP~67RQME^Nm3$y;80=aEuBY!|&D1f% z-9@Ydj(#l0!+%~^N`C^ge>7VcAvc$(%#^b9&1!da4+l5j;Wz9@c!Nl=Ig-iq4*_PD z?460DuE>-RT9#s@Q!?mZ;UY>ir>B%%Ce_&2F7MuTIJrZot`h$Zf%k!U>Q6(4mbNz8 z&pNZ6FLByir42p{WP+@og{`pCvcIxhHWv5y8TWN;*Z4w$a!-GO}{!N;T9+ zxmY-2sTD7P_JS+rmPK~w08hebJwGU)J|`S|=%U=D*iDvF$AmCqXXB^8o|nE+PAy>< z;I9Ncg%0DX0u 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�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 @9&E!dOnPQ=pi_N zv+J_J%ju8CbF@}@BpB!W!01{iUtuqzCyGA90;m(qN=+#xIo^gl6%-Umm{?GKwj2~N zjdGcPUq{XUaQD-vO8O{d>?3eqzZINgJm}kdUeh$NFzq=TejxtSNE>OQ%e|Oiakuli zvu;+=Gx>zA{oTW1nfEWqbJ3pmUe5kKo zt~VC1Azo9syD2KYmAG*B+1**WunpQFS>;2gd$)b-`o4M_IuuK zDKW(?njyOIZr!lZP@>T*af7zD`9^spZQMF(A3hA?Kr|h-SqN|I0mA&Z{z0D7&o}=2f&UcNATJ8I|SKo&ryqAJ%I6BSlHP;8tjxa1ipD~ z+~$fDaR(V)$K)(le$t>Yu?tZo`j2V(sw@+N;#&gR_)Mb z{TlGelzrEwaJ(vRxSmtW%p7Fj*DgWq|4rgsk#ggO4?FAuyEl$Bq_d)8W96o_oP_Jo z_PX`YPBQ#mf{zxhW4b<5ePBs(^B{SS-I-TS`^G(X-{GGRFnl!m8AMt*ZSh3YXZ=$y%XD=RAudI^85 z0lft?7*ws`ygbGGQod~zeY73hOHv}lJl7`@%`dEC1iuTo7lCZ_Zgm;Ako}7n17+Mr zp-5IrGX4eiaq4_57QL-c3=vxk_XZX=dLbLs&aFB{O^=k1fTxjqLIn2gOr>3k+bDQ$myX#{WzTV<5KMF?2ul>rf*e;>Rmlxc zGi73#V&^V6XnG!P8A)-mHMLPJu8x&jcypxUJ1Lx&^KlKHfhv+)J@2KWNZ=h)SaU-N z@hCBw1l79NdnLmcq^hvvrFclhzGkU(=-pc2&+H?3U7F0@ot@S?=X2Nlc0VQlhe{9w zm|6WaqqFyoZRI&8{{}06sy4ky-I&#A)n^ulq4)^_V$G&6M* z*&^d|j-9V|6Bw*Cj>6yS%<$a{KV+Xkk6UMat*IiM?6v;~Hxihp20&iguUR|czmwd* zv)v64Y*lcV>|~5xn!ypPgcOQ6#~~h~=Sf!t3AfR{+GS1K$<-Uo0}!J<`Ha6H-A%p# z$5p^&dgk|YW-mT9e%0=-3HHWK9A+D4d+RyLyHZY%^+RFL-IjL2l5gbj{d1kCER|b6 zHS53+?v2#sG-rCAvadx&3Ao2Bewd$WlRQ5gX}Uk^{cvQ+!hm%(N`u;kDbZznk>u%e z!atUBsu(~&D#EZU(plHq(sI{JEi}{uge^74_a8{^1vFKuWRIyf#rlHdcM3QjF7UYI z7&|teBqo|=`@f4mv~pp8z(O4B;90>(r&wOoUQg1iEEK9?Cl_7@dzzlBbv!|B7tX!ub{ z(}SOhMz5<(CyqQ(z?Hξ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

b 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 G8~+E)IYSHp literal 0 HcmV?d00001 diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx index 416d70482..8528a5c30 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsIamAuthForm.tsx @@ -183,7 +183,11 @@ export const IdentityAwsIamAuthForm = ({ defaultValue="2592000" name="allowedPrincipalArns" render={({ field, fieldState: { error } }) => ( - + Date: Mon, 6 May 2024 14:00:32 +0530 Subject: [PATCH 016/244] fix(ui): secret path input showing / for a valid value that comes delayed --- .../v2/SecretPathInput/SecretPathInput.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 1487cb302..ed97ad8dd 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -46,14 +46,6 @@ export const SecretPathInput = ({ setInputValue(propValue ?? "/"); }, [propValue]); - useEffect(() => { - if (environment) { - setInputValue("/"); - setSecretPath("/"); - onChange?.("/"); - } - }, [environment]); - useEffect(() => { // update secret path if input is valid if ( @@ -158,9 +150,8 @@ export const SecretPathInput = ({ key={`secret-reference-secret-${i + 1}`} >

From 4692aa12bd44c294b0f5bd9b7fd425718cb41b40 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 6 May 2024 14:01:30 +0530 Subject: [PATCH 017/244] feat: updated identity additional privilege permission object in api to have a proper body and explanation --- ...ity-project-additional-privilege-router.ts | 43 ++++------ ...ty-project-additional-privilege-service.ts | 79 ++++++++++++++++--- backend/src/lib/api-docs/constants.ts | 31 ++++++-- backend/src/server/routes/sanitizedSchemas.ts | 22 ++++++ frontend/package-lock.json | 13 +-- .../queries.tsx | 20 ++--- frontend/src/hooks/api/roles/types.ts | 2 +- .../SpecificPrivilegeSection.tsx | 4 +- 8 files changed, 146 insertions(+), 68 deletions(-) diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index a1a2e36fa..0fecc9d2e 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -1,16 +1,14 @@ -import { MongoAbility, RawRuleOf } from "@casl/ability"; -import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; +import { packRules } from "@casl/ability/extra"; import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; -import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; -import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission"; import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { PermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { @@ -41,11 +39,11 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + permissions: PermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -92,7 +90,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + permissions: PermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), @@ -107,7 +105,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -157,7 +155,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F message: "Slug must be a valid slug" }) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + permissions: PermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) @@ -175,7 +173,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -219,7 +217,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -260,7 +258,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -293,16 +291,11 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ], querystring: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.identityId), - projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug), - unpacked: z - .enum(["false", "true"]) - .transform((el) => el === "true") - .default("true") - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.unpacked) + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug) }), response: { 200: z.object({ - privileges: IdentityProjectAdditionalPrivilegeSchema.array() + privileges: SanitizedIdentityPrivilegeSchema.array() }) } }, @@ -315,15 +308,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, ...req.query }); - if (req.query.unpacked) { - return { - privileges: privileges.map(({ permissions, ...el }) => ({ - ...el, - permissions: unpackRules(permissions as PackRule>>[]) - })) - }; - } - return { privileges }; + return { + privileges + }; } }); }; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 81dc11a00..e4c2c690d 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -1,5 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, unpackRules } from "@casl/ability/extra"; import ms from "ms"; +import { z } from "zod"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; @@ -8,7 +10,7 @@ import { TIdentityProjectDALFactory } from "@app/services/identity-project/ident import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { ProjectPermissionActions, ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal"; import { IdentityProjectAdditionalPrivilegeTemporaryMode, @@ -30,6 +32,22 @@ export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< typeof identityProjectAdditionalPrivilegeServiceFactory >; +// TODO(akhilmhdh): move this to more centralized +export const UnpackedPermissionSchema = z.object({ + subject: z.union([z.string().min(1), z.string().array()]).optional(), + action: z.union([z.string().min(1), z.string().array()]), + conditions: z + .object({ + environment: z.string().optional(), + secretPath: z + .object({ + $glob: z.string().min(1) + }) + .optional() + }) + .optional() +}); + export const identityProjectAdditionalPrivilegeServiceFactory = ({ identityProjectAdditionalPrivilegeDAL, identityProjectDAL, @@ -86,7 +104,14 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ slug, permissions: customPermission }); - return additionalPrivilege; + return { + ...additionalPrivilege, + permissions: UnpackedPermissionSchema.array().parse( + unpackRules( + (additionalPrivilege.permissions || []) as PackRule>>[] + ) + ) + }; } const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); @@ -100,7 +125,14 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) }); - return additionalPrivilege; + return { + ...additionalPrivilege, + permissions: UnpackedPermissionSchema.array().parse( + unpackRules( + (additionalPrivilege.permissions || []) as PackRule>>[] + ) + ) + }; }; const updateBySlug = async ({ @@ -163,7 +195,14 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) }); - return additionalPrivilege; + return { + ...additionalPrivilege, + permissions: UnpackedPermissionSchema.array().parse( + unpackRules( + (additionalPrivilege.permissions || []) as PackRule>>[] + ) + ) + }; } const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { @@ -174,7 +213,14 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryRange: null, temporaryMode: null }); - return additionalPrivilege; + return { + ...additionalPrivilege, + permissions: UnpackedPermissionSchema.array().parse( + unpackRules( + (additionalPrivilege.permissions || []) as PackRule>>[] + ) + ) + }; }; const deleteBySlug = async ({ @@ -220,7 +266,12 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); - return deletedPrivilege; + return { + ...deletedPrivilege, + permissions: UnpackedPermissionSchema.array().parse( + unpackRules((deletedPrivilege.permissions || []) as PackRule>>[]) + ) + }; }; const getPrivilegeDetailsBySlug = async ({ @@ -254,7 +305,12 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); - return identityPrivilege; + return { + ...identityPrivilege, + permissions: UnpackedPermissionSchema.array().parse( + unpackRules((identityPrivilege.permissions || []) as PackRule>>[]) + ) + }; }; const listIdentityProjectPrivileges = async ({ @@ -284,7 +340,12 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ projectMembershipId: identityProjectMembership.id }); - return identityPrivileges; + return identityPrivileges.map((el) => ({ + ...el, + permissions: UnpackedPermissionSchema.array().parse( + unpackRules((el.permissions || []) as PackRule>>[]) + ) + })); }; return { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index efcb03bd3..10006b9ef 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -468,9 +468,18 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { identityId: "The ID of the identity to delete.", slug: "The slug of the privilege to create.", permissions: `The permission object for the privilege. -1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]] -2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]] -2. [["read", "secrets", {environment: "dev"}]] +- Read secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"]} +\`\`\` +- Read and Write secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"], {"action": "write", "subject": "secrets"]} +\`\`\` +- Read secrets scoped to an environment and secret path +\`\`\` +- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } +\`\`\` `, isPackPermission: "Whether the server should pack(compact) the permission object.", isTemporary: "Whether the privilege is temporary.", @@ -484,11 +493,19 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", permissions: `The permission object for the privilege. -1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]] -2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]] -2. [["read", "secrets", {environment: "dev"}]] +- Read secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"]} +\`\`\` +- Read and Write secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"], {"action": "write", "subject": "secrets"]} +\`\`\` +- Read secrets scoped to an environment and secret path +\`\`\` +- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } +\`\`\` `, - isPackPermission: "Whether the server should pack(compact) the permission object.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index a0b792789..04f0a1166 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { DynamicSecretsSchema, + IdentityProjectAdditionalPrivilegeSchema, IntegrationAuthsSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; // sometimes the return data must be santizied to avoid leaking important values // always prefer pick over omit in zod @@ -62,6 +64,26 @@ export const secretRawSchema = z.object({ secretComment: z.string().optional() }); +export const PermissionSchema = z.object({ + action: z.string().min(1).describe("Describe what user can actually do. Ex: create, edit, delete, read"), + subject: z.string().min(1).describe("The entity to check action on. Ex: secrets, environments"), + conditions: z + .object({ + environment: z.string().describe("To scope the permission to an environment.").optional(), + secretPath: z + .object({ + $glob: z.string().min(1).describe("To scope the permission to a secret path. Supports glob patterns.") + }) + .optional() + }) + .describe("Criteria which restricts user action only to matched subjects") + .optional() +}); + +export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7c587f13..c33c9dc36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -12165,9 +12166,9 @@ "dev": true }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -22439,9 +22440,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dev": true, "dependencies": { "chownr": "^2.0.0", diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx index 72534c158..e4bd141fb 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx @@ -1,9 +1,7 @@ -import { PackRule, unpackRules } from "@casl/ability/extra"; import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TProjectPermission } from "../roles/types"; import { TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails, TIdentityProjectPrivilege, @@ -36,17 +34,14 @@ export const useGetIdentityProjectPrivilegeDetails = ({ const { data: { privilege } } = await apiRequest.get<{ - privilege: Omit & { permissions: unknown }; + privilege: TIdentityProjectPrivilege; }>(`/api/v1/additional-privilege/identity/${privilegeSlug}`, { params: { identityId, projectSlug } }); - return { - ...privilege, - permissions: unpackRules(privilege.permissions as PackRule[]) - }; + return privilege; } }); }; @@ -62,16 +57,11 @@ export const useListIdentityProjectPrivileges = ({ const { data: { privileges } } = await apiRequest.get<{ - privileges: Array< - Omit & { permissions: unknown } - >; + privileges: Array; }>("/api/v1/additional-privilege/identity", { - params: { identityId, projectSlug, unpacked: false } + params: { identityId, projectSlug } }); - return privileges.map((el) => ({ - ...el, - permissions: unpackRules(el.permissions as PackRule[]) - })); + return privileges; } }); }; diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 5f205e585..97a90b421 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -41,7 +41,7 @@ export type TPermission = { export type TProjectPermission = { conditions?: Record; action: string; - subject: [string]; + subject: string | string[]; }; export type TGetUserOrgPermissionsDTO = { diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx index 56248145b..44feb73d9 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -142,7 +142,7 @@ const SpecificPrivilegeSecretForm = ({ .filter(({ allowed }) => allowed) .map(({ action }) => ({ action, - subject: [ProjectPermissionSub.Secrets], + subject: ProjectPermissionSub.Secrets, conditions })) }, @@ -477,7 +477,7 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { permissions: [ { action: ProjectPermissionActions.Read, - subject: [ProjectPermissionSub.Secrets], + subject: ProjectPermissionSub.Secrets, conditions: { environment: currentWorkspace?.environments?.[0].slug } From 86fd4d5fba3ad02acdd701a538dec04874f83e5f Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 6 May 2024 14:26:46 +0530 Subject: [PATCH 018/244] feat: added a fixed sorted order to avoid jumps --- .../components/IdentityRoleForm/SpecificPrivilegeSection.tsx | 1 + .../MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx index 44feb73d9..6c1d4654d 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -512,6 +512,7 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { ?.filter(({ permissions }) => permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) ) + .sort((a, b) => a.id.localeCompare(b.id)) ?.map((privilege) => ( { ?.filter(({ permissions }) => permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) ) + .sort((a, b) => a.id.localeCompare(b.id)) ?.map((privilege) => ( Date: Mon, 6 May 2024 20:43:23 +0530 Subject: [PATCH 019/244] feat: debounced main page search and rolled back to old input component --- frontend/src/lib/fn/debounce.ts | 15 +++++++++++ .../pages/project/[id]/secrets/v2/[env].tsx | 27 ------------------- .../components/ActionBar/ActionBar.tsx | 12 +++++++-- .../components/SecretListView/SecretItem.tsx | 10 +++---- 4 files changed, 28 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/fn/debounce.ts delete mode 100644 frontend/src/pages/project/[id]/secrets/v2/[env].tsx diff --git a/frontend/src/lib/fn/debounce.ts b/frontend/src/lib/fn/debounce.ts new file mode 100644 index 000000000..16d7b4ceb --- /dev/null +++ b/frontend/src/lib/fn/debounce.ts @@ -0,0 +1,15 @@ +export const debounce = any>( + func: F, + delay: number +): ((...args: Parameters) => void) => { + let timeoutId: ReturnType | null; + return function debounced(...args: Parameters) { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + func(...args); + timeoutId = null; + }, delay); + }; +}; diff --git a/frontend/src/pages/project/[id]/secrets/v2/[env].tsx b/frontend/src/pages/project/[id]/secrets/v2/[env].tsx deleted file mode 100644 index bba0704dd..000000000 --- a/frontend/src/pages/project/[id]/secrets/v2/[env].tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useTranslation } from "react-i18next"; -import Head from "next/head"; - -import { SecretMainPage } from "@app/views/SecretMainPage"; - -const Dashboard = () => { - const { t } = useTranslation(); - - return ( - <> - - {t("common.head-title", { title: t("dashboard.title") })} - - - - - -
- -
- - ); -}; - -export default Dashboard; - -Dashboard.requireAuth = true; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index f670ea496..61c7cf8c0 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { subject } from "@casl/ability"; import { faAngleDown, @@ -45,6 +46,7 @@ import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from import { usePopUp } from "@app/hooks"; import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api"; import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types"; +import { debounce } from "@app/lib/fn/debounce"; import { PopUpNames, @@ -106,6 +108,7 @@ export const ActionBar = ({ ] as const); const { subscription } = useSubscription(); const { openPopUp } = usePopUpAction(); + const [search, setSearch] = useState(filter.searchFilter); const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); @@ -114,6 +117,8 @@ export const ActionBar = ({ const { reset: resetSelectedSecret } = useSelectedSecretActions(); const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); + const debouncedOnSearch = debounce(onSearchChange, 500); + const handleFolderCreate = async (folderName: string) => { try { await createFolder({ @@ -199,8 +204,11 @@ export const ActionBar = ({ className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" placeholder="Search by folder name, key name, comment..." leftIcon={} - value={filter.searchFilter} - onChange={(evt) => onSearchChange(evt.target.value)} + value={search} + onChange={(evt) => { + setSearch(evt.target.value); + debouncedOnSearch(evt.target.value); + }} />
diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index 455b7bbe6..7996d932c 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -14,6 +14,7 @@ import { Popover, PopoverContent, PopoverTrigger, + SecretInput, Spinner, TextArea, Tooltip @@ -48,7 +49,6 @@ import { memo, useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; -import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { CreateReminderForm } from "./CreateReminderForm"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -263,12 +263,10 @@ export const SecretItem = memo( key="value-overriden" control={control} render={({ field }) => ( - @@ -280,12 +278,10 @@ export const SecretItem = memo( key="secret-value" control={control} render={({ field }) => ( - From b3579cb2719e79ed252b85ea8b40a9f38cc60d3e Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Mon, 6 May 2024 13:44:39 -0400 Subject: [PATCH 020/244] rephrase text for permission schema zod --- backend/src/server/routes/sanitizedSchemas.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 04f0a1166..14155ecf9 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -65,18 +65,27 @@ export const secretRawSchema = z.object({ }); export const PermissionSchema = z.object({ - action: z.string().min(1).describe("Describe what user can actually do. Ex: create, edit, delete, read"), - subject: z.string().min(1).describe("The entity to check action on. Ex: secrets, environments"), + action: z + .string() + .min(1) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read"), + subject: z + .string() + .min(1) + .describe("The entity this permission pertains to. Possible options: secrets, environments"), conditions: z .object({ - environment: z.string().describe("To scope the permission to an environment.").optional(), + environment: z.string().describe("The environment slug this permission should allow.").optional(), secretPath: z .object({ - $glob: z.string().min(1).describe("To scope the permission to a secret path. Supports glob patterns.") + $glob: z + .string() + .min(1) + .describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ") }) .optional() }) - .describe("Criteria which restricts user action only to matched subjects") + .describe("When specified, only matching conditions will be allowed to access given resource.") .optional() }); From d198ba1a794814d566cdfb107c495bb842049d4c Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 6 May 2024 23:27:24 +0530 Subject: [PATCH 021/244] feat: refactored the map unpack to a function --- ...ty-project-additional-privilege-service.ts | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index e4c2c690d..70753ee09 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -48,6 +48,11 @@ export const UnpackedPermissionSchema = z.object({ .optional() }); +const unpackPermissions = (permissions: unknown) => + UnpackedPermissionSchema.array().parse( + unpackRules((permissions || []) as PackRule>>[]) + ); + export const identityProjectAdditionalPrivilegeServiceFactory = ({ identityProjectAdditionalPrivilegeDAL, identityProjectDAL, @@ -106,11 +111,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); return { ...additionalPrivilege, - permissions: UnpackedPermissionSchema.array().parse( - unpackRules( - (additionalPrivilege.permissions || []) as PackRule>>[] - ) - ) + permissions: unpackPermissions(additionalPrivilege.permissions) }; } @@ -127,11 +128,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); return { ...additionalPrivilege, - permissions: UnpackedPermissionSchema.array().parse( - unpackRules( - (additionalPrivilege.permissions || []) as PackRule>>[] - ) - ) + permissions: unpackPermissions(additionalPrivilege.permissions) }; }; @@ -197,11 +194,8 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); return { ...additionalPrivilege, - permissions: UnpackedPermissionSchema.array().parse( - unpackRules( - (additionalPrivilege.permissions || []) as PackRule>>[] - ) - ) + + permissions: unpackPermissions(additionalPrivilege.permissions) }; } @@ -215,11 +209,8 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); return { ...additionalPrivilege, - permissions: UnpackedPermissionSchema.array().parse( - unpackRules( - (additionalPrivilege.permissions || []) as PackRule>>[] - ) - ) + + permissions: unpackPermissions(additionalPrivilege.permissions) }; }; @@ -268,9 +259,8 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); return { ...deletedPrivilege, - permissions: UnpackedPermissionSchema.array().parse( - unpackRules((deletedPrivilege.permissions || []) as PackRule>>[]) - ) + + permissions: unpackPermissions(deletedPrivilege.permissions) }; }; @@ -307,9 +297,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ return { ...identityPrivilege, - permissions: UnpackedPermissionSchema.array().parse( - unpackRules((identityPrivilege.permissions || []) as PackRule>>[]) - ) + permissions: unpackPermissions(identityPrivilege.permissions) }; }; @@ -342,9 +330,8 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); return identityPrivileges.map((el) => ({ ...el, - permissions: UnpackedPermissionSchema.array().parse( - unpackRules((el.permissions || []) as PackRule>>[]) - ) + + permissions: unpackPermissions(el.permissions) })); }; From 3b88a2759b97bb1e7dcd69bef3771b40266e3046 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 18:27:36 -0700 Subject: [PATCH 022/244] 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 6730e9101..e146668c2 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 81543c85e..6001b8b6e 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 7283b8fe1..8965c28f3 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 e308891f9..4f96ddbf0 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 d799b1754..6773c9486 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 b7e9feb7b..aa4aa8da7 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 42e77e431..7dfd211e1 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 0e84ff666..92ee32b5c 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 c9cc6e1ed..9a084c6d7 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 e804d51a5..46ab90b8f 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 fd095389a..aeb66d93f 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 5aea53786..be7f5777d 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 4dc76b612..1e52053b2 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 a666e6a03..d7ee1ce93 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 6d148d03b..e12114fef 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 81ecd6da1..8f1e3fc3f 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 6d6bf274a..089f3b8c6 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 aa4841625..5e4253a34 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 0b40d8b3a..b92b52bb9 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 2423be8c0..4d6c75e15 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 c81141c92..b0ac046d0 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 6064f26e8..9ab0acc3a 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 88021ab44..2510c9fa4 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 0b089e6fa60c6467e60a0a1f23d767f415b59bec Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 18:35:34 -0700 Subject: [PATCH 023/244] Update aws iam auth fns filename --- ...dentity-aws-iam-auth.fns.ts => identity-aws-iam-auth-fns.ts} | 0 .../identity-aws-iam-auth/identity-aws-iam-auth-service.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename backend/src/services/identity-aws-iam-auth/{identity-aws-iam-auth.fns.ts => identity-aws-iam-auth-fns.ts} (100%) diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth.fns.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-fns.ts similarity index 100% rename from backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth.fns.ts rename to backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-fns.ts diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts index 66dc2c21a..95deba2d8 100644 --- a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts +++ b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts @@ -16,8 +16,8 @@ import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; -import { extractPrincipalArn } from "./identity-aws-iam-auth.fns"; import { TIdentityAwsIamAuthDALFactory } from "./identity-aws-iam-auth-dal"; +import { extractPrincipalArn } from "./identity-aws-iam-auth-fns"; import { TAttachAWSIAMAuthDTO, TAWSGetCallerIdentityHeaders, From ba1b223655819996b2509e17de7f8e0585c01602 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 19:44:43 -0700 Subject: [PATCH 024/244] 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 8e7fd8b8b..950f78e58 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 025/244] 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 59d7b3d41..000000000 --- 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 950f78e58..63aa75ad8 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"); From 308e605b6c8c791a262207ce855cb599e04acb49 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 7 May 2024 03:28:10 +0000 Subject: [PATCH 026/244] chore: renamed new migration files to latest timestamp (gh-action) --- ...-ldap-emails.ts => 20240507032811_trusted-saml-ldap-emails.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240506163405_trusted-saml-ldap-emails.ts => 20240507032811_trusted-saml-ldap-emails.ts} (100%) diff --git a/backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts similarity index 100% rename from backend/src/db/migrations/20240506163405_trusted-saml-ldap-emails.ts rename to backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts From a3552d00d1e11d93918ad151fb3310a78c294083 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 13:52:42 +0800 Subject: [PATCH 027/244] feat: add multi-select in secret overview --- .../pages/project/[id]/secrets/overview.tsx | 5 +- .../SecretOverviewPage.store.tsx | 78 +++++++++++++++++++ .../SecretOverviewPage/SecretOverviewPage.tsx | 10 +++ .../SecretOverviewFolderRow.tsx | 23 +++++- .../SecretOverviewTableRow.tsx | 24 +++++- .../SelectionPanel/SelectionPanel.tsx | 78 +++++++++++++++++++ 6 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx create mode 100644 frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx diff --git a/frontend/src/pages/project/[id]/secrets/overview.tsx b/frontend/src/pages/project/[id]/secrets/overview.tsx index 69e2bc196..eaf915120 100644 --- a/frontend/src/pages/project/[id]/secrets/overview.tsx +++ b/frontend/src/pages/project/[id]/secrets/overview.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import Head from "next/head"; import { SecretOverviewPage } from "@app/views/SecretOverviewPage"; +import { StoreProvider } from "@app/views/SecretOverviewPage/SecretOverviewPage.store"; const Dashboard = () => { const { t } = useTranslation(); @@ -16,7 +17,9 @@ const Dashboard = () => {
- + + +
); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx new file mode 100644 index 000000000..df239d6ae --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx @@ -0,0 +1,78 @@ +import { createContext, ReactNode, useContext, useEffect, useRef } from "react"; +import { useRouter } from "next/router"; +import { createStore, StateCreator, StoreApi, useStore } from "zustand"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +type SelectedEntriesState = { + selectedEntries: { + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }; + action: { + toggle: (type: EntryType, key: string) => void; + reset: () => void; + }; +}; + +const createSelectedSecretStore: StateCreator = (set) => ({ + selectedEntries: { + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }, + action: { + toggle: (type: EntryType, key: string) => + set((state) => { + const isChecked = Boolean(state.selectedEntries[type]?.[key]); + const newChecks = { ...state.selectedEntries }; + // remove selection if its present else add it + if (isChecked) delete newChecks[type][key]; + else newChecks[type][key] = true; + return { selectedEntries: newChecks }; + }), + reset: () => + set({ + selectedEntries: { + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + } + }) + } +}); + +const StoreContext = createContext | null>(null); +export const StoreProvider = ({ children }: { children: ReactNode }) => { + const storeRef = useRef>(); + const router = useRouter(); + if (!storeRef.current) { + storeRef.current = createStore((...a) => ({ + ...createSelectedSecretStore(...a) + })); + } + + useEffect(() => { + const onRouteChangeStart = () => { + const state = storeRef.current?.getState(); + state?.action.reset(); + }; + + router.events.on("routeChangeStart", onRouteChangeStart); + return () => { + router.events.off("routeChangeStart", onRouteChangeStart); + }; + }, []); + + return {children}; +}; + +const useStoreContext = (selector: (state: SelectedEntriesState) => T): T => { + const ctx = useContext(StoreContext); + if (!ctx) throw new Error("Missing "); + return useStore(ctx, selector); +}; + +export const useSelectedEntries = () => useStoreContext((state) => state.selectedEntries); +export const useSelectedEntryActions = () => useStoreContext((state) => state.action); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 692bdcbe3..c6d97361a 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -70,6 +70,8 @@ import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSect import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; +import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; +import { EntryType, useSelectedEntries, useSelectedEntryActions } from "./SecretOverviewPage.store"; export const SecretOverviewPage = () => { const { t } = useTranslation(); @@ -105,6 +107,9 @@ export const SecretOverviewPage = () => { const [searchFilter, setSearchFilter] = useState(""); const secretPath = (router.query?.secretPath as string) || "/"; + const selectedEntries = useSelectedEntries(); + const { toggle: toggleSelectedEntry } = useSelectedEntryActions(); + useEffect(() => { if (!isWorkspaceLoading && !workspaceId && router.isReady) { router.push(`/org/${currentOrg?.id}/overview`); @@ -543,6 +548,7 @@ export const SecretOverviewPage = () => {
+
@@ -666,6 +672,8 @@ export const SecretOverviewPage = () => { toggleSelectedEntry(EntryType.FOLDER, folderName)} environments={visibleEnvs} key={`overview-${folderName}-${index + 1}`} onClick={handleFolderClick} @@ -684,6 +692,8 @@ export const SecretOverviewPage = () => { visibleEnvs?.length > 0 && filteredSecretNames.map((key, index) => ( toggleSelectedEntry(EntryType.SECRET, key)} secretPath={secretPath} isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} onSecretCreate={handleSecretCreate} diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx index 597541fe7..c60aff912 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx @@ -2,20 +2,23 @@ import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Td, Tr } from "@app/components/v2"; +import { Checkbox, Td, Tr } from "@app/components/v2"; type Props = { folderName: string; environments: { name: string; slug: string }[]; isFolderPresentInEnv: (name: string, env: string) => boolean; onClick: (path: string) => void; + isSelected: boolean; + onToggleFolderSelect: (folderName: string) => void; }; export const SecretOverviewFolderRow = ({ folderName, environments = [], isFolderPresentInEnv, - + isSelected, + onToggleFolderSelect, onClick }: Props) => { return ( @@ -23,7 +26,21 @@ export const SecretOverviewFolderRow = ({ + + + + + + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx new file mode 100644 index 000000000..56a4bdef5 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -0,0 +1,250 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + workspaceId: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z + .object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional().nullable(), + approvals: z.number().min(1), + approvers: z.string().array().min(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + workspaceId, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined + }); + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + try { + await createAccessApprovalPolicy({ + ...data, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!editValues?.id) return; + try { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + + Select members that are allowed to approve changes + + {members.map(({ id, user }) => { + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx new file mode 100644 index 000000000..f6db07c94 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { AccessApprovalPolicyList } from "./AccessApprovalPolicyList"; From d33711880326a5698305a5e5b51b88ab0b11283e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:25:58 -0700 Subject: [PATCH 039/244] Feat: Request access --- backend/src/@types/fastify.d.ts | 2 ++ backend/src/@types/knex.d.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 12c07486f..71a7c887c 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -2,6 +2,7 @@ import "fastify"; import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; +import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; @@ -115,6 +116,7 @@ declare module "fastify" { identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; + accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; secretApprovalRequest: TSecretApprovalRequestServiceFactory; secretRotation: TSecretRotationServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index b6e8744ed..14ebd4295 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -8,6 +8,12 @@ import { TAccessApprovalPoliciesApproversUpdate, TAccessApprovalPoliciesInsert, TAccessApprovalPoliciesUpdate, + TAccessApprovalRequests, + TAccessApprovalRequestsInsert, + TAccessApprovalRequestsReviewers, + TAccessApprovalRequestsReviewersInsert, + TAccessApprovalRequestsReviewersUpdate, + TAccessApprovalRequestsUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, @@ -363,6 +369,18 @@ declare module "knex/types/tables" { TAccessApprovalPoliciesApproversUpdate >; + [TableName.AccessApprovalRequest]: Knex.CompositeTableType< + TAccessApprovalRequests, + TAccessApprovalRequestsInsert, + TAccessApprovalRequestsUpdate + >; + + [TableName.AccessApprovalRequestReviewer]: Knex.CompositeTableType< + TAccessApprovalRequestsReviewers, + TAccessApprovalRequestsReviewersInsert, + TAccessApprovalRequestsReviewersUpdate + >; + [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, From a528d011c0c3f8999dfc6bdfc2009ea46a08d0f4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:35:51 -0700 Subject: [PATCH 040/244] Feat: Request Access (migrations) --- .../20240330075122_access-approval-policy.ts | 3 +- ...20240401173320_access_approval_requests.ts | 51 +++++++++++++++++++ .../db/schemas/access-approval-policies.ts | 1 + .../access-approval-requests-reviewers.ts | 26 ++++++++++ .../db/schemas/access-approval-requests.ts | 26 ++++++++++ backend/src/db/schemas/index.ts | 2 + 6 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/20240401173320_access_approval_requests.ts create mode 100644 backend/src/db/schemas/access-approval-requests-reviewers.ts create mode 100644 backend/src/db/schemas/access-approval-requests.ts diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 0595d5c6e..8203fb333 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -10,6 +10,7 @@ export async function up(knex: Knex): Promise { t.string("name").notNullable(); t.integer("approvals").defaultTo(1).notNullable(); t.uuid("envId").notNullable(); + t.string("secretPath"); t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); t.timestamps(true, true, true); }); @@ -31,8 +32,8 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); } diff --git a/backend/src/db/migrations/20240401173320_access_approval_requests.ts b/backend/src/db/migrations/20240401173320_access_approval_requests.ts new file mode 100644 index 000000000..901be9a78 --- /dev/null +++ b/backend/src/db/migrations/20240401173320_access_approval_requests.ts @@ -0,0 +1,51 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalRequest))) { + await knex.schema.createTable(TableName.AccessApprovalRequest, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + + t.uuid("privilegeId").nullable(); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE"); + + t.uuid("requestedBy").notNullable(); + t.foreign("requestedBy").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + + // We use these values to create the actual privilege at a later point in time. + t.boolean("isTemporary").notNullable(); + t.string("temporaryRange").nullable(); + + t.jsonb("permissions").notNullable(); + + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.AccessApprovalRequest); + + if (!(await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer))) { + await knex.schema.createTable(TableName.AccessApprovalRequestReviewer, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("member").notNullable(); + t.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.string("status").notNullable(); + t.uuid("requestId").notNullable(); + t.foreign("requestId").references("id").inTable(TableName.AccessApprovalRequest).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer); + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest); + + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index 550049702..bf7e74ff2 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -12,6 +12,7 @@ export const AccessApprovalPoliciesSchema = z.object({ name: z.string(), approvals: z.number().default(1), envId: z.string().uuid(), + secretPath: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/db/schemas/access-approval-requests-reviewers.ts b/backend/src/db/schemas/access-approval-requests-reviewers.ts new file mode 100644 index 000000000..509fd7425 --- /dev/null +++ b/backend/src/db/schemas/access-approval-requests-reviewers.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalRequestsReviewersSchema = z.object({ + id: z.string().uuid(), + member: z.string().uuid(), + status: z.string(), + requestId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalRequestsReviewers = z.infer; +export type TAccessApprovalRequestsReviewersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalRequestsReviewersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts new file mode 100644 index 000000000..bd598bac6 --- /dev/null +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalRequestsSchema = z.object({ + id: z.string().uuid(), + policyId: z.string().uuid(), + privilegeId: z.string().uuid().nullable().optional(), + requestedBy: z.string().uuid(), + isTemporary: z.boolean(), + temporaryRange: z.string().nullable().optional(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalRequests = z.infer; +export type TAccessApprovalRequestsInsert = Omit, TImmutableDBKeys>; +export type TAccessApprovalRequestsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index f02188577..0fd7c5271 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,5 +1,7 @@ export * from "./access-approval-policies"; export * from "./access-approval-policies-approvers"; +export * from "./access-approval-requests"; +export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; export * from "./audit-log-streams"; export * from "./audit-logs"; From f981c59b5c259b47cf6ef97dc4e38e585049c82b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:36:11 -0700 Subject: [PATCH 041/244] Feat: Request access (models) --- backend/src/db/schemas/models.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 8e66cabe4..f401e822c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -54,6 +54,8 @@ export enum TableName { // New tables so far AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", + AccessApprovalRequest = "access_approval_requests", + AccessApprovalRequestReviewer = "access_approval_requests_reviewers", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", From 99f5ed1f4bec5bff9e972286ad24d33940ca640c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:36:47 -0700 Subject: [PATCH 042/244] Fix: Move to project slug --- .../v1/access-approval-policy-router.ts | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index ec5331a89..8a8090042 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -12,8 +12,9 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi schema: { body: z .object({ - workspaceId: z.string(), + projectSlug: z.string().trim(), name: z.string().optional(), + secretPath: z.string().trim().default("/"), environment: z.string(), approvers: z.string().array().min(1), approvals: z.number().min(1).default(1) @@ -35,8 +36,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.body.workspaceId, ...req.body, + projectSlug: req.body.projectSlug, name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` }); return { approval }; @@ -48,11 +49,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi method: "GET", schema: { querystring: z.object({ - workspaceId: z.string().trim() + projectSlug: z.string().trim() }), response: { 200: z.object({ - approvals: sapPubSchema.merge(z.object({ approvers: z.string().array() })).array() + approvals: sapPubSchema.extend({ approvers: z.string().array(), secretPath: z.string().optional() }).array() }) } }, @@ -63,12 +64,41 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.query.workspaceId + projectSlug: req.query.projectSlug }); return { approvals }; } }); + server.route({ + url: "/policy-count", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string(), + envSlug: z.string() + }), + response: { + 200: z.object({ + policyCount: z.number() + }) + } + }, + + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { policyCount } = await server.services.accessApprovalPolicy.getAccessPolicyCountByEnvSlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + projectSlug: req.query.projectSlug, + actorOrgId: req.permission.orgId, + envSlug: req.query.envSlug + }); + return { policyCount }; + } + }); + server.route({ url: "/:policyId", method: "PATCH", @@ -79,6 +109,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi body: z .object({ name: z.string().optional(), + secretPath: z + .string() + .trim() + .optional() + .transform((val) => (val === "" ? "/" : val)), approvers: z.string().array().min(1), approvals: z.number().min(1).default(1) }) From f458e34c3734f797f50317447d21bfa979bd3e38 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:37:49 -0700 Subject: [PATCH 043/244] Feat: Request access (new routes) --- .../v1/access-approval-request-router.ts | 176 ++++++++++++++++++ backend/src/ee/routes/v1/index.ts | 2 + 2 files changed, 178 insertions(+) create mode 100644 backend/src/ee/routes/v1/access-approval-request-router.ts diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts new file mode 100644 index 000000000..696c8b76f --- /dev/null +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -0,0 +1,176 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; +import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(`requested-privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), + permissions: z.any().array(), + isTemporary: z.boolean(), + temporaryRange: z.string().optional() + }), + querystring: z.object({ + projectSlug: z.string().trim(), + secretPath: z.string().trim(), + envSlug: z.string().trim() + }), + response: { + 200: z.object({ + approval: AccessApprovalRequestsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.accessApprovalRequest.createAccessApprovalRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + permissions: req.body.permissions, + envSlug: req.query.envSlug, + actorOrgId: req.permission.orgId, + secretPath: req.query.secretPath, + projectSlug: req.query.projectSlug, + temporaryRange: req.body.temporaryRange, + isTemporary: req.body.isTemporary + }); + return { approval: request }; + } + }); + + server.route({ + url: "/count", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim() + }), + response: { + 200: z.object({ + pendingCount: z.number(), + finalizedCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { count } = await server.services.accessApprovalRequest.getCount({ + projectSlug: req.query.projectSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { ...count }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim(), + authorProjectMembershipId: z.string().trim().optional(), + envSlug: z.string().trim().optional() + }), + response: { + 200: z.object({ + requests: AccessApprovalRequestsSchema.extend({ + environmentName: z.string(), + isApproved: z.boolean(), + privilege: z + .object({ + membershipId: z.string(), + isTemporary: z.boolean(), + temporaryMode: z.string().nullish(), + temporaryRange: z.string().nullish(), + temporaryAccessStartTime: z.date().nullish(), + temporaryAccessEndTime: z.date().nullish(), + permissions: z.unknown() + }) + .nullable(), + policy: z.object({ + id: z.string(), + name: z.string(), + approvals: z.number(), + approvers: z.string().array(), + secretPath: z.string().nullish(), + envId: z.string() + }), + reviewers: z + .object({ + member: z.string(), + status: z.string() + }) + .array() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({ + projectSlug: req.query.projectSlug, + authorProjectMembershipId: req.query.authorProjectMembershipId, + envSlug: req.query.envSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { requests }; + } + }); + + server.route({ + url: "/:requestId/review", + method: "POST", + schema: { + params: z.object({ + requestId: z.string().trim() + }), + body: z.object({ + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + }), + response: { + 200: z.object({ + review: AccessApprovalRequestsReviewersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const review = await server.services.accessApprovalRequest.reviewAccessRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + requestId: req.params.requestId, + status: req.body.status + }); + + return { review }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 086a1b3d6..c73ed24c5 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,5 +1,6 @@ import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; +import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; @@ -43,6 +44,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { }); await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals" }); + await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approval-requests" }); await server.register( async (dynamicSecretRouter) => { From 7e29a6a6561825c8c4ff401a5f998767130c18c2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:38:36 -0700 Subject: [PATCH 044/244] Fix: Access Approval Policy DAL bugs --- .../access-approval-policy/access-approval-policy-dal.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index 792a851c3..88e288832 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -10,7 +10,7 @@ export type TAccessApprovalPolicyDALFactory = ReturnType { const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy); - const sapFindQuery = async (tx: Knex, filter: TFindFilter) => { + const accessApprovalPolicyFindQuery = async (tx: Knex, filter: TFindFilter) => { const result = await tx(TableName.AccessApprovalPolicy) // eslint-disable-next-line .where(buildFindFilter(filter)) @@ -32,7 +32,7 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex) => { try { - const doc = await sapFindQuery(tx || db, { + const doc = await accessApprovalPolicyFindQuery(tx || db, { [`${TableName.AccessApprovalPolicy}.id` as "id"]: id }); const formatedDoc = mergeOneToManyRelation( @@ -54,7 +54,7 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { const find = async (filter: TFindFilter, tx?: Knex) => { try { - const docs = await sapFindQuery(tx || db, filter); + const docs = await accessApprovalPolicyFindQuery(tx || db, filter); const formatedDoc = mergeOneToManyRelation( docs, "id", @@ -66,7 +66,7 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { ({ approverId }) => approverId, "approvers" ); - return formatedDoc; + return formatedDoc.map((policy) => ({ ...policy, secretPath: policy.secretPath || undefined })); } catch (error) { throw new DatabaseError({ error, name: "Find" }); } From eca36f19934eec05945f25fa6c24eced1e29c0c3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:38:52 -0700 Subject: [PATCH 045/244] Feat: Request access --- .../access-approval-policy-fns.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts new file mode 100644 index 000000000..b570c32fa --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts @@ -0,0 +1,37 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; + +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TVerifyApprovers } from "./access-approval-policy-types"; + +export const verifyApprovers = async ({ + approverProjectMemberships, + projectId, + orgId, + envSlug, + actorAuthMethod, + secretPath, + permissionService +}: TVerifyApprovers) => { + for (const approver of approverProjectMemberships) { + try { + // eslint-disable-next-line no-await-in-loop + const { permission: approverPermission } = await permissionService.getProjectPermission( + ActorType.USER, + approver.userId, + projectId, + actorAuthMethod, + orgId + ); + + ForbiddenError.from(approverPermission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment: envSlug, secretPath }) + ); + } catch (err) { + throw new BadRequestError({ message: "One or more approvers doesn't have access to be specified secret path" }); + } + } +}; From 5255c4075ae6f54e95cdb864bf7ab5a93c7304b3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:39:15 -0700 Subject: [PATCH 046/244] Fix: Validate approvers access --- .../access-approval-policy-service.ts | 87 ++++++++++++++++--- 1 file changed, 75 insertions(+), 12 deletions(-) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index d1f0ce1e7..30f5f10fe 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -3,22 +3,26 @@ import { ForbiddenError } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; +import { verifyApprovers } from "./access-approval-policy-fns"; import { TCreateAccessApprovalPolicy, TDeleteAccessApprovalPolicy, + TGetAccessPolicyCountByEnvironmentDTO, TListAccessApprovalPoliciesDTO, TUpdateAccessApprovalPolicy } from "./access-approval-policy-types"; type TSecretApprovalPolicyServiceFactoryDep = { + projectDAL: TProjectDALFactory; permissionService: Pick; accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; - projectEnvDAL: Pick; + projectEnvDAL: Pick; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; projectMembershipDAL: Pick; }; @@ -30,6 +34,7 @@ export const accessApprovalPolicyServiceFactory = ({ accessApprovalPolicyApproverDAL, permissionService, projectEnvDAL, + projectDAL, projectMembershipDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const createAccessApprovalPolicy = async ({ @@ -37,19 +42,24 @@ export const accessApprovalPolicyServiceFactory = ({ actor, actorId, actorOrgId, + secretPath, actorAuthMethod, approvals, approvers, - projectId, + projectSlug, environment }: TCreateAccessApprovalPolicy) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + if (!secretPath) throw new BadRequestError({ message: "Secret path is required" }); const { permission } = await permissionService.getProjectPermission( actor, actorId, - projectId, + project.id, actorAuthMethod, actorOrgId ); @@ -57,13 +67,24 @@ export const accessApprovalPolicyServiceFactory = ({ ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval ); - const env = await projectEnvDAL.findOne({ slug: environment, projectId }); + const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); if (!env) throw new BadRequestError({ message: "Environment not found" }); const secretApprovers = await projectMembershipDAL.find({ - projectId, + projectId: project.id, $in: { id: approvers } }); + + await verifyApprovers({ + projectId: project.id, + orgId: actorOrgId, + envSlug: environment, + secretPath, + actorAuthMethod, + permissionService, + approverProjectMemberships: secretApprovers + }); + if (secretApprovers.length !== approvers.length) { throw new BadRequestError({ message: "Approver not found in project" }); } @@ -73,6 +94,7 @@ export const accessApprovalPolicyServiceFactory = ({ { envId: env.id, approvals, + secretPath, name }, tx @@ -86,7 +108,7 @@ export const accessApprovalPolicyServiceFactory = ({ ); return doc; }); - return { ...accessApproval, environment: env, projectId }; + return { ...accessApproval, environment: env, projectId: project.id }; }; const getAccessApprovalPolicyByProjectId = async ({ @@ -94,24 +116,29 @@ export const accessApprovalPolicyServiceFactory = ({ actor, actorOrgId, actorAuthMethod, - projectId + projectSlug }: TListAccessApprovalPoliciesDTO) => { - const { permission } = await permissionService.getProjectPermission( + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + // Anyone in the project should be able to get the policies. + /* const { permission } = */ await permissionService.getProjectPermission( actor, actorId, - projectId, + project.id, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId }); + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); return accessApprovalPolicies; }; const updateAccessApprovalPolicy = async ({ policyId, approvers, + secretPath, name, actorId, actor, @@ -121,7 +148,6 @@ export const accessApprovalPolicyServiceFactory = ({ }: TUpdateAccessApprovalPolicy) => { const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); - const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -129,6 +155,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, actorOrgId ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { @@ -136,6 +163,7 @@ export const accessApprovalPolicyServiceFactory = ({ accessApprovalPolicy.id, { approvals, + secretPath, name }, tx @@ -149,6 +177,17 @@ export const accessApprovalPolicyServiceFactory = ({ }, { tx } ); + + await verifyApprovers({ + projectId: accessApprovalPolicy.projectId, + orgId: actorOrgId, + envSlug: accessApprovalPolicy.environment.slug, + secretPath: doc.secretPath!, + actorAuthMethod, + permissionService, + approverProjectMemberships: secretApprovers + }); + if (secretApprovers.length !== approvers.length) throw new BadRequestError({ message: "Approver not found in project" }); if (doc.approvals > secretApprovers.length) @@ -197,7 +236,31 @@ export const accessApprovalPolicyServiceFactory = ({ return policy; }; + const getAccessPolicyCountByEnvSlug = async ({ + actor, + actorOrgId, + actorAuthMethod, + projectSlug, + actorId, + envSlug + }: TGetAccessPolicyCountByEnvironmentDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + + if (!project) throw new BadRequestError({ message: "Project not found" }); + + await permissionService.getProjectPermission(actor, actorId, project.id, actorAuthMethod, actorOrgId); + + const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); + if (!environment) throw new BadRequestError({ message: "Environment not found" }); + + const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); + if (!policies) throw new BadRequestError({ message: "No policies found" }); + + return { policyCount: policies.length }; + }; + return { + getAccessPolicyCountByEnvSlug, createAccessApprovalPolicy, deleteAccessApprovalPolicy, updateAccessApprovalPolicy, From 317956a03844a91d06b23a82f9b3f95170a10477 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:39:48 -0700 Subject: [PATCH 047/244] Fix: Types mismatch --- .../access-approval-policy-types.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index 034132d04..c7b452771 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -1,10 +1,25 @@ +import { TProjectMemberships } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; +import { ActorAuthMethod } from "@app/services/auth/auth-type"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; + +export type TVerifyApprovers = { + approverProjectMemberships: TProjectMemberships[]; + permissionService: Pick; + envSlug: string; + actorAuthMethod: ActorAuthMethod; + secretPath: string; + projectId: string; + orgId: string; +}; export type TCreateAccessApprovalPolicy = { approvals: number; + secretPath?: string | null; environment: string; approvers: string[]; - projectId: string; + projectSlug: string; name: string; } & Omit; @@ -12,6 +27,7 @@ export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; approvers: string[]; + secretPath?: string; name?: string; } & Omit; @@ -19,7 +35,14 @@ export type TDeleteAccessApprovalPolicy = { policyId: string; } & Omit; -export type TListAccessApprovalPoliciesDTO = TProjectPermission; +export type TGetAccessPolicyCountByEnvironmentDTO = { + envSlug: string; + projectSlug: string; +} & Omit; + +export type TListAccessApprovalPoliciesDTO = { + projectSlug: string; +} & Omit; export type TGetBoardAccessApprovalPolicy = { projectId: string; From 992a82015aac946f6882e4313c37d6874b650182 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:40:05 -0700 Subject: [PATCH 048/244] Feat: Request access --- .../access-approval-request-dal.ts | 265 ++++++++++++++++++ .../access-approval-request-reviewer-dal.ts | 10 + .../access-approval-request-secret-dal.ts | 230 +++++++++++++++ 3 files changed, 505 insertions(+) create mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-dal.ts create mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts create mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts new file mode 100644 index 000000000..78fef7c8a --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -0,0 +1,265 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; + +import { ApprovalStatus } from "./access-approval-request-types"; + +export type TAccessApprovalRequestDALFactory = ReturnType; + +export const accessApprovalRequestDALFactory = (db: TDbClient) => { + const accessApprovalRequestOrm = ormify(db, TableName.AccessApprovalRequest); + + const findRequestsWithPrivilegeByPolicyIds = async (policyIds: string[]) => { + try { + const docs = await db(TableName.AccessApprovalRequest) + .whereIn(`${TableName.AccessApprovalRequest}.policyId`, policyIds) + + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.AccessApprovalRequest}.privilegeId`, + `${TableName.ProjectUserAdditionalPrivilege}.id` + ) + .leftJoin( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + .leftJoin( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select( + db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), + db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), + db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") + ) + + .select(db.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + + .select( + db.ref("projectId").withSchema(TableName.Environment), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + + .select( + db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus") + ) + + .select( + db + .ref("projectMembershipId") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeMembershipId"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeIsTemporary"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryMode"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeTemporaryAccessEndTime"), + + db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegePermissions") + ); + + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (doc) => ({ + ...AccessApprovalRequestsSchema.parse(doc), + projectId: doc.projectId, + environment: doc.envSlug, + environmentName: doc.envName, + policy: { + id: doc.policyId, + name: doc.policyName, + approvals: doc.policyApprovals, + secretPath: doc.policySecretPath, + envId: doc.policyEnvId + }, + privilege: doc.privilegeId + ? { + membershipId: doc.privilegeMembershipId, + isTemporary: doc.privilegeIsTemporary, + temporaryMode: doc.privilegeTemporaryMode, + temporaryRange: doc.privilegeTemporaryRange, + temporaryAccessStartTime: doc.privilegeTemporaryAccessStartTime, + temporaryAccessEndTime: doc.privilegeTemporaryAccessEndTime, + permissions: doc.privilegePermissions + } + : null, + + isApproved: !!doc.privilegeId + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + }, + { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + ] + }); + + if (!formattedDocs) return []; + + return formattedDocs.map((doc) => ({ + ...doc, + policy: { ...doc.policy, approvers: doc.approvers } + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); + } + }; + + const findQuery = (filter: TFindFilter, tx: Knex) => + tx(TableName.AccessApprovalRequest) + .where(filter) + .join( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + + .join( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select( + tx.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"), + tx.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), + tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), + tx.ref("projectId").withSchema(TableName.Environment), + tx.ref("slug").withSchema(TableName.Environment).as("environment"), + tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover) + ); + + const findById = async (id: string, tx?: Knex) => { + try { + const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db); + const docs = await sql; + const formatedDoc = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ + ...AccessApprovalRequestsSchema.parse(el), + projectId: el.projectId, + environment: el.environment, + policy: { + id: el.policyId, + name: el.policyName, + approvals: el.policyApprovals, + secretPath: el.policySecretPath + } + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + }, + { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + ] + }); + if (!formatedDoc?.[0]) return; + return { + ...formatedDoc[0], + policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" }); + } + }; + + const getCount = async ({ projectId }: { projectId: string }) => { + try { + const accessRequests = await db(TableName.AccessApprovalRequest) + .leftJoin( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.AccessApprovalRequest}.privilegeId`, + `${TableName.ProjectUserAdditionalPrivilege}.id` + ) + + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + + .where(`${TableName.Environment}.projectId`, projectId) + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) + .select(db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId")); + + const formattedRequests = sqlNestRelationships({ + data: accessRequests, + key: "id", + parentMapper: (doc) => ({ + ...AccessApprovalRequestsSchema.parse(doc) + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + } + ] + }); + + // an approval is pending if there is no reviewer rejections and no privilege ID is set + const pendingApprovals = formattedRequests.filter( + (req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + ); + + // an approval is finalized if there are any rejections or a privilege ID is set + const finalizedApprovals = formattedRequests.filter( + (req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + ); + + return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; + } catch (error) { + throw new DatabaseError({ error, name: "GetCountAccessApprovalRequest" }); + } + }; + + return { ...accessApprovalRequestOrm, findById, findRequestsWithPrivilegeByPolicyIds, getCount }; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts new file mode 100644 index 000000000..251015b22 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAccessApprovalRequestReviewerDALFactory = ReturnType; + +export const accessApprovalRequestReviewerDALFactory = (db: TDbClient) => { + const secretApprovalRequestReviewerOrm = ormify(db, TableName.AccessApprovalRequestReviewer); + return secretApprovalRequestReviewerOrm; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts new file mode 100644 index 000000000..d458d58ff --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts @@ -0,0 +1,230 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + SecretApprovalRequestsSecretsSchema, + TableName, + TSecretApprovalRequestsSecrets, + TSecretTags +} from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; + +export type TAccessApprovalRequestSecretDALFactory = ReturnType; + +export const accessApprovalRequestSecretDALFactory = (db: TDbClient) => { + const accessApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret); + const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag); + + const bulkUpdateNoVersionIncrement = async (data: TSecretApprovalRequestsSecrets[], tx?: Knex) => { + try { + const existingApprovalSecrets = await accessApprovalRequestSecretOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingApprovalSecrets.length !== data.length) { + throw new BadRequestError({ message: "Some of the secret approvals do not exist" }); + } + + if (data.length === 0) return []; + + const updatedApprovalSecrets = await (tx || db)(TableName.SecretApprovalRequestSecret) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedApprovalSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const findByRequestId = async (requestId: string, tx?: Knex) => { + try { + const doc = await (tx || db)({ + secVerTag: TableName.SecretTag + }) + .from(TableName.SecretApprovalRequestSecret) + .where({ requestId }) + .leftJoin( + TableName.SecretApprovalRequestSecretTag, + `${TableName.SecretApprovalRequestSecret}.id`, + `${TableName.SecretApprovalRequestSecretTag}.secretId` + ) + .leftJoin(TableName.SecretTag, `${TableName.SecretApprovalRequestSecretTag}.tagId`, `${TableName.SecretTag}.id`) + .leftJoin(TableName.Secret, `${TableName.SecretApprovalRequestSecret}.secretId`, `${TableName.Secret}.id`) + .leftJoin( + TableName.SecretVersion, + `${TableName.SecretVersion}.id`, + `${TableName.SecretApprovalRequestSecret}.secretVersion` + ) + .leftJoin( + TableName.SecretVersionTag, + `${TableName.SecretVersionTag}.${TableName.SecretVersion}Id`, + `${TableName.SecretVersion}.id` + ) + .leftJoin( + db.ref(TableName.SecretTag).as("secVerTag"), + `${TableName.SecretVersionTag}.${TableName.SecretTag}Id`, + db.ref("id").withSchema("secVerTag") + ) + .select(selectAllTableCols(TableName.SecretApprovalRequestSecret)) + .select({ + secVerTagId: "secVerTag.id", + secVerTagColor: "secVerTag.color", + secVerTagSlug: "secVerTag.slug", + secVerTagName: "secVerTag.name" + }) + .select( + db.ref("id").withSchema(TableName.SecretTag).as("tagId"), + db.ref("id").withSchema(TableName.SecretApprovalRequestSecretTag).as("tagJnId"), + db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("name").withSchema(TableName.SecretTag).as("tagName") + ) + .select( + db.ref("secretBlindIndex").withSchema(TableName.Secret).as("orgSecBlindIndex"), + db.ref("version").withSchema(TableName.Secret).as("orgSecVersion"), + db.ref("secretKeyIV").withSchema(TableName.Secret).as("orgSecKeyIV"), + db.ref("secretKeyTag").withSchema(TableName.Secret).as("orgSecKeyTag"), + db.ref("secretKeyCiphertext").withSchema(TableName.Secret).as("orgSecKeyCiphertext"), + db.ref("secretValueIV").withSchema(TableName.Secret).as("orgSecValueIV"), + db.ref("secretValueTag").withSchema(TableName.Secret).as("orgSecValueTag"), + db.ref("secretValueCiphertext").withSchema(TableName.Secret).as("orgSecValueCiphertext"), + db.ref("secretCommentIV").withSchema(TableName.Secret).as("orgSecCommentIV"), + db.ref("secretCommentTag").withSchema(TableName.Secret).as("orgSecCommentTag"), + db.ref("secretCommentCiphertext").withSchema(TableName.Secret).as("orgSecCommentCiphertext") + ) + .select( + db.ref("version").withSchema(TableName.SecretVersion).as("secVerVersion"), + db.ref("secretKeyIV").withSchema(TableName.SecretVersion).as("secVerKeyIV"), + db.ref("secretKeyTag").withSchema(TableName.SecretVersion).as("secVerKeyTag"), + db.ref("secretKeyCiphertext").withSchema(TableName.SecretVersion).as("secVerKeyCiphertext"), + db.ref("secretValueIV").withSchema(TableName.SecretVersion).as("secVerValueIV"), + db.ref("secretValueTag").withSchema(TableName.SecretVersion).as("secVerValueTag"), + db.ref("secretValueCiphertext").withSchema(TableName.SecretVersion).as("secVerValueCiphertext"), + db.ref("secretCommentIV").withSchema(TableName.SecretVersion).as("secVerCommentIV"), + db.ref("secretCommentTag").withSchema(TableName.SecretVersion).as("secVerCommentTag"), + db.ref("secretCommentCiphertext").withSchema(TableName.SecretVersion).as("secVerCommentCiphertext") + ); + const formatedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (data) => SecretApprovalRequestsSecretsSchema.omit({ secretVersion: true }).parse(data), + childrenMapper: [ + { + key: "tagJnId", + label: "tags" as const, + mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color }) => ({ + id, + name, + slug, + color + }) + }, + { + key: "secretId", + label: "secret" as const, + mapper: ({ + orgSecKeyIV, + orgSecKeyTag, + orgSecValueIV, + orgSecVersion, + orgSecValueTag, + orgSecCommentIV, + orgSecBlindIndex, + orgSecCommentTag, + orgSecKeyCiphertext, + orgSecValueCiphertext, + orgSecCommentCiphertext, + secretId + }) => + secretId + ? { + id: secretId, + version: orgSecVersion, + secretBlindIndex: orgSecBlindIndex, + secretKeyIV: orgSecKeyIV, + secretKeyTag: orgSecKeyTag, + secretKeyCiphertext: orgSecKeyCiphertext, + secretValueIV: orgSecValueIV, + secretValueTag: orgSecValueTag, + secretValueCiphertext: orgSecValueCiphertext, + secretCommentIV: orgSecCommentIV, + secretCommentTag: orgSecCommentTag, + secretCommentCiphertext: orgSecCommentCiphertext + } + : undefined + }, + { + key: "secretVersion", + label: "secretVersion" as const, + mapper: ({ + secVerCommentIV, + secVerCommentCiphertext, + secVerCommentTag, + secVerValueCiphertext, + secVerKeyIV, + secVerKeyTag, + secVerValueIV, + secretVersion, + secVerValueTag, + secVerKeyCiphertext, + secVerVersion + }) => + secretVersion + ? { + version: secVerVersion, + id: secretVersion, + secretKeyIV: secVerKeyIV, + secretKeyTag: secVerKeyTag, + secretKeyCiphertext: secVerKeyCiphertext, + secretValueIV: secVerValueIV, + secretValueTag: secVerValueTag, + secretValueCiphertext: secVerValueCiphertext, + secretCommentIV: secVerCommentIV, + secretCommentTag: secVerCommentTag, + secretCommentCiphertext: secVerCommentCiphertext + } + : undefined, + childrenMapper: [ + { + key: "secVerTagId", + label: "tags" as const, + mapper: ({ secVerTagId: id, secVerTagName: name, secVerTagSlug: slug, secVerTagColor: color }) => ({ + // eslint-disable-next-line + id, + // eslint-disable-next-line + name, + // eslint-disable-next-line + slug, + // eslint-disable-next-line + color + }) + } + ] + } + ] + }); + return formatedDoc?.map(({ secret, secretVersion, ...el }) => ({ + ...el, + secret: secret?.[0], + secretVersion: secretVersion?.[0] + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindByRequestId" }); + } + }; + return { + ...accessApprovalRequestSecretOrm, + findByRequestId, + bulkUpdateNoVersionIncrement, + insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany + }; +}; From ad405109a0526c4ea20ca127b0d452500d3f04a8 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:40:51 -0700 Subject: [PATCH 049/244] Feat: Request access --- .../access-approval-request-service.ts | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-service.ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts new file mode 100644 index 000000000..d02842939 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -0,0 +1,269 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { ProjectMembershipRole } from "@app/db/schemas"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; +import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; +import { TProjectUserAdditionalPrivilegeServiceFactory } from "../project-user-additional-privilege/project-user-additional-privilege-service"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; +import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; +import { TAccessApprovalRequestReviewerDALFactory } from "./access-approval-request-reviewer-dal"; +import { + ApprovalStatus, + TCreateAccessApprovalRequestDTO, + TGetAccessRequestCountDTO, + TListApprovalRequestsDTO, + TReviewAccessRequestDTO +} from "./access-approval-request-types"; + +type TSecretApprovalRequestServiceFactoryDep = { + additionalPrivilegeService: TProjectUserAdditionalPrivilegeServiceFactory; + additionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; + permissionService: TPermissionServiceFactory; + projectEnvDAL: Pick; + projectDAL: Pick; + accessApprovalRequestDAL: TAccessApprovalRequestDALFactory; + accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; + accessApprovalRequestReviewerDAL: TAccessApprovalRequestReviewerDALFactory; + projectMembershipDAL: TProjectMembershipDALFactory; +}; + +export type TAccessApprovalRequestServiceFactory = ReturnType; + +export const accessApprovalRequestServiceFactory = ({ + projectDAL, + projectEnvDAL, + permissionService, + accessApprovalRequestDAL, + accessApprovalRequestReviewerDAL, + projectMembershipDAL, + accessApprovalPolicyDAL, + additionalPrivilegeDAL +}: TSecretApprovalRequestServiceFactoryDep) => { + const createAccessApprovalRequest = async ({ + isTemporary, + temporaryRange, + actorId, + envSlug, + permissions, + secretPath, + actor, + actorOrgId, + actorAuthMethod, + projectSlug + }: TCreateAccessApprovalRequestDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + // Anyone can create an access approval request. + const p = await permissionService.getProjectPermission(actor, actorId, project.id, actorAuthMethod, actorOrgId); + + if (!p.membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + await projectDAL.checkProjectUpgradeStatus(project.id); + + const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); + + if (!environment) throw new UnauthorizedError({ message: "Environment not found" }); + if (!secretPath) throw new UnauthorizedError({ message: "Secret path is required" }); + + const policy = await accessApprovalPolicyDAL.findOne({ + envId: environment.id, + secretPath + }); + if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." }); + + const approvalRequest = await accessApprovalRequestDAL.create({ + policyId: policy.id, + requestedBy: p.membership.id, + temporaryRange: temporaryRange || null, + permissions: JSON.stringify(permissions), + isTemporary + }); + + return { request: approvalRequest }; + }; + + const listApprovalRequests = async ({ + projectSlug, + authorProjectMembershipId, + envSlug, + actor, + actorOrgId, + actorId, + actorAuthMethod + }: TListApprovalRequestsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + + const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); + + if (authorProjectMembershipId) { + requests = requests.filter((request) => request.requestedBy === authorProjectMembershipId); + } + + if (envSlug) { + requests = requests.filter((request) => request.environment === envSlug); + } + + return { requests }; + }; + + const reviewAccessRequest = async ({ + requestId, + actor, + status, + actorId, + actorAuthMethod, + actorOrgId + }: TReviewAccessRequestDTO) => { + const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); + if (!accessApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); + + const { policy } = accessApprovalRequest; + const { membership, hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + accessApprovalRequest.projectId, + actorAuthMethod, + actorOrgId + ); + + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + if ( + !hasRole(ProjectMembershipRole.Admin) && + accessApprovalRequest.requestedBy !== membership.id && // The request wasn't made by the current user + !policy.approvers.find((approverId) => approverId === membership.id) // The request isn't performed by an assigned approver + ) { + throw new UnauthorizedError({ message: "No access" }); + } + + const reviewerProjectMembership = await projectMembershipDAL.findById(membership.id); + + await verifyApprovers({ + projectId: accessApprovalRequest.projectId, + orgId: actorOrgId, + envSlug: accessApprovalRequest.environment, + secretPath: accessApprovalRequest.policy.secretPath!, + actorAuthMethod, + permissionService, + approverProjectMemberships: [reviewerProjectMembership] + }); + + const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); + if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { + throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); + } + + const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => { + const review = await accessApprovalRequestReviewerDAL.findOne( + { + requestId: accessApprovalRequest.id, + member: membership.id + }, + tx + ); + if (!review) { + const newReview = await accessApprovalRequestReviewerDAL.create( + { + status, + requestId: accessApprovalRequest.id, + member: membership.id + }, + tx + ); + + const allReviews = [...existingReviews, newReview]; + + const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + + // If all approvers have approved the request, update the privilege to approved + if (approvedReviews.length === policy.approvers.length) { + if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { + throw new BadRequestError({ message: "Temporary range is required for temporary access" }); + } + + let privilegeId: string | null = null; + + if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { + // Permanent access + const privilege = await additionalPrivilegeDAL.create({ + projectMembershipId: accessApprovalRequest.requestedBy, + slug: "", + permissions: JSON.stringify(accessApprovalRequest.permissions) + }); + privilegeId = privilege.id; + } else { + // Temporary access + const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); + const startTime = new Date(); + + const privilege = await additionalPrivilegeDAL.create({ + projectMembershipId: accessApprovalRequest.requestedBy, + slug: "", + permissions: JSON.stringify(accessApprovalRequest.permissions), + isTemporary: true, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: accessApprovalRequest.temporaryRange!, + temporaryAccessStartTime: startTime, + temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) + }); + privilegeId = privilege.id; + } + + await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx); + } + + return newReview; + } + throw new BadRequestError({ message: "You have already reviewed this request" }); + }); + + return reviewStatus; + }; + + const getCount = async ({ projectSlug, actor, actorAuthMethod, actorId, actorOrgId }: TGetAccessRequestCountDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + + const count = await accessApprovalRequestDAL.getCount({ projectId: project.id }); + + return { count }; + }; + + return { + createAccessApprovalRequest, + listApprovalRequests, + reviewAccessRequest, + getCount + }; +}; From ad6e2aeb9e7ca01403bfaa8098006d5280fa561f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:43:05 -0700 Subject: [PATCH 050/244] Feat: Request Access --- .../access-approval-request-types.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-types.ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts new file mode 100644 index 000000000..6bba0c14d --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -0,0 +1,31 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" +} + +export type TGetAccessRequestCountDTO = { + projectSlug: string; +} & Omit; + +export type TReviewAccessRequestDTO = { + requestId: string; + status: ApprovalStatus; +} & Omit; + +export type TCreateAccessApprovalRequestDTO = { + projectSlug: string; + secretPath: string; + envSlug: string; + permissions: unknown; + isTemporary: boolean; + temporaryRange?: string; +} & Omit; + +export type TListApprovalRequestsDTO = { + projectSlug: string; + authorProjectMembershipId?: string; + envSlug?: string; +} & Omit; From 696479a2efb098b27f694e349c84449eb3da7ed2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:43:48 -0700 Subject: [PATCH 051/244] Fix: Remove redundant code --- .../src/components/permissions/PermissionDeniedBanner.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index f9707d634..b3c7a4f53 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,8 +3,6 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button } from "../v2"; - type Props = { containerClassName?: string; className?: string; @@ -34,9 +32,6 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children )} - ); From b298eec9dba82f77a0f27d3153bb526808d9c91e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:44:00 -0700 Subject: [PATCH 052/244] Fix: Danger color not working on disabled buttons --- frontend/src/components/v2/Button/Button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 5536d699a..7707805e9 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -29,7 +29,7 @@ const buttonVariants = cva( colorSchema: { primary: ["bg-primary", "text-black", "border-primary bg-opacity-90 hover:bg-opacity-100"], secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-opacity-80"], - danger: ["bg-red", "text-white", "border-red hover:bg-opacity-90"], + danger: ["!bg-red", "!text-white", "!border-red hover:!bg-opacity-90"], gray: ["bg-bunker-500", "text-bunker-200"] }, variant: { From 7fd1d729850da8dc177fcaff652ab18d6c62c274 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:44:25 -0700 Subject: [PATCH 053/244] Fix: Access Request setup --- backend/src/server/routes/index.ts | 35 +++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0a2c77c9f..905d4028c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -5,6 +5,9 @@ import { registerV1EERoutes } from "@app/ee/routes/v1"; import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; +import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; +import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; +import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; @@ -212,7 +215,9 @@ export const registerRoutes = async ( const ldapGroupMapDAL = ldapGroupMapDALFactory(db); const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); + const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); @@ -273,14 +278,6 @@ export const registerRoutes = async ( }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ - accessApprovalPolicyDAL, - accessApprovalPolicyApproverDAL, - permissionService, - projectEnvDAL, - projectMembershipDAL - }); - const samlService = samlConfigServiceFactory({ permissionService, orgBotDAL, @@ -613,6 +610,27 @@ export const registerRoutes = async ( secretQueueService }); + const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectMembershipDAL, + projectDAL + }); + + const accessApprovalRequestService = accessApprovalRequestServiceFactory({ + projectDAL, + permissionService, + accessApprovalRequestReviewerDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + projectMembershipDAL, + additionalPrivilegeService: projectUserAdditionalPrivilegeService, + accessApprovalPolicyDAL, + accessApprovalRequestDAL, + projectEnvDAL + }); + const secretRotationQueue = secretRotationQueueFactory({ telemetryService, secretRotationDAL, @@ -750,6 +768,7 @@ export const registerRoutes = async ( identityUa: identityUaService, secretApprovalPolicy: sapService, accessApprovalPolicy: accessApprovalPolicyService, + accessApprovalRequest: accessApprovalRequestService, secretApprovalRequest: sarService, secretRotation: secretRotationService, dynamicSecret: dynamicSecretService, From 1f68730aa3aedc49acfe06f06f6ad6cc685093c5 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:44:49 -0700 Subject: [PATCH 054/244] Fix: Improve disabled Select --- frontend/src/components/v2/Select/Select.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 2a76be2ab..12a9094e0 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -41,18 +41,22 @@ export const Select = forwardRef( ref={ref} className={twMerge( `inline-flex items-center justify-between rounded-md - bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200 focus:bg-mineshaft-700/80`, - className + bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none focus:bg-mineshaft-700/80 data-[placeholder]:text-mineshaft-200`, + className, + isDisabled && "cursor-not-allowed opacity-50" )} > {props.icon ? : placeholder} - {!isDisabled && ( - - - - )} + + + + Date: Wed, 3 Apr 2024 16:45:18 -0700 Subject: [PATCH 055/244] Update index.tsx --- frontend/src/hooks/api/accessApproval/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/api/accessApproval/index.tsx b/frontend/src/hooks/api/accessApproval/index.tsx index 537dfe3be..ec9789d05 100644 --- a/frontend/src/hooks/api/accessApproval/index.tsx +++ b/frontend/src/hooks/api/accessApproval/index.tsx @@ -1,6 +1,13 @@ export { useCreateAccessApprovalPolicy, + useCreateAccessRequest, useDeleteAccessApprovalPolicy, + useReviewAccessRequest, useUpdateAccessApprovalPolicy } from "./mutation"; -export { useGetAccessApprovalPolicies } from "./queries"; +export { + useGetAccessApprovalPolicies, + useGetAccessApprovalRequests, + useGetAccessPolicyApprovalCount, + useGetAccessRequestsCount +} from "./queries"; From 7aee4fdfcdfdf3f312504e6e62841b6cc07a97e6 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:45:37 -0700 Subject: [PATCH 056/244] Feat: Request access --- .../src/hooks/api/accessApproval/mutation.tsx | 87 ++++++++-- .../src/hooks/api/accessApproval/queries.tsx | 149 ++++++++++++++++-- .../src/hooks/api/accessApproval/types.ts | 106 ++++++++++++- 3 files changed, 312 insertions(+), 30 deletions(-) diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 98e9b3f97..7a150092c 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -1,26 +1,34 @@ +import { packRules } from "@casl/ability/extra"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { accessApprovalKeys } from "./queries"; -import { TCreateAccessPolicyDTO, TDeleteSecretPolicyDTO, TUpdateAccessPolicyDTO } from "./types"; +import { + TAccessApproval, + TCreateAccessPolicyDTO, + TCreateAccessRequestDTO, + TDeleteSecretPolicyDTO, + TUpdateAccessPolicyDTO +} from "./types"; export const useCreateAccessApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateAccessPolicyDTO>({ - mutationFn: async ({ environment, workspaceId, approvals, approvers, name }) => { + mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => { const { data } = await apiRequest.post("/api/v1/access-approvals", { environment, - workspaceId, + projectSlug, approvals, approvers, + secretPath, name }); return data; }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId)); + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug)); } }); }; @@ -29,16 +37,17 @@ export const useUpdateAccessApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateAccessPolicyDTO>({ - mutationFn: async ({ id, approvers, approvals, name }) => { + mutationFn: async ({ id, approvers, approvals, name, secretPath }) => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/${id}`, { approvals, approvers, + secretPath, name }); return data; }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId)); + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug)); } }); }; @@ -51,8 +60,66 @@ export const useDeleteAccessApprovalPolicy = () => { const { data } = await apiRequest.delete(`/api/v1/access-approvals/${id}`); return data; }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId)); + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug)); + } + }); +}; + +export const useCreateAccessRequest = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, TCreateAccessRequestDTO>({ + mutationFn: async ({ envSlug, projectSlug, secretPath, ...privilege }) => { + const { data } = await apiRequest.post( + "/api/v1/access-approval-requests", + { + ...privilege, + permissions: privilege.permissions ? packRules(privilege.permissions) : undefined + }, + { + params: { + envSlug, + projectSlug, + secretPath + } + } + ); + + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequests(projectSlug)); + } + }); +}; + +export const useReviewAccessRequest = () => { + const queryClient = useQueryClient(); + return useMutation< + {}, + {}, + { + requestId: string; + status: "approved" | "rejected"; + projectSlug: string; + envSlug?: string; + requestedBy?: string; + } + >({ + mutationFn: async ({ requestId, status }) => { + const { data } = await apiRequest.post( + `/api/v1/access-approval-requests/${requestId}/review`, + { + status + } + ); + return data; + }, + onSuccess: (_, { projectSlug, envSlug, requestedBy }) => { + queryClient.invalidateQueries( + accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy) + ); + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug)); } }); }; diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index 864122593..112c53fe3 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -1,30 +1,125 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TAccessApprovalPolicy, TGetSecretApprovalPoliciesDTO } from "./types"; +import { TProjectPermission } from "../roles/types"; +import { + TAccessApprovalPolicy, + TAccessApprovalRequest, + TAccessRequestCount, + TGetAccessApprovalRequestsDTO, + TGetAccessPolicyApprovalCountDTO +} from "./types"; export const accessApprovalKeys = { - getAccessApprovalPolicies: (workspaceId: string) => - [{ workspaceId }, "access-approval-policies"] as const, - getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => [ - { workspaceId, environment }, - "access-approval-policy" - ] + getAccessApprovalPolicies: (projectSlug: string) => + [{ projectSlug }, "access-approval-policies"] as const, + getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => + [{ workspaceId, environment }, "access-approval-policy"] as const, + + getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) => + [{ projectSlug, envSlug, requestedBy }, "access-approval-requests"] as const, + getAccessApprovalRequestCount: (projectSlug: string) => + [{ projectSlug }, "access-approval-request-count"] as const }; -const fetchApprovalPolicies = async (workspaceId: string) => { +export const fetchPolicyApprovalCount = async ({ + projectSlug, + envSlug +}: TGetAccessPolicyApprovalCountDTO) => { + const { data } = await apiRequest.get<{ policyCount: number }>( + "/api/v1/access-approvals/policy-count", + { + params: { projectSlug, envSlug } + } + ); + return data.policyCount; +}; + +export const useGetAccessPolicyApprovalCount = ({ + projectSlug, + envSlug, + options = {} +}: TGetAccessPolicyApprovalCountDTO & { + options?: UseQueryOptions< + number, + unknown, + number, + ReturnType + >; +}) => + useQuery({ + queryFn: () => fetchPolicyApprovalCount({ projectSlug, envSlug }), + ...options, + enabled: Boolean(projectSlug) && (options?.enabled ?? true) + }); + +const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequestsDTO) => { const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>( "/api/v1/access-approvals", - { params: { workspaceId } } + { params: { projectSlug } } ); return data.approvals; }; -export const useGetAccessApprovalPolicies = ({ - workspaceId, +const fetchApprovalRequests = async ({ + projectSlug, + envSlug, + authorProjectMembershipId +}: TGetAccessApprovalRequestsDTO) => { + const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>( + "/api/v1/access-approval-requests", + { params: { projectSlug, envSlug, authorProjectMembershipId } } + ); + + return data.requests.map((request) => ({ + ...request, + + privilege: request.privilege + ? { + ...request.privilege, + permissions: unpackRules( + request.privilege.permissions as unknown as PackRule[] + ) + } + : null, + permissions: unpackRules(request.permissions as unknown as PackRule[]) + })); +}; + +const fetchAccessRequestsCount = async (projectSlug: string) => { + const { data } = await apiRequest.get( + "/api/v1/access-approval-requests/count", + { params: { projectSlug } } + ); + return data; +}; + +export const useGetAccessRequestsCount = ({ + projectSlug, options = {} -}: TGetSecretApprovalPoliciesDTO & { +}: TGetAccessApprovalRequestsDTO & { + options?: UseQueryOptions< + TAccessRequestCount, + unknown, + { pendingCount: number; finalizedCount: number }, + ReturnType + >; +}) => + useQuery({ + queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug), + queryFn: () => fetchAccessRequestsCount(projectSlug), + ...options, + enabled: Boolean(projectSlug) && (options?.enabled ?? true) + }); + +export const useGetAccessApprovalPolicies = ({ + projectSlug, + envSlug, + authorProjectMembershipId, + options = {} +}: TGetAccessApprovalRequestsDTO & { options?: UseQueryOptions< TAccessApprovalPolicy[], unknown, @@ -33,8 +128,32 @@ export const useGetAccessApprovalPolicies = ({ >; }) => useQuery({ - queryKey: accessApprovalKeys.getAccessApprovalPolicies(workspaceId), - queryFn: () => fetchApprovalPolicies(workspaceId), + queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug), + queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }), ...options, - enabled: Boolean(workspaceId) && (options?.enabled ?? true) + enabled: Boolean(projectSlug) && (options?.enabled ?? true) + }); + +export const useGetAccessApprovalRequests = ({ + projectSlug, + envSlug, + authorProjectMembershipId, + options = {} +}: TGetAccessApprovalRequestsDTO & { + options?: UseQueryOptions< + TAccessApprovalRequest[], + unknown, + TAccessApprovalRequest[], + ReturnType + >; +}) => + useQuery({ + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envSlug, + authorProjectMembershipId + ), + queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }), + ...options, + enabled: Boolean(projectSlug) && (options?.enabled ?? true) }); diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 63020166b..1ffd0bf2e 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -1,9 +1,11 @@ +import { TProjectPermission } from "../roles/types"; import { WorkspaceEnv } from "../workspace/types"; export type TAccessApprovalPolicy = { id: string; name: string; approvals: number; + secretPath: string; envId: string; workspace: string; environment: WorkspaceEnv; @@ -11,8 +13,99 @@ export type TAccessApprovalPolicy = { approvers: string[]; }; -export type TGetSecretApprovalPoliciesDTO = { - workspaceId: string; +export type TAccessApprovalRequest = { + id: string; + policyId: string; + privilegeId: string | null; + requestedBy: string; + createdAt: Date; + updatedAt: Date; + isTemporary: boolean; + temporaryRange: string | null | undefined; + + permissions: TProjectPermission[] | null; + + // Computed + environmentName: string; + isApproved: boolean; + + privilege: { + membershipId: string; + isTemporary: boolean; + temporaryMode?: string | null; + temporaryRange?: string | null; + temporaryAccessStartTime?: Date | null; + temporaryAccessEndTime?: Date | null; + permissions: TProjectPermission[]; + isApproved: boolean; + } | null; + + policy: { + id: string; + name: string; + approvals: number; + approvers: string[]; + secretPath?: string | null; + envId: string; + }; + + reviewers: { + member: string; + status: string; + }[]; +}; + +export type TAccessApproval = { + id: string; + policyId: string; + privilegeId: string; + requestedBy: string; +}; + +export type TAccessRequestCount = { + pendingCount: number; + finalizedCount: number; +}; + +export type TProjectUserPrivilege = { + projectMembershipId: string; + slug: string; + id: string; + createdAt: Date; + updatedAt: Date; + permissions?: TProjectPermission[]; +} & ( + | { + isTemporary: true; + temporaryMode: string; + temporaryRange: string; + temporaryAccessStartTime: string; + temporaryAccessEndTime?: string; + } + | { + isTemporary: false; + temporaryMode?: null; + temporaryRange?: null; + temporaryAccessStartTime?: null; + temporaryAccessEndTime?: null; + } +); + +export type TCreateAccessRequestDTO = { + envSlug: string; + projectSlug: string; + secretPath: string; +} & Omit; + +export type TGetAccessApprovalRequestsDTO = { + projectSlug: string; + envSlug?: string; + authorProjectMembershipId?: string; +}; + +export type TGetAccessPolicyApprovalCountDTO = { + projectSlug: string; + envSlug: string; }; export type TGetSecretApprovalPolicyOfBoardDTO = { @@ -22,24 +115,27 @@ export type TGetSecretApprovalPolicyOfBoardDTO = { }; export type TCreateAccessPolicyDTO = { - workspaceId: string; + projectSlug: string; name?: string; environment: string; approvers?: string[]; approvals?: number; + secretPath?: string; }; export type TUpdateAccessPolicyDTO = { id: string; name?: string; approvers?: string[]; + secretPath?: string; + environment?: string; approvals?: number; // for invalidating list - workspaceId: string; + projectSlug: string; }; export type TDeleteSecretPolicyDTO = { id: string; // for invalidating list - workspaceId: string; + projectSlug: string; }; From d380b7f788f150ee0c3ddaeb9db9fc228c604d2a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:49:00 -0700 Subject: [PATCH 057/244] Fix: Added support for request access --- .infisicalignore | 1 + .../SpecificPrivilegeSection.tsx | 270 +++++++++++++++--- 2 files changed, 235 insertions(+), 36 deletions(-) diff --git a/.infisicalignore b/.infisicalignore index d5cc9f15d..855047fe4 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -4,3 +4,4 @@ frontend/src/views/Project/MembersPage/components/IdentityTab/components/Identit 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 docs/self-hosting/configuration/envars.mdx:generic-api-key:106 +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451 diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 228683726..89058b08d 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -1,9 +1,11 @@ +import { useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { faArrowRotateLeft, faCaretDown, faCheck, faClock, + faLockOpen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; @@ -44,11 +46,13 @@ import { import { usePopUp } from "@app/hooks"; import { TProjectUserPrivilege, + useCreateAccessRequest, useCreateProjectUserAdditionalPrivilege, useDeleteProjectUserAdditionalPrivilege, useListProjectUserPrivileges, useUpdateProjectUserAdditionalPrivilege } from "@app/hooks/api"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; const secretPermissionSchema = z.object({ secretPath: z.string().optional(), @@ -70,51 +74,107 @@ const secretPermissionSchema = z.object({ ]) }); type TSecretPermissionForm = z.infer; -const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => { +export const SpecificPrivilegeSecretForm = ({ + privilege, + policies, + onClose +}: { + privilege?: TProjectUserPrivilege; + policies?: TAccessApprovalPolicy[]; + onClose?: () => void; +}) => { const { currentWorkspace } = useWorkspace(); + const { membership: projectMembership } = useProjectPermission(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "deletePrivilege" + "deletePrivilege", + "requestAccess" ] as const); const { permission } = useProjectPermission(); - const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, - ProjectPermissionSub.Member - ); + const isMemberEditDisabled = + permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege; const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege(); const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); + const requestAccess = useCreateAccessRequest(); const privilegeForm = useForm({ resolver: zodResolver(secretPermissionSchema), values: { - environmentSlug: privilege.permissions?.[0]?.conditions?.environment, - // secret path will be inside $glob operator - secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", - read: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Read) - ), - edit: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Edit) - ), - create: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Create) - ), - delete: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Delete) - ), - // zod will pick it - temporaryAccess: privilege + ...(privilege + ? { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + : { + environmentSlug: currentWorkspace?.environments?.[0].slug!, + read: false, + edit: false, + create: false, + delete: false, + temporaryAccess: { + isTemporary: false + } + }) } }); const temporaryAccessField = privilegeForm.watch("temporaryAccess"); const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug"); + const selectedEnvironment = privilegeForm.watch("environmentSlug"); + const secretPath = privilegeForm.watch("secretPath"); + + const readAccess = privilegeForm.watch("read"); + const createAccess = privilegeForm.watch("create"); + const editAccess = privilegeForm.watch("edit"); + const deleteAccess = privilegeForm.watch("delete"); + + const accessSelected = readAccess || createAccess || editAccess || deleteAccess; + + const selectablePaths = useMemo(() => { + if (!policies) return []; + const environmentPolicies = policies.filter( + (policy) => policy.environment.slug === selectedEnvironment + ); + + privilegeForm.setValue("secretPath", "", { + shouldValidate: true + }); + + return [...environmentPolicies.map((policy) => policy.secretPath)]; + }, [policies, selectedEnvironment]); + const isTemporary = temporaryAccessField?.isTemporary; const isExpired = temporaryAccessField.isTemporary && new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { + if (!privilege) { + createNotification({ + type: "error", + text: "No privilege to update found.", + title: "Error" + }); + + return; + } + if (updateUserPrivilege.isLoading) return; try { const actions = [ @@ -152,6 +212,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri }; const handleDeletePrivilege = async () => { + if (!privilege) { + createNotification({ + type: "error", + text: "No privilege to delete found.", + title: "Error" + }); + return; + } + if (deleteUserPrivilege.isLoading) return; try { await deleteUserPrivilege.mutateAsync({ @@ -170,35 +239,122 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri } }; + const getAccessLabel = (exactTime = false) => { if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; + if (exactTime) return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" )}`; return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + }; + + // This is used for requesting access additional privileges, not directly creating a privilege! + const handleRequestAccess = async (data: TSecretPermissionForm) => { + if (!policies) return; + if (!currentWorkspace) { + createNotification({ + type: "error", + text: "No workspace found.", + title: "Error" + }); + return; + } + + if (!data.secretPath) { + createNotification({ + type: "error", + text: "Please select a secret path", + title: "Error" + }); + return; + } + + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await requestAccess.mutateAsync({ + ...data, + ...(data.temporaryAccess.isTemporary && { + temporaryAccessStartTime: data.temporaryAccess.temporaryAccessStartTime, + temporaryAccessEndTime: data.temporaryAccess.temporaryAccessEndTime, + temporaryRange: data.temporaryAccess.temporaryRange, + temporaryMode: "relative" + }), + envSlug: data.environmentSlug, + secretPath: data.secretPath, + projectSlug: currentWorkspace.slug, + projectMembershipId: projectMembership.id, + isTemporary: data.temporaryAccess.isTemporary, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }); + + createNotification({ + type: "success", + text: "Successfully requested access" + }); + privilegeForm.reset(); + if (onClose) onClose(); + }; + + const handleSubmit = async (data: TSecretPermissionForm) => { + if (privilege) { + handleUpdatePrivilege(data); + } else { + handleRequestAccess(data); + } + }; + + const getAccessLabel = (exactTime = false) => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + + if (exactTime) + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + }; return ( -
-
-
+
+ +
( - + @@ -208,8 +364,28 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri ( - + render={({ field }) => { + console.log(policies); + if (policies) { + return ( + + + + ); + } + return ( + - )} + ); + }} />
-
+
@@ -301,7 +478,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri isExpired && "text-red-600" )} > - {getAccessLabel()} + {getAccessLabel(false)}
@@ -382,7 +559,8 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
- {privilegeForm.formState.isDirty ? ( + {/* eslint-disable-next-line no-nested-ternary */} + {privilegeForm.formState.isDirty && privilege ? ( <> - ) : ( + ) : // eslint-disable-next-line no-nested-ternary + privilege ? ( + ) : ( +
)}
+ {!!policies && ( + + )} Date: Wed, 3 Apr 2024 16:49:48 -0700 Subject: [PATCH 058/244] Fix: Move to project slug --- .../AccessApprovalPolicyList.tsx | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx index 834b54f00..7c7b354a8 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx @@ -21,7 +21,8 @@ import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, - useSubscription + useSubscription, + useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteAccessApprovalPolicy, useGetWorkspaceUsers } from "@app/hooks/api"; @@ -43,12 +44,15 @@ export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { ] as const); const { permission } = useProjectPermission(); const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); const { data: members } = useGetWorkspaceUsers(workspaceId); const { data: policies, isLoading: isPoliciesLoading } = useGetAccessApprovalPolicies({ - workspaceId, + projectSlug: currentWorkspace?.slug as string, options: { - enabled: permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.slug } }); @@ -56,9 +60,11 @@ export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { const handleDeletePolicy = async () => { const { id } = popUp.deletePolicy.data as TAccessApprovalPolicy; + if (!currentWorkspace?.slug) return; + try { await deleteSecretApprovalPolicy({ - workspaceId, + projectSlug: currentWorkspace?.slug, id }); createNotification({ @@ -114,6 +120,7 @@ export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => {
+ @@ -129,21 +136,22 @@ export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { )} - {policies?.map((policy) => ( - handlePopUpOpen("secretPolicyForm", policy)} - onDelete={() => handlePopUpOpen("deletePolicy", policy)} - /> - ))} + {!!currentWorkspace && + policies?.map((policy) => ( + handlePopUpOpen("secretPolicyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))}
- + { + onToggleFolderSelect(folderName); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> +
{folderName}
diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 076ff22c8..f1476c167 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -11,7 +11,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { DecryptedSecret } from "@app/hooks/api/secrets/types"; @@ -23,6 +23,8 @@ type Props = { secretPath: string; environments: { name: string; slug: string }[]; expandableColWidth: number; + isSelected: boolean; + onToggleSecretSelect: (key: string) => void; getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; onSecretCreate: (env: string, key: string, value: string) => Promise; onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise; @@ -39,7 +41,9 @@ export const SecretOverviewTableRow = ({ onSecretCreate, onSecretDelete, isImportedSecretPresentInEnv, - expandableColWidth + expandableColWidth, + onToggleSecretSelect, + isSelected }: Props) => { const [isFormExpanded, setIsFormExpanded] = useToggle(); const totalCols = environments.length + 1; // secret key row @@ -56,7 +60,21 @@ export const SecretOverviewTableRow = ({
- + { + onToggleSecretSelect(secretKey); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> +
{secretKey}
diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx new file mode 100644 index 000000000..6b95ffccf --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -0,0 +1,78 @@ +import { subject } from "@casl/ability"; +import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { usePopUp } from "@app/hooks"; + +import { useSelectedEntries, useSelectedEntryActions } from "../../SecretOverviewPage.store"; + +type Props = { + secretPath: string; +}; + +export const SelectionPanel = ({ secretPath }: Props) => { + const { permission } = useProjectPermission(); + + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "bulkDeleteEntries" + ] as const); + + const selectedEntries = useSelectedEntries(); + const { reset: resetSelectedEntries } = useSelectedEntryActions(); + const selectedCount = + Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length; + + const isMultiSelectActive = selectedCount > 0; + + // TODO: REVISIT RBAC + const shouldShowDelete = permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: "", secretPath }) + ); + + const handleBulkDelete = async () => { + handlePopUpClose("bulkDeleteEntries"); + }; + + return ( + <> +
+
+ + + + + +
{selectedCount} Selected
+ {shouldShowDelete && ( + + )} +
+
+ handlePopUpToggle("bulkDeleteEntries", isOpen)} + onDeleteApproved={handleBulkDelete} + /> + + ); +}; From 5d7c0f30c8a01894f45712bdab12d63eb335ac1e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 22:58:35 -0700 Subject: [PATCH 028/244] Fix typo universal auth --- backend/src/server/routes/v1/identity-aws-iam-auth-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index 0245b7607..12003a158 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide rateLimit: writeLimit }, schema: { - description: "Login with Universal Auth", + description: "Login with AWS IAM Auth", body: z.object({ identityId: z.string().describe(AWS_IAM_AUTH.LOGIN.identityId), iamHttpRequestMethod: z.string().default("POST").describe(AWS_IAM_AUTH.LOGIN.iamHttpRequestMethod), From e3c80309c3bfc9f7cd2705fefaa5bfa5489cad1c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 May 2024 23:03:45 -0700 Subject: [PATCH 029/244] Move aws auth migration file to front --- ...ty-aws-iam-auth.ts => 20240507055915_identity-aws-iam-auth.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240502044531_identity-aws-iam-auth.ts => 20240507055915_identity-aws-iam-auth.ts} (100%) diff --git a/backend/src/db/migrations/20240502044531_identity-aws-iam-auth.ts b/backend/src/db/migrations/20240507055915_identity-aws-iam-auth.ts similarity index 100% rename from backend/src/db/migrations/20240502044531_identity-aws-iam-auth.ts rename to backend/src/db/migrations/20240507055915_identity-aws-iam-auth.ts From a9b72b2da391003c18cdd2db230a02e49bc41b8b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 15:16:37 +0800 Subject: [PATCH 030/244] feat: added handling of folder/secret deletion --- .../src/hooks/api/secretFolders/queries.tsx | 16 +++- .../SecretOverviewPage/SecretOverviewPage.tsx | 9 ++- .../SelectionPanel/SelectionPanel.tsx | 78 ++++++++++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index bcda2b0a4..71c63f3eb 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -94,7 +94,21 @@ export const useGetFoldersByEnv = ({ [(folders || []).map((folder) => folder.data)] ); - return { folders, folderNames, isFolderPresentInEnv }; + const getFolderByNameAndEnv = useCallback( + (name: string, env: string) => { + const selectedEnvIndex = environments.indexOf(env); + if (selectedEnvIndex !== -1) { + return folders?.[selectedEnvIndex]?.data?.find( + ({ name: folderName }) => folderName === name + ); + } + + return undefined; + }, + [(folders || []).map((folder) => folder.data)] + ); + + return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; }; export const useCreateFolder = () => { diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index c6d97361a..cd00af883 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -134,7 +134,8 @@ export const SecretOverviewPage = () => { secretPath, decryptFileKey: latestFileKey! }); - const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({ + + const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({ projectId: workspaceId, path: secretPath, environments: userAvailableEnvs.map(({ slug }) => slug) @@ -548,7 +549,11 @@ export const SecretOverviewPage = () => {
- +
diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 6b95ffccf..f68e804bf 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -3,17 +3,27 @@ import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { createNotification } from "@app/components/notifications"; import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; +import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types"; import { useSelectedEntries, useSelectedEntryActions } from "../../SecretOverviewPage.store"; type Props = { secretPath: string; + getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; + getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined; }; -export const SelectionPanel = ({ secretPath }: Props) => { +export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPath }: Props) => { const { permission } = useProjectPermission(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ @@ -25,6 +35,12 @@ export const SelectionPanel = ({ secretPath }: Props) => { const selectedCount = Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length; + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const userAvailableEnvs = currentWorkspace?.environments || []; + const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: deleteFolder } = useDeleteFolder(); + const isMultiSelectActive = selectedCount > 0; // TODO: REVISIT RBAC @@ -34,7 +50,63 @@ export const SelectionPanel = ({ secretPath }: Props) => { ); const handleBulkDelete = async () => { - handlePopUpClose("bulkDeleteEntries"); + const promises = userAvailableEnvs.map(async (env) => { + await Promise.all( + Object.keys(selectedEntries.folder).map(async (folderName) => { + const folder = getFolderByNameAndEnv(folderName, env.slug); + if (folder) { + await deleteFolder({ + folderId: folder?.id, + path: secretPath, + environment: env.slug, + projectId: workspaceId + }); + } + }) + ); + + const secretsToDelete = Object.keys(selectedEntries.secret).reduce( + (accum: TDeleteSecretBatchDTO["secrets"], secretName) => { + const entry = getSecretByKey(env.slug, secretName); + if (entry) { + return [ + ...accum, + { + secretName: entry.key, + type: "shared" as "shared" + } + ]; + } + return accum; + }, + [] + ); + + if (secretsToDelete.length > 0) { + await deleteBatchSecretV3({ + secretPath, + workspaceId, + environment: env.slug, + secrets: secretsToDelete + }); + } + }); + + const results = await Promise.allSettled(promises); + const areEntriesDeleted = results.some((result) => result.status === "fulfilled"); + if (areEntriesDeleted) { + handlePopUpClose("bulkDeleteEntries"); + resetSelectedEntries(); + createNotification({ + type: "success", + text: "Successfully deleted selected secrets and folders" + }); + } else { + createNotification({ + type: "error", + text: "Failed to delete selected secrets and folders" + }); + } }; return ( From 536f51f6bacba9808e4e8e401bff6f2ffe0dce3d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 15:21:17 +0800 Subject: [PATCH 031/244] misc: added descriptive error message --- .../src/views/SecretOverviewPage/SecretOverviewPage.store.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx index df239d6ae..db544c7e8 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx @@ -70,7 +70,7 @@ export const StoreProvider = ({ children }: { children: ReactNode }) => { const useStoreContext = (selector: (state: SelectedEntriesState) => T): T => { const ctx = useContext(StoreContext); - if (!ctx) throw new Error("Missing "); + if (!ctx) throw new Error("Missing context provider"); return useStore(ctx, selector); }; From b6a73459a8cfd1ecd74c639f9da28d2837f20d48 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 16:37:10 +0800 Subject: [PATCH 032/244] misc: addressed rbac for bulk delete in overview --- .../SelectionPanel/SelectionPanel.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index f68e804bf..03d2d44be 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -43,14 +43,26 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa const isMultiSelectActive = selectedCount > 0; - // TODO: REVISIT RBAC - const shouldShowDelete = permission.can( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment: "", secretPath }) + // user should have the ability to delete secrets/folders in at least one of the envs + const shouldShowDelete = userAvailableEnvs.some((env) => + permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) ); const handleBulkDelete = async () => { const promises = userAvailableEnvs.map(async (env) => { + // additional check: ensure that bulk delete is only executed on envs that user has access to + if ( + permission.cannot( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) + ) { + return; + } + await Promise.all( Object.keys(selectedEntries.folder).map(async (folderName) => { const folder = getFolderByNameAndEnv(folderName, env.slug); From 06c103c10af0ec6a150ae29303695d5a53e26d49 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 22:19:20 +0800 Subject: [PATCH 033/244] misc: added handling for no changes made --- .../components/SelectionPanel/SelectionPanel.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 03d2d44be..4f331e989 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -52,6 +52,8 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa ); const handleBulkDelete = async () => { + let processedEntries = 0; + const promises = userAvailableEnvs.map(async (env) => { // additional check: ensure that bulk delete is only executed on envs that user has access to if ( @@ -67,6 +69,7 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa Object.keys(selectedEntries.folder).map(async (folderName) => { const folder = getFolderByNameAndEnv(folderName, env.slug); if (folder) { + processedEntries += 1; await deleteFolder({ folderId: folder?.id, path: secretPath, @@ -95,6 +98,7 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa ); if (secretsToDelete.length > 0) { + processedEntries += secretsToDelete.length; await deleteBatchSecretV3({ secretPath, workspaceId, @@ -106,7 +110,13 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa const results = await Promise.allSettled(promises); const areEntriesDeleted = results.some((result) => result.status === "fulfilled"); - if (areEntriesDeleted) { + if (processedEntries === 0) { + handlePopUpClose("bulkDeleteEntries"); + createNotification({ + type: "info", + text: "No changes have been made. Ensure that you have sufficient access." + }); + } else if (areEntriesDeleted) { handlePopUpClose("bulkDeleteEntries"); resetSelectedEntries(); createNotification({ From c5cd5047d71cd1888b445f74e8b62dbeb37ea42a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 7 May 2024 07:59:37 -0700 Subject: [PATCH 034/244] Update trusted email migration file with backfill --- .../20240507032811_trusted-saml-ldap-emails.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts index 63aa75ad8..410ee0f00 100644 --- a/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts +++ b/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts @@ -5,9 +5,16 @@ 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 hasIsEmailVerifiedColumn = await knex.schema.hasColumn(TableName.Users, "isEmailVerified"); + + if (!hasIsEmailVerifiedColumn) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + } + + // Backfilling the isEmailVerified to true where isAccepted is true + await knex(TableName.Users).update({ isEmailVerified: true }).where("isAccepted", true); } const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases); From 1d2f10178f59267743b5b815f3dd9dcdf041be18 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 29 Mar 2024 16:24:15 +0100 Subject: [PATCH 035/244] Draft --- .../src/ee/services/license/licence-fns.ts | 2 +- frontend/src/components/basic/Divider.tsx | 15 ++++++++ .../permissions/PermissionDeniedBanner.tsx | 36 ++++++++++--------- frontend/src/layouts/AppLayout/AppLayout.tsx | 4 +-- .../SecretApprovalPage/SecretApprovalPage.tsx | 22 +++++++----- 5 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/basic/Divider.tsx diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..492d09ec3 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true }); diff --git a/frontend/src/components/basic/Divider.tsx b/frontend/src/components/basic/Divider.tsx new file mode 100644 index 000000000..f1a570d7b --- /dev/null +++ b/frontend/src/components/basic/Divider.tsx @@ -0,0 +1,15 @@ +import { twMerge } from "tailwind-merge"; + +interface IProps { + className?: string; +} + +const Divider = ({ className }: IProps): JSX.Element => { + return ( +
+ + ); +}; + +export default Divider; diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index 067ee9c4a..f9707d634 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,6 +3,8 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { Button } from "../v2"; + type Props = { containerClassName?: string; className?: string; @@ -17,24 +19,24 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children containerClassName )} > -
-
- -
-
-
Access Restricted
- {children || ( -
- Your role has limited permissions, please
contact your administrator to gain - access -
- )} +
+
+
+ +
+
+
Access Restricted
+ {children || ( +
+ Your role has limited permissions, please
contact your administrator to gain + access +
+ )} +
+
); diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 2550150f3..a6c25c7c2 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -115,7 +115,7 @@ type TAddProjectFormData = yup.InferType; export const AppLayout = ({ children }: LayoutProps) => { const router = useRouter(); - + const { mutateAsync } = useGetOrgTrialUrl(); const { workspaces, currentWorkspace } = useWorkspace(); @@ -554,7 +554,7 @@ export const AppLayout = ({ children }: LayoutProps) => { } icon="system-outline-189-domain-verification" > - Secret Approvals + Approvals {Boolean(secretApprovalReqCount?.open) && ( {secretApprovalReqCount?.open} diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index ce1896efa..4ab5830fe 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Divider from "@app/components/basic/Divider"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useWorkspace } from "@app/context"; @@ -9,8 +10,10 @@ import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList" import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; enum TabSection { - ApprovalRequests = "approval-requests", - Rules = "approval-rules" + SecretApprovalRequests = "approval-requests", + SecretPolicies = "approval-rules", + ResourcePolicies = "resource-rules", + ResourceApprovalRequests = "resource-requests" } export const SecretApprovalPage = () => { @@ -21,7 +24,7 @@ export const SecretApprovalPage = () => {
-

Secret Approval Workflows

+

Approval Workflows

Create approval policies for any modifications to secrets in sensitive environments and folders. @@ -39,15 +42,18 @@ export const SecretApprovalPage = () => {

- + - Secret PRs - Policies + Secret Approvals + Secret Policies + + Resource Approvals + Resource Policies - + - + From 12c655a152e0e5d9e752ad5579333ad6e8d64277 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 08:22:46 -0700 Subject: [PATCH 036/244] Feat: Request Access --- backend/src/@types/fastify.d.ts | 2 ++ backend/src/@types/knex.d.ts | 19 +++++++++++++++++++ backend/src/db/schemas/index.ts | 2 ++ backend/src/db/schemas/models.ts | 6 ++++++ backend/src/ee/routes/v1/index.ts | 3 +++ .../server/plugins/auth/inject-identity.ts | 8 ++++++++ 6 files changed, 40 insertions(+) diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index b3e9d9952..12c07486f 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,6 +1,7 @@ import "fastify"; import { TUsers } from "@app/db/schemas"; +import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; @@ -113,6 +114,7 @@ declare module "fastify" { identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; + accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; secretApprovalRequest: TSecretApprovalRequestServiceFactory; secretRotation: TSecretRotationServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index a7d76e944..b6e8744ed 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -2,6 +2,12 @@ import { Knex } from "knex"; import { TableName, + TAccessApprovalPolicies, + TAccessApprovalPoliciesApprovers, + TAccessApprovalPoliciesApproversInsert, + TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesInsert, + TAccessApprovalPoliciesUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, @@ -344,6 +350,19 @@ declare module "knex/types/tables" { TIdentityProjectAdditionalPrivilegeInsert, TIdentityProjectAdditionalPrivilegeUpdate >; + + [TableName.AccessApprovalPolicy]: Knex.CompositeTableType< + TAccessApprovalPolicies, + TAccessApprovalPoliciesInsert, + TAccessApprovalPoliciesUpdate + >; + + [TableName.AccessApprovalPolicyApprover]: Knex.CompositeTableType< + TAccessApprovalPoliciesApprovers, + TAccessApprovalPoliciesApproversInsert, + TAccessApprovalPoliciesApproversUpdate + >; + [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 0eb9b1986..f02188577 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,3 +1,5 @@ +export * from "./access-approval-policies"; +export * from "./access-approval-policies-approvers"; export * from "./api-keys"; export * from "./audit-log-streams"; export * from "./audit-logs"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 3baa7f40d..8e66cabe4 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -50,12 +50,18 @@ export enum TableName { IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", ScimToken = "scim_tokens", + + // New tables so far + AccessApprovalPolicy = "access_approval_policies", + AccessApprovalPolicyApprover = "access_approval_policies_approvers", + SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalRequest = "secret_approval_requests", SecretApprovalRequestReviewer = "secret_approval_requests_reviewers", SecretApprovalRequestSecret = "secret_approval_requests_secrets", SecretApprovalRequestSecretTag = "secret_approval_request_secret_tags", + SecretRotation = "secret_rotations", SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index cf325b2e3..086a1b3d6 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,4 +1,5 @@ import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; @@ -41,6 +42,8 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { prefix: "/secret-rotation-providers" }); + await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals" }); + await server.register( async (dynamicSecretRouter) => { await dynamicSecretRouter.register(registerDynamicSecretRouter); diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 4c0683797..71522dcc2 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -108,8 +108,16 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { if (req.url.includes("/api/v3/auth/")) { return; } + + console.log("authMode", authMode); + console.log("authMode", authMode); + console.log("authMode", authMode); + console.log("authMode", authMode); + if (!authMode) return; + console.log("authMode", authMode); + switch (authMode) { case AuthMode.JWT: { const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); From 91bf6a6dad439a3ead05fbd9932ca5ff0b070ca1 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 08:23:02 -0700 Subject: [PATCH 037/244] Fix: Remove logs --- backend/src/server/plugins/auth/inject-identity.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 71522dcc2..d8814dd40 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -109,15 +109,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { return; } - console.log("authMode", authMode); - console.log("authMode", authMode); - console.log("authMode", authMode); - console.log("authMode", authMode); - if (!authMode) return; - console.log("authMode", authMode); - switch (authMode) { case AuthMode.JWT: { const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); From 68a11db1c6914f4987ca0d022ec18331a32c286c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:49:22 -0700 Subject: [PATCH 038/244] Feat: Request access --- backend/scripts/generate-schema-types.ts | 2 +- .../20240330075122_access-approval-policy.ts | 38 +++ .../access-approval-policies-approvers.ts | 25 ++ .../db/schemas/access-approval-policies.ts | 23 ++ .../v1/access-approval-policy-router.ts | 133 ++++++++++ .../access-approval-policy-approver-dal.ts | 10 + .../access-approval-policy-dal.ts | 76 ++++++ .../access-approval-policy-service.ts | 206 +++++++++++++++ .../access-approval-policy-types.ts | 27 ++ backend/src/server/routes/index.ts | 18 ++ .../src/hooks/api/accessApproval/index.tsx | 6 + .../src/hooks/api/accessApproval/mutation.tsx | 58 ++++ .../src/hooks/api/accessApproval/queries.tsx | 40 +++ .../src/hooks/api/accessApproval/types.ts | 45 ++++ frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/types.ts | 17 +- .../SecretApprovalPage/SecretApprovalPage.tsx | 9 +- .../AccessApprovalPolicyList.tsx | 166 ++++++++++++ .../components/AccessApprovalPolicyRow.tsx | 145 ++++++++++ .../components/AccessPolicyForm.tsx | 250 ++++++++++++++++++ .../AccessApprovalPolicyList/index.tsx | 1 + 21 files changed, 1285 insertions(+), 11 deletions(-) create mode 100644 backend/src/db/migrations/20240330075122_access-approval-policy.ts create mode 100644 backend/src/db/schemas/access-approval-policies-approvers.ts create mode 100644 backend/src/db/schemas/access-approval-policies.ts create mode 100644 backend/src/ee/routes/v1/access-approval-policy-router.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts create mode 100644 backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts create mode 100644 frontend/src/hooks/api/accessApproval/index.tsx create mode 100644 frontend/src/hooks/api/accessApproval/mutation.tsx create mode 100644 frontend/src/hooks/api/accessApproval/queries.tsx create mode 100644 frontend/src/hooks/api/accessApproval/types.ts create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991f..28b736152 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env.migration") + path: path.join(__dirname, "../../.env") }); const db = knex({ diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts new file mode 100644 index 000000000..0595d5c6e --- /dev/null +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicy))) { + await knex.schema.createTable(TableName.AccessApprovalPolicy, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.integer("approvals").defaultTo(1).notNullable(); + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); + + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyApprover, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("approverId").notNullable(); + t.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); +} diff --git a/backend/src/db/schemas/access-approval-policies-approvers.ts b/backend/src/db/schemas/access-approval-policies-approvers.ts new file mode 100644 index 000000000..4ebbfa9ae --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-approvers.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalPoliciesApproversSchema = z.object({ + id: z.string().uuid(), + approverId: z.string().uuid(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesApprovers = z.infer; +export type TAccessApprovalPoliciesApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts new file mode 100644 index 000000000..550049702 --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalPoliciesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + approvals: z.number().default(1), + envId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPolicies = z.infer; +export type TAccessApprovalPoliciesInsert = Omit, TImmutableDBKeys>; +export type TAccessApprovalPoliciesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts new file mode 100644 index 000000000..ec5331a89 --- /dev/null +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -0,0 +1,133 @@ +import { nanoid } from "nanoid"; +import { z } from "zod"; + +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z + .object({ + workspaceId: z.string(), + name: z.string().optional(), + environment: z.string(), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.accessApprovalPolicy.createAccessApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.workspaceId, + ...req.body, + name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` + }); + return { approval }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + approvals: sapPubSchema.merge(z.object({ approvers: z.string().array() })).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.workspaceId + }); + return { approvals }; + } + }); + + server.route({ + url: "/:policyId", + method: "PATCH", + schema: { + params: z.object({ + policyId: z.string() + }), + body: z + .object({ + name: z.string().optional(), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.accessApprovalPolicy.updateAccessApprovalPolicy({ + policyId: req.params.policyId, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + ...req.body + }); + } + }); + + server.route({ + url: "/:policyId", + method: "DELETE", + schema: { + params: z.object({ + policyId: z.string() + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.accessApprovalPolicy.deleteAccessApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + policyId: req.params.policyId + }); + return { approval }; + } + }); +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts new file mode 100644 index 000000000..e14854d8f --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAccessApprovalPolicyApproverDALFactory = ReturnType; + +export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => { + const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover); + return { ...accessApprovalPolicyApproverOrm }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts new file mode 100644 index 000000000..792a851c3 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -0,0 +1,76 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TAccessApprovalPolicies } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex"; + +export type TAccessApprovalPolicyDALFactory = ReturnType; + +export const accessApprovalPolicyDALFactory = (db: TDbClient) => { + const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy); + + const sapFindQuery = async (tx: Knex, filter: TFindFilter) => { + const result = await tx(TableName.AccessApprovalPolicy) + // eslint-disable-next-line + .where(buildFindFilter(filter)) + .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .join( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + .select(tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) + .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) + .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) + .select(tx.ref("projectId").withSchema(TableName.Environment)) + .select(selectAllTableCols(TableName.AccessApprovalPolicy)); + + return result; + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await sapFindQuery(tx || db, { + [`${TableName.AccessApprovalPolicy}.id` as "id"]: id + }); + const formatedDoc = mergeOneToManyRelation( + doc, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + const find = async (filter: TFindFilter, tx?: Knex) => { + try { + const docs = await sapFindQuery(tx || db, filter); + const formatedDoc = mergeOneToManyRelation( + docs, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc; + } catch (error) { + throw new DatabaseError({ error, name: "Find" }); + } + }; + + return { ...accessApprovalPolicyOrm, find, findById }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts new file mode 100644 index 000000000..d1f0ce1e7 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -0,0 +1,206 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; +import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; +import { + TCreateAccessApprovalPolicy, + TDeleteAccessApprovalPolicy, + TListAccessApprovalPoliciesDTO, + TUpdateAccessApprovalPolicy +} from "./access-approval-policy-types"; + +type TSecretApprovalPolicyServiceFactoryDep = { + permissionService: Pick; + accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; + projectEnvDAL: Pick; + accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + projectMembershipDAL: Pick; +}; + +export type TAccessApprovalPolicyServiceFactory = ReturnType; + +export const accessApprovalPolicyServiceFactory = ({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectMembershipDAL +}: TSecretApprovalPolicyServiceFactoryDep) => { + const createAccessApprovalPolicy = async ({ + name, + actor, + actorId, + actorOrgId, + actorAuthMethod, + approvals, + approvers, + projectId, + environment + }: TCreateAccessApprovalPolicy) => { + if (approvals > approvers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretApproval + ); + const env = await projectEnvDAL.findOne({ slug: environment, projectId }); + if (!env) throw new BadRequestError({ message: "Environment not found" }); + + const secretApprovers = await projectMembershipDAL.find({ + projectId, + $in: { id: approvers } + }); + if (secretApprovers.length !== approvers.length) { + throw new BadRequestError({ message: "Approver not found in project" }); + } + + const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { + const doc = await accessApprovalPolicyDAL.create( + { + envId: env.id, + approvals, + name + }, + tx + ); + await accessApprovalPolicyApproverDAL.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + return doc; + }); + return { ...accessApproval, environment: env, projectId }; + }; + + const getAccessApprovalPolicyByProjectId = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TListAccessApprovalPoliciesDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId }); + return accessApprovalPolicies; + }; + + const updateAccessApprovalPolicy = async ({ + policyId, + approvers, + name, + actorId, + actor, + actorOrgId, + actorAuthMethod, + approvals + }: TUpdateAccessApprovalPolicy) => { + const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); + if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + accessApprovalPolicy.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + + const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { + const doc = await accessApprovalPolicyDAL.updateById( + accessApprovalPolicy.id, + { + approvals, + name + }, + tx + ); + if (approvers) { + // Find the workspace project memberships of the users passed in the approvers array + const secretApprovers = await projectMembershipDAL.find( + { + projectId: accessApprovalPolicy.projectId, + $in: { id: approvers } + }, + { tx } + ); + if (secretApprovers.length !== approvers.length) + throw new BadRequestError({ message: "Approver not found in project" }); + if (doc.approvals > secretApprovers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); + await accessApprovalPolicyApproverDAL.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + } + return doc; + }); + return { + ...updatedPolicy, + environment: accessApprovalPolicy.environment, + projectId: accessApprovalPolicy.projectId + }; + }; + + const deleteAccessApprovalPolicy = async ({ + policyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteAccessApprovalPolicy) => { + const policy = await accessApprovalPolicyDAL.findById(policyId); + if (!policy) throw new BadRequestError({ message: "Secret approval policy not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + policy.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SecretApproval + ); + + await accessApprovalPolicyDAL.deleteById(policyId); + return policy; + }; + + return { + createAccessApprovalPolicy, + deleteAccessApprovalPolicy, + updateAccessApprovalPolicy, + getAccessApprovalPolicyByProjectId + }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts new file mode 100644 index 000000000..034132d04 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -0,0 +1,27 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateAccessApprovalPolicy = { + approvals: number; + environment: string; + approvers: string[]; + projectId: string; + name: string; +} & Omit; + +export type TUpdateAccessApprovalPolicy = { + policyId: string; + approvals?: number; + approvers: string[]; + name?: string; +} & Omit; + +export type TDeleteAccessApprovalPolicy = { + policyId: string; +} & Omit; + +export type TListAccessApprovalPoliciesDTO = TProjectPermission; + +export type TGetBoardAccessApprovalPolicy = { + projectId: string; + environment: string; +} & Omit; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index aeb66d93f..0a2c77c9f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2,6 +2,9 @@ import { Knex } from "knex"; import { z } from "zod"; import { registerV1EERoutes } from "@app/ee/routes/v1"; +import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; +import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; @@ -207,6 +210,10 @@ export const registerRoutes = async ( const scimDAL = scimDALFactory(db); const ldapConfigDAL = ldapConfigDALFactory(db); const ldapGroupMapDAL = ldapGroupMapDALFactory(db); + + const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); + const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); @@ -265,6 +272,15 @@ export const registerRoutes = async ( secretApprovalPolicyDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); + + const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectMembershipDAL + }); + const samlService = samlConfigServiceFactory({ permissionService, orgBotDAL, @@ -596,6 +612,7 @@ export const registerRoutes = async ( secretVersionTagDAL, secretQueueService }); + const secretRotationQueue = secretRotationQueueFactory({ telemetryService, secretRotationDAL, @@ -732,6 +749,7 @@ export const registerRoutes = async ( identityProject: identityProjectService, identityUa: identityUaService, secretApprovalPolicy: sapService, + accessApprovalPolicy: accessApprovalPolicyService, secretApprovalRequest: sarService, secretRotation: secretRotationService, dynamicSecret: dynamicSecretService, diff --git a/frontend/src/hooks/api/accessApproval/index.tsx b/frontend/src/hooks/api/accessApproval/index.tsx new file mode 100644 index 000000000..537dfe3be --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateAccessApprovalPolicy, + useDeleteAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "./mutation"; +export { useGetAccessApprovalPolicies } from "./queries"; diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx new file mode 100644 index 000000000..98e9b3f97 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { accessApprovalKeys } from "./queries"; +import { TCreateAccessPolicyDTO, TDeleteSecretPolicyDTO, TUpdateAccessPolicyDTO } from "./types"; + +export const useCreateAccessApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateAccessPolicyDTO>({ + mutationFn: async ({ environment, workspaceId, approvals, approvers, name }) => { + const { data } = await apiRequest.post("/api/v1/access-approvals", { + environment, + workspaceId, + approvals, + approvers, + name + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId)); + } + }); +}; + +export const useUpdateAccessApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateAccessPolicyDTO>({ + mutationFn: async ({ id, approvers, approvals, name }) => { + const { data } = await apiRequest.patch(`/api/v1/access-approvals/${id}`, { + approvals, + approvers, + name + }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId)); + } + }); +}; + +export const useDeleteAccessApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretPolicyDTO>({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.delete(`/api/v1/access-approvals/${id}`); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx new file mode 100644 index 000000000..864122593 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TAccessApprovalPolicy, TGetSecretApprovalPoliciesDTO } from "./types"; + +export const accessApprovalKeys = { + getAccessApprovalPolicies: (workspaceId: string) => + [{ workspaceId }, "access-approval-policies"] as const, + getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => [ + { workspaceId, environment }, + "access-approval-policy" + ] +}; + +const fetchApprovalPolicies = async (workspaceId: string) => { + const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>( + "/api/v1/access-approvals", + { params: { workspaceId } } + ); + return data.approvals; +}; + +export const useGetAccessApprovalPolicies = ({ + workspaceId, + options = {} +}: TGetSecretApprovalPoliciesDTO & { + options?: UseQueryOptions< + TAccessApprovalPolicy[], + unknown, + TAccessApprovalPolicy[], + ReturnType + >; +}) => + useQuery({ + queryKey: accessApprovalKeys.getAccessApprovalPolicies(workspaceId), + queryFn: () => fetchApprovalPolicies(workspaceId), + ...options, + enabled: Boolean(workspaceId) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts new file mode 100644 index 000000000..63020166b --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -0,0 +1,45 @@ +import { WorkspaceEnv } from "../workspace/types"; + +export type TAccessApprovalPolicy = { + id: string; + name: string; + approvals: number; + envId: string; + workspace: string; + environment: WorkspaceEnv; + projectId: string; + approvers: string[]; +}; + +export type TGetSecretApprovalPoliciesDTO = { + workspaceId: string; +}; + +export type TGetSecretApprovalPolicyOfBoardDTO = { + workspaceId: string; + environment: string; + secretPath: string; +}; + +export type TCreateAccessPolicyDTO = { + workspaceId: string; + name?: string; + environment: string; + approvers?: string[]; + approvals?: number; +}; + +export type TUpdateAccessPolicyDTO = { + id: string; + name?: string; + approvers?: string[]; + approvals?: number; + // for invalidating list + workspaceId: string; +}; + +export type TDeleteSecretPolicyDTO = { + id: string; + // for invalidating list + workspaceId: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 574da5a31..61e8cb666 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,3 +1,4 @@ +export * from "./accessApproval"; export * from "./admin"; export * from "./apiKeys"; export * from "./auditLogs"; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 49949d88e..516a5d7cf 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,5 +1,6 @@ import { ZodIssue } from "zod"; +export type { TAccessApprovalPolicy } from "./accessApproval/types"; export type { TAuditLogStream } from "./auditLogStreams/types"; export type { GetAuthTokenAPI } from "./auth/types"; export type { IncidentContact } from "./incidentContacts/types"; @@ -49,13 +50,13 @@ export enum ApiErrorTypes { export type TApiErrors = | { - error: ApiErrorTypes.ValidationError; - message: ZodIssue[]; - statusCode: 403; - } + error: ApiErrorTypes.ValidationError; + message: ZodIssue[]; + statusCode: 403; + } | { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 } | { - statusCode: 400; - message: string; - error: ApiErrorTypes.BadRequestError; - }; + statusCode: 400; + message: string; + error: ApiErrorTypes.BadRequestError; + }; diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 4ab5830fe..ebbfd7b5e 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -6,6 +6,7 @@ import Divider from "@app/components/basic/Divider"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useWorkspace } from "@app/context"; +import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList"; import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; @@ -47,8 +48,8 @@ export const SecretApprovalPage = () => { Secret Approvals Secret Policies - Resource Approvals - Resource Policies + Access Approvals + Access Policies @@ -56,6 +57,10 @@ export const SecretApprovalPage = () => { + + + +
); diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx new file mode 100644 index 000000000..834b54f00 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx @@ -0,0 +1,166 @@ +import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteAccessApprovalPolicy, useGetWorkspaceUsers } from "@app/hooks/api"; +import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; + +import { AccessApprovalPolicyRow } from "./components/AccessApprovalPolicyRow"; +import { AccessPolicyForm } from "./components/AccessPolicyForm"; + +interface IProps { + workspaceId: string; +} + +export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { + const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ + "secretPolicyForm", + "deletePolicy", + "upgradePlan" + ] as const); + const { permission } = useProjectPermission(); + const { subscription } = useSubscription(); + + const { data: members } = useGetWorkspaceUsers(workspaceId); + const { data: policies, isLoading: isPoliciesLoading } = useGetAccessApprovalPolicies({ + workspaceId, + options: { + enabled: permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) + } + }); + + const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteAccessApprovalPolicy(); + + const handleDeletePolicy = async () => { + const { id } = popUp.deletePolicy.data as TAccessApprovalPolicy; + try { + await deleteSecretApprovalPolicy({ + workspaceId, + id + }); + createNotification({ + type: "success", + text: "Successfully deleted policy" + }); + handlePopUpClose("deletePolicy"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete policy" + }); + } + }; + + return ( +
+
+
+ Access Approval Policies +
+ Implement policies to prevent unauthorized secret changes. +
+
+
+ + {(isAllowed) => ( + + )} + +
+
+ +
+ + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !policies?.length && ( + + + + )} + {policies?.map((policy) => ( + handlePopUpOpen("secretPolicyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
NameEnvironmentSecret PathApproval Required +
+ +
+
+ handlePopUpToggle("secretPolicyForm", isOpen)} + members={members} + editValues={popUp.secretPolicyForm.data as TAccessApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret approval policy if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx new file mode 100644 index 000000000..54eaae67c --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { useUpdateAccessApprovalPolicy } from "@app/hooks/api"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + policy: TAccessApprovalPolicy; + members?: TWorkspaceUser[]; + workspaceId: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const AccessApprovalPolicyRow = ({ + policy, + members = [], + workspaceId, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState([]); + const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); + const { permission } = useProjectPermission(); + + return ( +
{policy.name}{policy.environment.slug} + { + if (!isOpen) { + updateAccessApprovalPolicy( + { + workspaceId, + id: policy.id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + + Select members that are allowed to approve changes + + {members?.map(({ id, user }) => { + const isChecked = selectedApprovers.includes(id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== id) : [...state, id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + {policy.approvals} +
+ + {(isAllowed) => ( + + + + )} + + + {(isAllowed) => ( + + + + )} + +
+
Name Environment Secret PathEligible Approvers Approval Required
handlePopUpToggle("secretPolicyForm", isOpen)} members={members} From a4b6d2650a9d052b2fde1ddd39faa3deb2c60355 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:49:57 -0700 Subject: [PATCH 059/244] Fix: Move to project slug --- .../components/AccessApprovalPolicyRow.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx index 54eaae67c..8554e928e 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx @@ -22,7 +22,7 @@ import { TWorkspaceUser } from "@app/hooks/api/users/types"; type Props = { policy: TAccessApprovalPolicy; members?: TWorkspaceUser[]; - workspaceId: string; + projectSlug: string; onEdit: () => void; onDelete: () => void; }; @@ -30,7 +30,7 @@ type Props = { export const AccessApprovalPolicyRow = ({ policy, members = [], - workspaceId, + projectSlug, onEdit, onDelete }: Props) => { @@ -42,13 +42,14 @@ export const AccessApprovalPolicyRow = ({ {policy.name} {policy.environment.slug} + {policy.secretPath || "*"} { if (!isOpen) { updateAccessApprovalPolicy( { - workspaceId, + projectSlug, id: policy.id, approvers: selectedApprovers }, From ddd6adf804af8849bb81ade952b9bc107bf1dd92 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:50:16 -0700 Subject: [PATCH 060/244] Fix: Move to project slug --- .../components/AccessPolicyForm.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index 56a4bdef5..6b53e2f1e 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -32,7 +32,7 @@ type Props = { isOpen?: boolean; onToggle: (isOpen: boolean) => void; members?: TWorkspaceUser[]; - workspaceId: string; + projectSlug: string; editValues?: TAccessApprovalPolicy; }; @@ -40,7 +40,7 @@ const formSchema = z .object({ environment: z.string(), name: z.string().optional(), - secretPath: z.string().optional().nullable(), + secretPath: z.string().optional(), approvals: z.number().min(1), approvers: z.string().array().min(1) }) @@ -55,7 +55,7 @@ export const AccessPolicyForm = ({ isOpen, onToggle, members = [], - workspaceId, + projectSlug, editValues }: Props) => { const { @@ -80,10 +80,12 @@ export const AccessPolicyForm = ({ const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); const handleCreatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + try { await createAccessApprovalPolicy({ ...data, - workspaceId + projectSlug }); createNotification({ type: "success", @@ -100,12 +102,14 @@ export const AccessPolicyForm = ({ }; const handleUpdatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; if (!editValues?.id) return; + try { await updateAccessApprovalPolicy({ id: editValues?.id, ...data, - workspaceId + projectSlug }); createNotification({ type: "success", @@ -154,6 +158,7 @@ export const AccessPolicyForm = ({ errorText={error?.message} > + + )} + /> + Date: Wed, 3 Apr 2024 16:50:32 -0700 Subject: [PATCH 061/244] Feat: Request access --- .../AccessApprovalRequest.tsx | 577 ++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx new file mode 100644 index 000000000..ffb8ac049 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -0,0 +1,577 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable react/jsx-no-useless-fragment */ +import { useCallback, useMemo, useState } from "react"; +import { + faCheck, + faCheckCircle, + faChevronDown, + faLockOpen +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + Modal, + ModalContent, + UpgradePlanModal +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetWorkspaceUsers, useReviewAccessRequest } from "@app/hooks/api"; +import { + useGetAccessApprovalPolicies, + useGetAccessApprovalRequests, + useGetAccessRequestsCount +} from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { ApprovalStatus, TAccessApprovalPolicy, TWorkspaceUser } from "@app/hooks/api/types"; +import { SpecificPrivilegeSecretForm } from "@app/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection"; + +const DisplayBadge = ({ text, className }: { text: string; className?: string }) => { + return ( +
+ {text} +
+ ); +}; + +const ReviewRequestModal = ({ + isOpen, + onOpenChange, + request, + projectSlug, + selectedRequester, + selectedEnvSlug +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }; + projectSlug: string; + selectedRequester: string | undefined; + selectedEnvSlug: string | undefined; +}) => { + const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); + + const accessDetails = { + env: request.environmentName, + // secret path will be inside $glob operator + secretPath: request.policy.secretPath, + read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)), + edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)), + create: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + + temporaryAccess: { + isTemporary: request.isTemporary, + temporaryRange: request.temporaryRange + } + }; + + const requestedAccess = useMemo(() => { + const access: string[] = []; + if (accessDetails.read) access.push("Read"); + if (accessDetails.edit) access.push("Edit"); + if (accessDetails.create) access.push("Create"); + if (accessDetails.delete) access.push("Delete"); + + return access.join(", "); + }, [accessDetails]); + + const getAccessLabel = () => { + if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange) + return "Permanent"; + + // convert the range to human readable format + ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true }); + + return ( + + ); + }; + + const reviewAccessRequest = useReviewAccessRequest(); + + const handleReview = useCallback(async (status: "approved" | "rejected") => { + setIsLoading(status); + try { + await reviewAccessRequest.mutateAsync({ + requestId: request.id, + status, + projectSlug, + envSlug: selectedEnvSlug, + requestedBy: selectedRequester + }); + } catch (error) { + console.error(error); + setIsLoading(null); + return; + } + + createNotification({ + title: `Request ${status}`, + text: `The request has been ${status}`, + type: status === "approved" ? "success" : "info" + }); + + setIsLoading(null); + onOpenChange(false); + }, []); + + return ( + + +
+ + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + {" "} + is requesting access to the following resource + + +
+
+ Requested path: + +
+ +
+ Permissions: + +
+ +
+ Access Type: + {getAccessLabel()} +
+
+ +
+ + +
+
+
+
+ ); +}; + +const SelectAccessModal = ({ + isOpen, + onOpenChange, + policies +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + policies: TAccessApprovalPolicy[]; +}) => { + return ( + + + onOpenChange(false)} policies={policies} /> + + + ); +}; + +const generateRequestText = (request: TAccessApprovalRequest) => { + const { isTemporary } = request; + + return ( + + Requested {isTemporary ? "temporary" : "permanent"} access to{" "} + + {request.policy.secretPath} + + in + + {request.environmentName} + + + ); +}; + +export const AccessApprovalRequest = ({ + projectSlug, + projectId +}: { + projectSlug: string; + projectId: string; +}) => { + const [selectedRequest, setSelectedRequest] = useState< + (TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }) | null + >(null); + + const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([ + "requestAccess", + "reviewRequest", + "upgradePlan" + ] as const); + const { permission, membership } = useProjectPermission(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: members } = useGetWorkspaceUsers(projectId); + const membersGroupById = members?.reduce>( + (prev, curr) => ({ ...prev, [curr.id]: curr }), + {} + ); + + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); + const [requestedByFilter, setRequestedByFilter] = useState(undefined); + const [envFilter, setEnvFilter] = useState(undefined); + + console.log("requestedByFilter", requestedByFilter); + console.log("envFilter", envFilter); + + const { data: requestCount } = useGetAccessRequestsCount({ + projectSlug + }); + + const { data: policies, isLoading: policiesLoading } = useGetAccessApprovalPolicies({ + projectSlug, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!projectSlug + } + }); + + const { data: requests } = useGetAccessApprovalRequests({ + projectSlug, + authorProjectMembershipId: requestedByFilter, + envSlug: envFilter + }); + + const filteredRequests = useMemo(() => { + if (statusFilter === "open") return requests?.filter((request) => !request.isApproved); + if (statusFilter === "close") return requests?.filter((request) => request.isApproved); + + return requests; + }, [requests, statusFilter, requestedByFilter, envFilter]); + + const generateRequestDetails = (request: TAccessApprovalRequest) => { + const isReviewedByUser = + request.reviewers.findIndex(({ member }) => member === membership.id) !== -1; + const isRejectedByAnyone = request.reviewers.some( + ({ status }) => status === ApprovalStatus.REJECTED + ); + const isApprover = request.policy.approvers.indexOf(membership.id || "") !== -1; + const isAccepted = request.isApproved; + + const userReviewStatus = request.reviewers.find( + ({ member }) => member === membership.id + )?.status; + + let displayData: { label: string; colorClass: string } = { label: "", colorClass: "" }; + + const isExpired = + request.privilege && + request.isApproved && + new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); + + if (isExpired) displayData = { label: "Access Expired", colorClass: "bg-red/20 text-red" }; + else if (isAccepted) + displayData = { label: "Access Granted", colorClass: "bg-green/20 text-green" }; + else if (isRejectedByAnyone) + displayData = { label: "Rejected", colorClass: "bg-red/20 text-red" }; + else if (userReviewStatus === ApprovalStatus.APPROVED) + displayData = { + label: `Pending ${request.policy.approvals - request.reviewers.length} reviews`, + colorClass: "bg-yellow/20 text-yellow" + }; + else if (!isReviewedByUser) + displayData = { + label: "Review Required", + colorClass: "bg-yellow/20 text-yellow" + }; + + return { + displayData, + isReviewedByUser, + isRejectedByAnyone, + isApprover, + userReviewStatus, + isAccepted + }; + }; + + return ( +
+
+
+ Access Approval Requests +
+ Request access to secrets in sensitive environments and folders. +
+
+
+ + {(isAllowed) => ( + + )} + +
+
+ + + +
+
setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={ + statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" + } + > + + {!!requestCount && requestCount?.pendingCount} Pending +
+
setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {!!requestCount && requestCount.finalizedCount} Completed +
+
+ + + + + + Select an environment + {currentWorkspace?.environments.map(({ slug, name }) => ( + setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } + iconPos="right" + > + {name} + + ))} + + + + + + + + Select an author + {members?.map(({ user, id }) => ( + + setRequestedByFilter((state) => (state === id ? undefined : id)) + } + key={`request-filter-member-${id}`} + icon={requestedByFilter === id && } + iconPos="right" + > + {user.email} + + ))} + + +
+
+
+ {filteredRequests?.length === 0 && ( +
+ +
+ )} + {!!filteredRequests?.length && + requests?.map((request) => { + const details = generateRequestDetails(request); + + return ( +
{ + if (details.isReviewedByUser || details.isRejectedByAnyone) return; + + setSelectedRequest({ + ...request, + user: membersGroupById?.[request.requestedBy].user! + }); + handlePopUpOpen("reviewRequest"); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedRequest({ + ...request, + user: membersGroupById?.[request.requestedBy].user! + }); + handlePopUpOpen("reviewRequest"); + } + }} + > +
+
+ + {generateRequestText(request)} +
+ + Requested {formatDistance(new Date(request.createdAt), new Date())} ago by{" "} + {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "} + {membersGroupById?.[request.requestedBy]?.user?.lastName} ( + {membersGroupById?.[request.requestedBy]?.user?.email}){" "} + + {details.isApprover && + !details.isReviewedByUser && + !details.isAccepted && + "- Review required"} + + + + {details.isApprover && ( + + )} +
+
+ ); + })} +
+
+
+ + {!!policies && ( + handlePopUpClose("requestAccess")} + /> + )} + + {!!selectedRequest && ( + { + handlePopUpClose("reviewRequest"); + setSelectedRequest(null); + }} + /> + )} + + handlePopUpClose("upgradePlan")} + /> +
+ ); +}; From 0e95c1bcee020e072f488ec4b6521f3bba68fc08 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:50:47 -0700 Subject: [PATCH 062/244] Feat: Request access --- .../components/AccessApprovalPolicyRow.tsx | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx new file mode 100644 index 000000000..8554e928e --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { useUpdateAccessApprovalPolicy } from "@app/hooks/api"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + policy: TAccessApprovalPolicy; + members?: TWorkspaceUser[]; + projectSlug: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const AccessApprovalPolicyRow = ({ + policy, + members = [], + projectSlug, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState([]); + const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); + const { permission } = useProjectPermission(); + + return ( + + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + { + if (!isOpen) { + updateAccessApprovalPolicy( + { + projectSlug, + id: policy.id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + + Select members that are allowed to approve changes + + {members?.map(({ id, user }) => { + const isChecked = selectedApprovers.includes(id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== id) : [...state, id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + {policy.approvals} + +
+ + {(isAllowed) => ( + + + + )} + + + {(isAllowed) => ( + + + + )} + +
+ + + ); +}; From ac24c0f760f847fb5ed1b35623be364da4700e1c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:50:56 -0700 Subject: [PATCH 063/244] Feat: Request access --- .../components/AccessPolicyForm.tsx | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx new file mode 100644 index 000000000..9cf756c14 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx @@ -0,0 +1,261 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + projectSlug: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z + .object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approvers: z.string().array().min(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + projectSlug, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined + }); + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + try { + await createAccessApprovalPolicy({ + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!editValues?.id) return; + try { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + + + + + Select members that are allowed to approve changes + + {members.map(({ id, user }) => { + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; From 7f1c8d9ff649cad65da22599e562d2ac705fd8a2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:51:02 -0700 Subject: [PATCH 064/244] Create index.tsx --- .../components/AccessApprovalRequest/index.tsx | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/index.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/index.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/index.tsx new file mode 100644 index 000000000..ec0a8d744 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/index.tsx @@ -0,0 +1 @@ +export { AccessApprovalRequest } from "./AccessApprovalRequest"; From 17587ff1b881a29a5860a1d71b1ae0f461f46f48 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:51:13 -0700 Subject: [PATCH 065/244] Fix: Minor fixes --- .../SecretApprovalPolicyList/SecretApprovalPolicyList.tsx | 2 -- .../components/SecretApprovalRequest/SecretApprovalRequest.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx index 345ff7d52..d3a36988c 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx @@ -46,7 +46,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { ] as const); const { permission } = useProjectPermission(); const { subscription } = useSubscription(); - const { data: members } = useGetWorkspaceUsers(workspaceId); const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({ @@ -120,7 +119,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { Secret Path Eligible Approvers Approval Required - diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 90133803b..96976d140 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -183,7 +183,7 @@ export const SecretApprovalRequest = () => {
-
+
{isRequestListEmpty && (
From ac92a916b47c6f895e0754eb74db3589d02eefae Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:51:21 -0700 Subject: [PATCH 066/244] Update SecretApprovalPage.tsx --- .../views/SecretApprovalPage/SecretApprovalPage.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index ebbfd7b5e..35ab35f94 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -7,6 +7,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList"; +import { AccessApprovalRequest } from "./components/AccessApprovalRequest"; import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; @@ -19,7 +20,8 @@ enum TabSection { export const SecretApprovalPage = () => { const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; return (
@@ -55,11 +57,15 @@ export const SecretApprovalPage = () => { - + + + + + - +
From 352d363bd4e3b159e891cbab10f97c6c2d4d90ed Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:53:02 -0700 Subject: [PATCH 067/244] Update generate-schema-types.ts --- backend/scripts/generate-schema-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 28b736152..8c913991f 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env") + path: path.join(__dirname, "../../.env.migration") }); const db = knex({ From aedc1f2441a5681e9d356e1daadb7f435f80ae68 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:13:23 -0700 Subject: [PATCH 068/244] Update SpecificPrivilegeSection.tsx --- .../SpecificPrivilegeSection.tsx | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 89058b08d..b0c312f09 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -239,21 +239,6 @@ export const SpecificPrivilegeSecretForm = ({ } }; - - const getAccessLabel = (exactTime = false) => { - if (isExpired) return "Access expired"; - if (!temporaryAccessField?.isTemporary) return "Permanent"; - - if (exactTime) - return `Until ${format( - new Date(temporaryAccessField.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}`; - return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); - }; - - }; - // This is used for requesting access additional privileges, not directly creating a privilege! const handleRequestAccess = async (data: TSecretPermissionForm) => { if (!policies) return; @@ -327,14 +312,13 @@ export const SpecificPrivilegeSecretForm = ({ if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - if (exactTime) + if (exactTime) { return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" )}`; + } return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); - }; - }; return ( From 076c70f6ff697efc4a45974570f42ae198f80a72 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:19:26 -0700 Subject: [PATCH 069/244] Removed logs --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index ffb8ac049..f29b8a52f 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -276,9 +276,6 @@ export const AccessApprovalRequest = ({ const [requestedByFilter, setRequestedByFilter] = useState(undefined); const [envFilter, setEnvFilter] = useState(undefined); - console.log("requestedByFilter", requestedByFilter); - console.log("envFilter", envFilter); - const { data: requestCount } = useGetAccessRequestsCount({ projectSlug }); From 6f4b62cfbb00243b6db811859d847b0b9e89e5d2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:20:33 -0700 Subject: [PATCH 070/244] Removed logs --- .../MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx | 1 - .../components/SecretApprovalRequestChanges.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index b0c312f09..2993704bb 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -349,7 +349,6 @@ export const SpecificPrivilegeSecretForm = ({ control={privilegeForm.control} name="secretPath" render={({ field }) => { - console.log(policies); if (policies) { return ( diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index e1437b563..80dbe9f73 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -83,7 +83,6 @@ export const SecretApprovalRequestChanges = ({ workspaceId, members = {} }: Props) => { - const { user } = useUser(); const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const { @@ -94,7 +93,6 @@ export const SecretApprovalRequestChanges = ({ id: approvalRequestId, decryptKey: decryptFileKey! }); - console.log(secretApprovalRequestDetails); const { mutateAsync: updateSecretApprovalRequestStatus, From 890c8b89bead0faaa43a5ea027dac4be6ca127e6 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:41:17 -0700 Subject: [PATCH 071/244] Removed unused parameter --- .../ee/routes/v1/access-approval-request-router.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 696c8b76f..2a6142c88 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -1,9 +1,7 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; -import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -13,16 +11,6 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv method: "POST", schema: { body: z.object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .default(`requested-privilege-${slugify(alphaNumericNanoId(12))}`) - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }), permissions: z.any().array(), isTemporary: z.boolean(), temporaryRange: z.string().optional() From e6c086ab09d93db17e34c516a5d1cc2ce1b9bdd3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:42:14 -0700 Subject: [PATCH 072/244] Fix: Don't allow users to request access to the same resource with same permissions multiple times --- .../access-approval-request-service.ts | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index d02842939..3640d801e 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -1,8 +1,10 @@ import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { ProjectMembershipRole } from "@app/db/schemas"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -64,9 +66,15 @@ export const accessApprovalRequestServiceFactory = ({ if (!project) throw new UnauthorizedError({ message: "Project not found" }); // Anyone can create an access approval request. - const p = await permissionService.getProjectPermission(actor, actorId, project.id, actorAuthMethod, actorOrgId); + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); - if (!p.membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); await projectDAL.checkProjectUpgradeStatus(project.id); @@ -81,9 +89,27 @@ export const accessApprovalRequestServiceFactory = ({ }); if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." }); + const duplicateRequest = await accessApprovalRequestDAL.findOne({ + policyId: policy.id, + requestedBy: membership.id, + permissions: JSON.stringify(permissions), + temporaryRange: temporaryRange || null, + isTemporary + }); + + if (duplicateRequest.privilegeId) { + const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); + + const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); + + if (!isExpired) { + throw new BadRequestError({ message: "You have already requested access with the same permissions" }); + } + } + const approvalRequest = await accessApprovalRequestDAL.create({ policyId: policy.id, - requestedBy: p.membership.id, + requestedBy: membership.id, temporaryRange: temporaryRange || null, permissions: JSON.stringify(permissions), isTemporary @@ -209,7 +235,7 @@ export const accessApprovalRequestServiceFactory = ({ // Permanent access const privilege = await additionalPrivilegeDAL.create({ projectMembershipId: accessApprovalRequest.requestedBy, - slug: "", + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions) }); privilegeId = privilege.id; @@ -220,7 +246,7 @@ export const accessApprovalRequestServiceFactory = ({ const privilege = await additionalPrivilegeDAL.create({ projectMembershipId: accessApprovalRequest.requestedBy, - slug: "", + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions), isTemporary: true, temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, From d371c568f1beac2e53bd8d8415bb643280e43efb Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:42:27 -0700 Subject: [PATCH 073/244] Add count --- .../src/hooks/api/accessApproval/mutation.tsx | 5 ++++- frontend/src/layouts/AppLayout/AppLayout.tsx | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 7a150092c..c81295e60 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -88,7 +88,10 @@ export const useCreateAccessRequest = () => { return data; }, onSuccess: (_, { projectSlug }) => { - queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequests(projectSlug)); + queryClient.invalidateQueries([ + accessApprovalKeys.getAccessApprovalRequests(projectSlug), + accessApprovalKeys.getAccessApprovalRequestCount(projectSlug) + ]); } }); }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index a6c25c7c2..2fcdc9339 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -5,7 +5,7 @@ /* eslint-disable no-var */ /* eslint-disable func-names */ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Image from "next/image"; @@ -64,6 +64,7 @@ import { fetchOrgUsers, useAddUserToWsNonE2EE, useCreateWorkspace, + useGetAccessRequestsCount, useGetOrgTrialUrl, useGetSecretApprovalRequestCount, useGetUserAction, @@ -124,9 +125,15 @@ export const AppLayout = ({ children }: LayoutProps) => { const { user } = useUser(); const { subscription } = useSubscription(); const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; const { data: updateClosed } = useGetUserAction("december_update_closed"); const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId }); + const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug }); + + const pendingRequestsCount = useMemo(() => { + return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0); + }, [secretApprovalReqCount, accessApprovalRequestCount]); const isAddingProjectsAllowed = subscription?.workspaceLimit ? subscription.workspacesUsed < subscription.workspaceLimit @@ -555,9 +562,12 @@ export const AppLayout = ({ children }: LayoutProps) => { icon="system-outline-189-domain-verification" > Approvals - {Boolean(secretApprovalReqCount?.open) && ( + {Boolean( + secretApprovalReqCount?.open || + accessApprovalRequestCount?.pendingCount + ) && ( - {secretApprovalReqCount?.open} + {pendingRequestsCount} )} From 073a9ee6a469dd7dc83b4ec27f1071fff3fd6d40 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:43:45 -0700 Subject: [PATCH 074/244] Update licence-fns.ts --- backend/src/ee/services/license/licence-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 492d09ec3..189a3c4e0 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: true, + secretApproval: false, secretRotation: true }); From 15c747e8e8bacce64b3f85c9b76416db5b0c5611 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:48:27 -0700 Subject: [PATCH 075/244] Fix: Request access permissions --- .../access-approval-policy-service.ts | 9 ++++++++- .../access-approval-request-service.ts | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 30f5f10fe..d46f93e1d 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -248,7 +248,14 @@ export const accessApprovalPolicyServiceFactory = ({ if (!project) throw new BadRequestError({ message: "Project not found" }); - await permissionService.getProjectPermission(actor, actorId, project.id, actorAuthMethod, actorOrgId); + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new BadRequestError({ message: "User not found in project" }); const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new BadRequestError({ message: "Environment not found" }); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 3640d801e..780e98655 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -272,14 +272,14 @@ export const accessApprovalRequestServiceFactory = ({ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new UnauthorizedError({ message: "Project not found" }); - const { permission } = await permissionService.getProjectPermission( + const { membership } = await permissionService.getProjectPermission( actor, actorId, project.id, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + if (!membership) throw new BadRequestError({ message: "User not found in project" }); const count = await accessApprovalRequestDAL.getCount({ projectId: project.id }); From b59413ded00731f2bb65c5214e1ee6ca10cb7398 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 3 Apr 2024 19:47:59 -0700 Subject: [PATCH 076/244] fix privilegeId issue --- .../access-approval-request/access-approval-request-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 780e98655..230431d18 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -97,7 +97,7 @@ export const accessApprovalRequestServiceFactory = ({ isTemporary }); - if (duplicateRequest.privilegeId) { + if (duplicateRequest?.privilegeId) { const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); From f8e0e01bb89328c4af02cc811bda414ce0489812 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:02:37 -0700 Subject: [PATCH 077/244] Fix: Access request query invalidation --- frontend/src/hooks/api/accessApproval/mutation.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index c81295e60..518729aa5 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -89,7 +89,6 @@ export const useCreateAccessRequest = () => { }, onSuccess: (_, { projectSlug }) => { queryClient.invalidateQueries([ - accessApprovalKeys.getAccessApprovalRequests(projectSlug), accessApprovalKeys.getAccessApprovalRequestCount(projectSlug) ]); } From 8c256bd9c87f2d67c4c8c1be87ccbc36586b6e70 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:02:54 -0700 Subject: [PATCH 078/244] Fix: Status filtering & query invalidation --- .../AccessApprovalRequest.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index f29b8a52f..8fe69a2d0 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -37,12 +37,14 @@ import { import { usePopUp } from "@app/hooks"; import { useGetWorkspaceUsers, useReviewAccessRequest } from "@app/hooks/api"; import { + accessApprovalKeys, useGetAccessApprovalPolicies, useGetAccessApprovalRequests, useGetAccessRequestsCount } from "@app/hooks/api/accessApproval/queries"; import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; import { ApprovalStatus, TAccessApprovalPolicy, TWorkspaceUser } from "@app/hooks/api/types"; +import { queryClient } from "@app/reactQuery"; import { SpecificPrivilegeSecretForm } from "@app/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection"; const DisplayBadge = ({ text, className }: { text: string; className?: string }) => { @@ -296,8 +298,18 @@ export const AccessApprovalRequest = ({ }); const filteredRequests = useMemo(() => { - if (statusFilter === "open") return requests?.filter((request) => !request.isApproved); - if (statusFilter === "close") return requests?.filter((request) => request.isApproved); + if (statusFilter === "open") + return requests?.filter( + (request) => + !request.isApproved && + !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) + ); + if (statusFilter === "close") + return requests?.filter( + (request) => + request.isApproved || + request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) + ); return requests; }, [requests, statusFilter, requestedByFilter, envFilter]); @@ -481,7 +493,7 @@ export const AccessApprovalRequest = ({
)} {!!filteredRequests?.length && - requests?.map((request) => { + filteredRequests?.map((request) => { const details = generateRequestDetails(request); return ( @@ -546,7 +558,16 @@ export const AccessApprovalRequest = ({ handlePopUpClose("requestAccess")} + onOpenChange={() => { + queryClient.invalidateQueries( + accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envFilter, + requestedByFilter + ) + ); + handlePopUpClose("requestAccess"); + }} /> )} From 299653528c4ddb7fe588072d7f3f5e6e0d1177db Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 3 Apr 2024 20:24:33 -0700 Subject: [PATCH 079/244] style changes --- .../SecretApprovalPage/SecretApprovalPage.tsx | 8 +- .../AccessApprovalPolicyList.tsx | 6 +- .../AccessApprovalRequest.tsx | 73 +++++++++---------- 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 35ab35f94..a12881793 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -47,11 +47,11 @@ export const SecretApprovalPage = () => {
- Secret Approvals - Secret Policies + Change Requests + Change Request Policies - Access Approvals - Access Policies + Access Requests + Access Request Policies diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx index 7c7b354a8..ba12c5189 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx @@ -83,11 +83,11 @@ export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { return (
-
+
- Access Approval Policies + Access Request Policies
- Implement policies to prevent unauthorized secret changes. + Implement secret request policies for specific secrets and environments.
diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 8fe69a2d0..eb8b886f2 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -5,7 +5,8 @@ import { faCheck, faCheckCircle, faChevronDown, - faLockOpen + faLock, + faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { formatDistance } from "date-fns"; @@ -25,6 +26,7 @@ import { EmptyState, Modal, ModalContent, + Tooltip, UpgradePlanModal } from "@app/components/v2"; import { @@ -51,7 +53,7 @@ const DisplayBadge = ({ text, className }: { text: string; className?: string }) return (
@@ -155,28 +157,28 @@ const ReviewRequestModal = ({
{request.user?.firstName} {request.user?.lastName} ({request.user?.email}) {" "} - is requesting access to the following resource + is requesting access to the following resource: -
-
+
+
Requested path:
-
+
Permissions:
-
+
Access Type: {getAccessLabel()}
@@ -188,7 +190,7 @@ const ReviewRequestModal = ({ isDisabled={!!isLoading} onClick={() => handleReview("approved")} className="mt-4" - size="xs" + size="sm" > Approve Request @@ -196,9 +198,8 @@ const ReviewRequestModal = ({ isLoading={isLoading === "rejected"} isDisabled={!!isLoading} onClick={() => handleReview("rejected")} - className="mt-4" - size="xs" - colorSchema="danger" + className="mt-4 bg-transparent border-transparent hover:bg-red/20 hover:border-red text-mineshaft-200 hover:text-mineshaft-200" + size="sm" > Reject Request @@ -237,11 +238,11 @@ const generateRequestText = (request: TAccessApprovalRequest) => { return ( Requested {isTemporary ? "temporary" : "permanent"} access to{" "} - + {request.policy.secretPath} in - + {request.environmentName} @@ -362,9 +363,9 @@ export const AccessApprovalRequest = ({ return (
-
+
- Access Approval Requests + Access Requests
Request access to secrets in sensitive environments and folders.
@@ -375,19 +376,21 @@ export const AccessApprovalRequest = ({ a={ProjectPermissionSub.SecretApproval} > {(isAllowed) => ( - + + + )}
@@ -414,7 +417,7 @@ export const AccessApprovalRequest = ({ statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" } > - + {!!requestCount && requestCount?.pendingCount} Pending
)} {!!filteredRequests?.length && - filteredRequests?.map((request) => { + requests?.map((request) => { const details = generateRequestDetails(request); return ( @@ -524,7 +527,7 @@ export const AccessApprovalRequest = ({ >
- + {generateRequestText(request)}
@@ -532,12 +535,6 @@ export const AccessApprovalRequest = ({ {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "} {membersGroupById?.[request.requestedBy]?.user?.lastName} ( {membersGroupById?.[request.requestedBy]?.user?.email}){" "} - - {details.isApprover && - !details.isReviewedByUser && - !details.isAccepted && - "- Review required"} - {details.isApprover && ( From 3a002b921aebad865c80617e0ccf88f1afd7b62c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:29:20 -0700 Subject: [PATCH 080/244] Update AccessApprovalRequest.tsx --- .../AccessApprovalRequest/AccessApprovalRequest.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index eb8b886f2..8ac0b5b78 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -167,7 +167,7 @@ const ReviewRequestModal = ({ is requesting access to the following resource: -
+
Requested path: @@ -198,7 +198,7 @@ const ReviewRequestModal = ({ isLoading={isLoading === "rejected"} isDisabled={!!isLoading} onClick={() => handleReview("rejected")} - className="mt-4 bg-transparent border-transparent hover:bg-red/20 hover:border-red text-mineshaft-200 hover:text-mineshaft-200" + className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200" size="sm" > Reject Request @@ -363,7 +363,7 @@ export const AccessApprovalRequest = ({ return (
-
+
Access Requests
@@ -376,7 +376,10 @@ export const AccessApprovalRequest = ({ a={ProjectPermissionSub.SecretApproval} > {(isAllowed) => ( - +
)} {!!filteredRequests?.length && - requests?.map((request) => { + filteredRequests?.map((request) => { const details = generateRequestDetails(request); return ( From 8c40918cef5d64aaf74b7c8760758c7d0fcfa078 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:33:58 -0700 Subject: [PATCH 081/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 8ac0b5b78..c112a5b55 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -493,7 +493,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( + {filteredRequests?.length === 0 && ( //
From 4cb51805f02fcd50b6787c255eec2b7a533b030f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:34:11 -0700 Subject: [PATCH 082/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index c112a5b55..8ac0b5b78 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -493,7 +493,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( // + {filteredRequests?.length === 0 && (
From f1428d72c2bb4eeecbac2c23b4e54b8f1009bde0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:00:14 -0700 Subject: [PATCH 083/244] Fix: Security vulnurbility making it possible to spoof env & secret path requested. --- backend/src/ee/routes/v1/access-approval-request-router.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 2a6142c88..4b173cfa7 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -16,9 +16,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv temporaryRange: z.string().optional() }), querystring: z.object({ - projectSlug: z.string().trim(), - secretPath: z.string().trim(), - envSlug: z.string().trim() + projectSlug: z.string().trim() }), response: { 200: z.object({ @@ -33,9 +31,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, permissions: req.body.permissions, - envSlug: req.query.envSlug, actorOrgId: req.permission.orgId, - secretPath: req.query.secretPath, projectSlug: req.query.projectSlug, temporaryRange: req.body.temporaryRange, isTemporary: req.body.isTemporary From fb2ab200b9650cac0a44750d7168033ca4516f17 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:00:39 -0700 Subject: [PATCH 084/244] Feat: Request access, extract permission details --- .../access-approval-request-fns.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-fns.ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts new file mode 100644 index 000000000..90b42aaf7 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts @@ -0,0 +1,53 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TVerifyPermission } from "./access-approval-request-types"; + +function filterUnique(value: string, index: number, array: string[]) { + return array.indexOf(value) === index; +} + +export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) => { + const permission = unpackRules( + permissions as PackRule<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conditions?: Record; + action: string; + subject: [string]; + }>[] + ); + + if (!permission || !permission.length) { + throw new UnauthorizedError({ message: "No permission provided" }); + } + + const requestedPermissions: string[] = []; + + for (const p of permission) { + if (p.action[0] === "read") requestedPermissions.push("Read Access"); + if (p.action[0] === "create") requestedPermissions.push("Create Access"); + if (p.action[0] === "delete") requestedPermissions.push("Delete Access"); + if (p.action[0] === "edit") requestedPermissions.push("Edit Access"); + } + + const firstPermission = permission[0]; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const permissionSecretPath = firstPermission.conditions?.secretPath?.$glob; + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment + const permissionEnv = firstPermission.conditions?.environment; + + if (!permissionEnv || typeof permissionEnv !== "string") { + throw new UnauthorizedError({ message: "Permission environment is not a string" }); + } + if (!permissionSecretPath || typeof permissionSecretPath !== "string") { + throw new UnauthorizedError({ message: "Permission path is not a string" }); + } + + return { + envSlug: permissionEnv, + secretPath: permissionSecretPath, + accessTypes: requestedPermissions.filter(filterUnique) + }; +}; From 6ae62675be59bbd60c1eb5935f487a5e1a03921d Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:01:14 -0700 Subject: [PATCH 085/244] Feat: Send emails for access requests --- .../access-approval-request-service.ts | 103 +++++++++++++----- 1 file changed, 77 insertions(+), 26 deletions(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 230431d18..0c170c40f 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -1,22 +1,25 @@ -import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { ProjectMembershipRole } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-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 { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "../project-user-additional-privilege/project-user-additional-privilege-service"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; +import { verifyRequestedPermissions } from "./access-approval-request-fns"; import { TAccessApprovalRequestReviewerDALFactory } from "./access-approval-request-reviewer-dal"; import { ApprovalStatus, @@ -30,12 +33,15 @@ type TSecretApprovalRequestServiceFactoryDep = { additionalPrivilegeService: TProjectUserAdditionalPrivilegeServiceFactory; additionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; permissionService: TPermissionServiceFactory; + accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; projectEnvDAL: Pick; projectDAL: Pick; accessApprovalRequestDAL: TAccessApprovalRequestDALFactory; accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; accessApprovalRequestReviewerDAL: TAccessApprovalRequestReviewerDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; + smtpService: TSmtpService; + userDAL: TUserDALFactory; }; export type TAccessApprovalRequestServiceFactory = ReturnType; @@ -48,20 +54,22 @@ export const accessApprovalRequestServiceFactory = ({ accessApprovalRequestReviewerDAL, projectMembershipDAL, accessApprovalPolicyDAL, - additionalPrivilegeDAL + accessApprovalPolicyApproverDAL, + additionalPrivilegeDAL, + smtpService, + userDAL }: TSecretApprovalRequestServiceFactoryDep) => { const createAccessApprovalRequest = async ({ isTemporary, temporaryRange, actorId, - envSlug, - permissions, - secretPath, + permissions: requestedPermissions, actor, actorOrgId, actorAuthMethod, projectSlug }: TCreateAccessApprovalRequestDTO) => { + const cfg = getConfig(); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new UnauthorizedError({ message: "Project not found" }); @@ -73,15 +81,17 @@ export const accessApprovalRequestServiceFactory = ({ actorAuthMethod, actorOrgId ); - if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + const requestedByUser = await userDAL.findUserByProjectMembershipId(membership.id); + if (!requestedByUser) throw new UnauthorizedError({ message: "User not found" }); + await projectDAL.checkProjectUpgradeStatus(project.id); + const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ permissions: requestedPermissions }); const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new UnauthorizedError({ message: "Environment not found" }); - if (!secretPath) throw new UnauthorizedError({ message: "Secret path is required" }); const policy = await accessApprovalPolicyDAL.findOne({ envId: environment.id, @@ -89,33 +99,75 @@ export const accessApprovalRequestServiceFactory = ({ }); if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." }); + const approvers = await accessApprovalPolicyApproverDAL.find({ + policyId: policy.id + }); + + const approverUsers = await userDAL.findUsersByProjectMembershipIds( + approvers.map((approver) => approver.approverId) + ); + + if (approverUsers.length !== approvers.length) { + throw new BadRequestError({ message: "Some approvers were not found" }); + } + const duplicateRequest = await accessApprovalRequestDAL.findOne({ policyId: policy.id, requestedBy: membership.id, - permissions: JSON.stringify(permissions), - temporaryRange: temporaryRange || null, + permissions: JSON.stringify(requestedPermissions), isTemporary }); - if (duplicateRequest?.privilegeId) { - const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); + if (duplicateRequest) { + if (duplicateRequest.privilegeId) { + const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); - const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); + const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); - if (!isExpired) { - throw new BadRequestError({ message: "You have already requested access with the same permissions" }); + if (!isExpired || !privilege.isTemporary) { + throw new BadRequestError({ message: "You already have an active privilege with the same criteria" }); + } + } else { + throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); } } - const approvalRequest = await accessApprovalRequestDAL.create({ - policyId: policy.id, - requestedBy: membership.id, - temporaryRange: temporaryRange || null, - permissions: JSON.stringify(permissions), - isTemporary + const approval = await accessApprovalRequestDAL.transaction(async (tx) => { + const approvalRequest = await accessApprovalRequestDAL.create( + { + policyId: policy.id, + requestedBy: membership.id, + temporaryRange: temporaryRange || null, + permissions: JSON.stringify(requestedPermissions), + isTemporary + }, + tx + ); + + await smtpService.sendMail({ + recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), + subjectLine: "Access Approval Request", + + substitutions: { + projectName: project.name, + requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`, + requesterEmail: requestedByUser.email, + isTemporary, + ...(isTemporary && { + expiresIn: ms(ms(temporaryRange || ""), { long: true }) + }), + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval` + }, + template: SmtpTemplates.AccessApprovalRequest + }); + + return approvalRequest; }); - return { request: approvalRequest }; + return { request: approval }; }; const listApprovalRequests = async ({ @@ -130,15 +182,14 @@ export const accessApprovalRequestServiceFactory = ({ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new UnauthorizedError({ message: "Project not found" }); - const { permission } = await permissionService.getProjectPermission( + const { membership } = await permissionService.getProjectPermission( actor, actorId, project.id, actorAuthMethod, actorOrgId ); - - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); @@ -181,7 +232,7 @@ export const accessApprovalRequestServiceFactory = ({ accessApprovalRequest.requestedBy !== membership.id && // The request wasn't made by the current user !policy.approvers.find((approverId) => approverId === membership.id) // The request isn't performed by an assigned approver ) { - throw new UnauthorizedError({ message: "No access" }); + throw new UnauthorizedError({ message: "You are not authorized to approve this request" }); } const reviewerProjectMembership = await projectMembershipDAL.findById(membership.id); From c7b60bcf0ea94306b531d273b264bf76dc3b64e4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:01:22 -0700 Subject: [PATCH 086/244] Update access-approval-request-types.ts --- .../access-approval-request-types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 6bba0c14d..e11ca58d5 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -6,6 +6,10 @@ export enum ApprovalStatus { REJECTED = "rejected" } +export type TVerifyPermission = { + permissions: unknown; +}; + export type TGetAccessRequestCountDTO = { projectSlug: string; } & Omit; @@ -17,8 +21,6 @@ export type TReviewAccessRequestDTO = { export type TCreateAccessApprovalRequestDTO = { projectSlug: string; - secretPath: string; - envSlug: string; permissions: unknown; isTemporary: boolean; temporaryRange?: string; From 608c7a4dee51f1ad6b1aeb41d49783a01650e368 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:01:32 -0700 Subject: [PATCH 087/244] Update index.ts --- backend/src/server/routes/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 905d4028c..63c5609be 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -628,7 +628,10 @@ export const registerRoutes = async ( additionalPrivilegeService: projectUserAdditionalPrivilegeService, accessApprovalPolicyDAL, accessApprovalRequestDAL, - projectEnvDAL + projectEnvDAL, + userDAL, + smtpService, + accessApprovalPolicyApproverDAL }); const secretRotationQueue = secretRotationQueueFactory({ From 8e1d19c0412bade9e4847feb90c4680d1c37fb42 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:02:24 -0700 Subject: [PATCH 088/244] Feat: access request emails --- .../accessApprovalRequest.handlebars | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 backend/src/services/smtp/templates/accessApprovalRequest.handlebars diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars new file mode 100644 index 000000000..82c66ce5f --- /dev/null +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -0,0 +1,50 @@ + + + + + + Access Approval Request + + + +

Infisical

+

New access approval request pending your review

+

You have a new access approval request pending review in project "{{projectName}}".

+ +

+ {{requesterFullName}} + ({{requesterEmail}}) has requested + {{#if isTemporary}} + temporary + {{else}} + permanent + {{/if}} + access to + {{secretPath}} + in the + {{environment}} + environment. + + {{#if isTemporary}} +
+ This access will expire + {{expiresIn}} + after it has been approved. + {{/if}} +

+

+ The following permissions are requested: +

    + {{#each permissions}} +
  • {{this}}
  • + {{/each}} +
+

+ +

+ View the request and approve or deny it + here. +

+ + + \ No newline at end of file From c5d11eee7f47b4018e936535a34f59dfa1164e75 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:02:39 -0700 Subject: [PATCH 089/244] Feat: Find users by project membership ID's --- backend/src/services/user/user-dal.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 530ca3ad1..f2da0df0e 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -74,6 +74,17 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findUsersByProjectMembershipIds = async (projectMembershipIds: string[]) => { + try { + return await db(TableName.ProjectMembership) + .whereIn(`${TableName.ProjectMembership}.id`, projectMembershipIds) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .select("*"); + } catch (error) { + throw new DatabaseError({ error, name: "Find users by project membership ids" }); + } + }; + const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => { try { const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*"); @@ -140,6 +151,7 @@ export const userDALFactory = (db: TDbClient) => { findUserEncKeyByUserId, updateUserEncryptionByUserId, findUserByProjectMembershipId, + findUsersByProjectMembershipIds, upsertUserEncryptionKey, createUserEncryption, findOneUserAction, From cb8763bc9c97eeda013a89602860f590e8ee0340 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:02:47 -0700 Subject: [PATCH 090/244] Update smtp-service.ts --- backend/src/services/smtp/smtp-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 0b43ffb90..81680537d 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -21,6 +21,7 @@ export enum SmtpTemplates { EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", + AccessApprovalRequest = "accessApprovalRequest.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", From 54313f9c08fab5e83f72bf03aeda7cbc8a60c5da Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:02:55 -0700 Subject: [PATCH 091/244] Renaming --- frontend/src/hooks/api/accessApproval/mutation.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 518729aa5..1bf3841ff 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -69,18 +69,16 @@ export const useDeleteAccessApprovalPolicy = () => { export const useCreateAccessRequest = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateAccessRequestDTO>({ - mutationFn: async ({ envSlug, projectSlug, secretPath, ...privilege }) => { + mutationFn: async ({ projectSlug, ...request }) => { const { data } = await apiRequest.post( "/api/v1/access-approval-requests", { - ...privilege, - permissions: privilege.permissions ? packRules(privilege.permissions) : undefined + ...request, + permissions: request.permissions ? packRules(request.permissions) : undefined }, { params: { - envSlug, - projectSlug, - secretPath + projectSlug } } ); @@ -88,9 +86,7 @@ export const useCreateAccessRequest = () => { return data; }, onSuccess: (_, { projectSlug }) => { - queryClient.invalidateQueries([ - accessApprovalKeys.getAccessApprovalRequestCount(projectSlug) - ]); + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug)); } }); }; From 478520f090f94930bf70aecf0422c2269a89d1d8 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:10 -0700 Subject: [PATCH 092/244] Remove unnessecary types and projectMembershipid --- frontend/src/hooks/api/accessApproval/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 1ffd0bf2e..2176b8bc1 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -92,10 +92,8 @@ export type TProjectUserPrivilege = { ); export type TCreateAccessRequestDTO = { - envSlug: string; projectSlug: string; - secretPath: string; -} & Omit; +} & Omit; export type TGetAccessApprovalRequestsDTO = { projectSlug: string; From 2369ff6813a29a2e214ab59598f397ae49b40b56 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:19 -0700 Subject: [PATCH 093/244] Removed unnessecary types --- .../MemberRoleForm/SpecificPrivilegeSection.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 2993704bb..88dba72a1 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -84,7 +84,6 @@ export const SpecificPrivilegeSecretForm = ({ onClose?: () => void; }) => { const { currentWorkspace } = useWorkspace(); - const { membership: projectMembership } = useProjectPermission(); const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ "deletePrivilege", @@ -271,17 +270,10 @@ export const SpecificPrivilegeSecretForm = ({ conditions.secretPath = { $glob: data.secretPath }; } await requestAccess.mutateAsync({ - ...data, ...(data.temporaryAccess.isTemporary && { - temporaryAccessStartTime: data.temporaryAccess.temporaryAccessStartTime, - temporaryAccessEndTime: data.temporaryAccess.temporaryAccessEndTime, - temporaryRange: data.temporaryAccess.temporaryRange, - temporaryMode: "relative" + temporaryRange: data.temporaryAccess.temporaryRange }), - envSlug: data.environmentSlug, - secretPath: data.secretPath, projectSlug: currentWorkspace.slug, - projectMembershipId: projectMembership.id, isTemporary: data.temporaryAccess.isTemporary, permissions: actions .filter(({ allowed }) => allowed) @@ -312,7 +304,7 @@ export const SpecificPrivilegeSecretForm = ({ if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - if (exactTime) { + if (exactTime && !policies) { return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" From 0990ce1f9257ca8892118b79e78997b679db0980 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:29 -0700 Subject: [PATCH 094/244] Capitalization --- .../AccessApprovalPolicyList/components/AccessPolicyForm.tsx | 2 +- .../AccessApprovalRequest/components/AccessPolicyForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index 6b53e2f1e..dd51ea53d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -135,7 +135,7 @@ export const AccessPolicyForm = ({ return ( - +
- + Date: Thu, 4 Apr 2024 01:03:44 -0700 Subject: [PATCH 095/244] Style: Fix styling --- .../AccessApprovalRequest.tsx | 133 +++++++++--------- 1 file changed, 70 insertions(+), 63 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 8ac0b5b78..b072513dc 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -15,7 +15,6 @@ import ms from "ms"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DropdownMenu, @@ -31,7 +30,6 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, - ProjectPermissionSub, useProjectPermission, useSubscription, useWorkspace @@ -53,7 +51,7 @@ const DisplayBadge = ({ text, className }: { text: string; className?: string }) return (
@@ -232,20 +230,29 @@ const SelectAccessModal = ({ ); }; -const generateRequestText = (request: TAccessApprovalRequest) => { +const generateRequestText = (request: TAccessApprovalRequest, membershipId: string) => { const { isTemporary } = request; return ( - - Requested {isTemporary ? "temporary" : "permanent"} access to{" "} - - {request.policy.secretPath} - - in - - {request.environmentName} - - +
+
+ Requested {isTemporary ? "temporary" : "permanent"} access to{" "} + + {request.policy.secretPath} + + in + + {request.environmentName} + +
+
+ {request.requestedBy === membershipId && ( + + + + )} +
+
); }; @@ -265,7 +272,7 @@ export const AccessApprovalRequest = ({ "reviewRequest", "upgradePlan" ] as const); - const { permission, membership } = useProjectPermission(); + const { membership } = useProjectPermission(); const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); @@ -284,12 +291,7 @@ export const AccessApprovalRequest = ({ }); const { data: policies, isLoading: policiesLoading } = useGetAccessApprovalPolicies({ - projectSlug, - options: { - enabled: - permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && - !!projectSlug - } + projectSlug }); const { data: requests } = useGetAccessApprovalRequests({ @@ -371,31 +373,24 @@ export const AccessApprovalRequest = ({
- - {(isAllowed) => ( - - - - )} - + +
@@ -506,10 +501,11 @@ export const AccessApprovalRequest = ({
{ + if (!details.isApprover) return; if (details.isReviewedByUser || details.isRejectedByAnyone) return; setSelectedRequest({ @@ -519,6 +515,8 @@ export const AccessApprovalRequest = ({ handlePopUpOpen("reviewRequest"); }} onKeyDown={(evt) => { + if (!details.isApprover) return; + if (details.isReviewedByUser || details.isRejectedByAnyone) return; if (evt.key === "Enter") { setSelectedRequest({ ...request, @@ -528,24 +526,33 @@ export const AccessApprovalRequest = ({ } }} > -
-
- - {generateRequestText(request)} +
+
+
+ + {generateRequestText(request, membership.id)} +
+
+
+ {membersGroupById?.[request.requestedBy]?.user && ( + <> + Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} + ago by {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "} + {membersGroupById?.[request.requestedBy]?.user?.lastName} ( + {membersGroupById?.[request.requestedBy]?.user?.email}){" "} + + )} +
+
+ {details.isApprover && ( + + )} +
+
- - Requested {formatDistance(new Date(request.createdAt), new Date())} ago by{" "} - {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "} - {membersGroupById?.[request.requestedBy]?.user?.lastName} ( - {membersGroupById?.[request.requestedBy]?.user?.email}){" "} - - - {details.isApprover && ( - - )}
); From da5278f6bf1f77b4233d8a8e5f959c0cf0204aad Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:58 -0700 Subject: [PATCH 096/244] Fix: Rename change -> secret --- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 4 ++-- .../components/SecretApprovalRequestAction.tsx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index a12881793..18ce5f391 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -47,8 +47,8 @@ export const SecretApprovalPage = () => {
- Change Requests - Change Request Policies + Secret Requests + Secret Policies Access Requests Access Request Policies diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index 51f26a549..c3d804779 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -37,7 +37,6 @@ export const SecretApprovalRequestAction = ({ workspaceId, canApprove }: Props) => { - const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } = usePerformSecretApprovalRequestMerge(); @@ -136,7 +135,7 @@ export const SecretApprovalRequestAction = ({
- Change request merged + Secret approval merged Merged by {statusChangeByEmail} @@ -150,7 +149,7 @@ export const SecretApprovalRequestAction = ({
- Change request has been closed + Secret approval has been closed Closed by {statusChangeByEmail} From 32a110e0ca8487946ee240a24461014778ae868d Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 12:44:49 -0700 Subject: [PATCH 097/244] Fix: Multiple approvers acceptance bug --- .../access-approval-request-service.ts | 40 +++++++++++-------- .../AccessApprovalRequest.tsx | 32 ++++++++++----- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 0c170c40f..b7b61fd8e 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -274,8 +274,8 @@ export const accessApprovalRequestServiceFactory = ({ const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED); - // If all approvers have approved the request, update the privilege to approved - if (approvedReviews.length === policy.approvers.length) { + // approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved. + if (approvedReviews.length === policy.approvals) { if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { throw new BadRequestError({ message: "Temporary range is required for temporary access" }); } @@ -284,27 +284,33 @@ export const accessApprovalRequestServiceFactory = ({ if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { // Permanent access - const privilege = await additionalPrivilegeDAL.create({ - projectMembershipId: accessApprovalRequest.requestedBy, - slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, - permissions: JSON.stringify(accessApprovalRequest.permissions) - }); + const privilege = await additionalPrivilegeDAL.create( + { + projectMembershipId: accessApprovalRequest.requestedBy, + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + permissions: JSON.stringify(accessApprovalRequest.permissions) + }, + tx + ); privilegeId = privilege.id; } else { // Temporary access const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); const startTime = new Date(); - const privilege = await additionalPrivilegeDAL.create({ - projectMembershipId: accessApprovalRequest.requestedBy, - slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, - permissions: JSON.stringify(accessApprovalRequest.permissions), - isTemporary: true, - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, - temporaryRange: accessApprovalRequest.temporaryRange!, - temporaryAccessStartTime: startTime, - temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) - }); + const privilege = await additionalPrivilegeDAL.create( + { + projectMembershipId: accessApprovalRequest.requestedBy, + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + permissions: JSON.stringify(accessApprovalRequest.permissions), + isTemporary: true, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: accessApprovalRequest.temporaryRange!, + temporaryAccessStartTime: startTime, + temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) + }, + tx + ); privilegeId = privilege.id; } diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index b072513dc..2421a629d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -342,12 +342,14 @@ export const AccessApprovalRequest = ({ displayData = { label: "Access Granted", colorClass: "bg-green/20 text-green" }; else if (isRejectedByAnyone) displayData = { label: "Rejected", colorClass: "bg-red/20 text-red" }; - else if (userReviewStatus === ApprovalStatus.APPROVED) + else if (userReviewStatus === ApprovalStatus.APPROVED) { displayData = { - label: `Pending ${request.policy.approvals - request.reviewers.length} reviews`, + label: `Pending ${request.policy.approvals - request.reviewers.length} review${ + request.policy.approvals - request.reviewers.length > 1 ? "s" : "" + }`, colorClass: "bg-yellow/20 text-yellow" }; - else if (!isReviewedByUser) + } else if (!isReviewedByUser) displayData = { label: "Review Required", colorClass: "bg-yellow/20 text-yellow" @@ -499,14 +501,21 @@ export const AccessApprovalRequest = ({ return (
{ - if (!details.isApprover) return; - if (details.isReviewedByUser || details.isRejectedByAnyone) return; + if ( + !details.isApprover || + details.isReviewedByUser || + details.isRejectedByAnyone || + details.isAccepted + ) + return; setSelectedRequest({ ...request, @@ -515,8 +524,13 @@ export const AccessApprovalRequest = ({ handlePopUpOpen("reviewRequest"); }} onKeyDown={(evt) => { - if (!details.isApprover) return; - if (details.isReviewedByUser || details.isRejectedByAnyone) return; + if ( + !details.isApprover || + details.isAccepted || + details.isReviewedByUser || + details.isRejectedByAnyone + ) + return; if (evt.key === "Enter") { setSelectedRequest({ ...request, @@ -543,7 +557,7 @@ export const AccessApprovalRequest = ({ )}
-
+
{details.isApprover && ( Date: Fri, 29 Mar 2024 16:24:15 +0100 Subject: [PATCH 098/244] Draft --- backend/src/ee/services/license/licence-fns.ts | 2 +- .../src/components/permissions/PermissionDeniedBanner.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..492d09ec3 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true }); diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index b3c7a4f53..f9707d634 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,6 +3,8 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { Button } from "../v2"; + type Props = { containerClassName?: string; className?: string; @@ -32,6 +34,9 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children )}
+
); From 4326ce970a2093e9e28fc2791048ff3a974e0dd3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:49:22 -0700 Subject: [PATCH 099/244] Feat: Request access --- backend/scripts/generate-schema-types.ts | 2 +- .../db/migrations/20240330075122_access-approval-policy.ts | 1 + .../access-approval-policy/access-approval-policy-service.ts | 4 ---- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 4 ++++ .../AccessApprovalPolicyList/AccessApprovalPolicyList.tsx | 2 +- .../AccessApprovalPolicyList/components/AccessPolicyForm.tsx | 3 --- 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991f..28b736152 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env.migration") + path: path.join(__dirname, "../../.env") }); const db = knex({ diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 8203fb333..20a50c37f 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -34,6 +34,7 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); } diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index d46f93e1d..f08b94122 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -155,7 +155,6 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { @@ -179,7 +178,6 @@ export const accessApprovalPolicyServiceFactory = ({ ); await verifyApprovers({ - projectId: accessApprovalPolicy.projectId, orgId: actorOrgId, envSlug: accessApprovalPolicy.environment.slug, secretPath: doc.secretPath!, @@ -189,8 +187,6 @@ export const accessApprovalPolicyServiceFactory = ({ }); if (secretApprovers.length !== approvers.length) - throw new BadRequestError({ message: "Approver not found in project" }); - if (doc.approvals > secretApprovers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); await accessApprovalPolicyApproverDAL.insertMany( diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 18ce5f391..98ed330f5 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -67,6 +67,10 @@ export const SecretApprovalPage = () => { + + + +
); diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx index ba12c5189..3e8fdb3b9 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx @@ -83,7 +83,7 @@ export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { return (
-
+
Access Request Policies
diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index dd51ea53d..081556041 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -177,7 +177,6 @@ export const AccessPolicyForm = ({ /> ( @@ -187,8 +186,6 @@ export const AccessPolicyForm = ({ /> ( Date: Wed, 3 Apr 2024 16:35:51 -0700 Subject: [PATCH 100/244] Feat: Request Access (migrations) --- .../src/db/migrations/20240330075122_access-approval-policy.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 20a50c37f..8203fb333 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -34,7 +34,6 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); } From 6d9de752d713673ab77605c1b575d66eec01dca1 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:37:49 -0700 Subject: [PATCH 101/244] Feat: Request access (new routes) --- backend/src/ee/routes/v1/access-approval-request-router.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 4b173cfa7..104f32665 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -2,10 +2,6 @@ import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; -import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; - -export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "POST", From 5e1484bd05c86f6010da496e21112f72e4505395 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:39:15 -0700 Subject: [PATCH 102/244] Fix: Validate approvers access --- .../access-approval-policy/access-approval-policy-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index f08b94122..cca29e431 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -155,6 +155,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, actorOrgId ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { From e48377dea9e2f116574f527942269dc40d54a492 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:43:48 -0700 Subject: [PATCH 103/244] Fix: Remove redundant code --- .../src/components/permissions/PermissionDeniedBanner.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index f9707d634..b3c7a4f53 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,8 +3,6 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button } from "../v2"; - type Props = { containerClassName?: string; className?: string; @@ -34,9 +32,6 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children )}
-
); From 926d324ae33d8f9ea6aab493ab4902d42e2bdd5d Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:49:00 -0700 Subject: [PATCH 104/244] Fix: Added support for request access --- .../SpecificPrivilegeSection.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 88dba72a1..4a7e45662 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -311,6 +311,91 @@ export const SpecificPrivilegeSecretForm = ({ )}`; } return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + }; + + // This is used for requesting access additional privileges, not directly creating a privilege! + const handleRequestAccess = async (data: TSecretPermissionForm) => { + if (!policies) return; + if (!currentWorkspace) { + createNotification({ + type: "error", + text: "No workspace found.", + title: "Error" + }); + return; + } + + if (!data.secretPath) { + createNotification({ + type: "error", + text: "Please select a secret path", + title: "Error" + }); + return; + } + + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await requestAccess.mutateAsync({ + ...data, + ...(data.temporaryAccess.isTemporary && { + temporaryAccessStartTime: data.temporaryAccess.temporaryAccessStartTime, + temporaryAccessEndTime: data.temporaryAccess.temporaryAccessEndTime, + temporaryRange: data.temporaryAccess.temporaryRange, + temporaryMode: "relative" + }), + envSlug: data.environmentSlug, + secretPath: data.secretPath, + projectSlug: currentWorkspace.slug, + projectMembershipId: projectMembership.id, + isTemporary: data.temporaryAccess.isTemporary, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }); + + createNotification({ + type: "success", + text: "Successfully requested access" + }); + privilegeForm.reset(); + if (onClose) onClose(); + }; + + const handleSubmit = async (data: TSecretPermissionForm) => { + if (privilege) { + handleUpdatePrivilege(data); + } else { + handleRequestAccess(data); + } + }; + + const getAccessLabel = (exactTime = false) => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + + if (exactTime) + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + }; return ( From 70c06c91c8d489003c428cbf4609b57f13e99d26 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:51:21 -0700 Subject: [PATCH 105/244] Update SecretApprovalPage.tsx --- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 98ed330f5..5bd6ecacf 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -69,7 +69,7 @@ export const SecretApprovalPage = () => { - +
From 5a04371fb0e0f13c6324d9e4fb66b84a2f499aeb Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:53:02 -0700 Subject: [PATCH 106/244] Update generate-schema-types.ts --- backend/scripts/generate-schema-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 28b736152..8c913991f 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env") + path: path.join(__dirname, "../../.env.migration") }); const db = knex({ From 0aa77f90c80df578abb41d2bae50fc24de062681 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:13:23 -0700 Subject: [PATCH 107/244] Update SpecificPrivilegeSection.tsx --- .../MemberRoleForm/SpecificPrivilegeSection.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 4a7e45662..b47768d1f 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -388,14 +388,13 @@ export const SpecificPrivilegeSecretForm = ({ if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - if (exactTime) + if (exactTime) { return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" )}`; + } return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); - }; - }; return ( From a687b1d0db278c7346bbcaf7c0c35916bf700f2a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:43:45 -0700 Subject: [PATCH 108/244] Update licence-fns.ts --- backend/src/ee/services/license/licence-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 492d09ec3..189a3c4e0 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: true, + secretApproval: false, secretRotation: true }); From bb9503471f72ab4cb8bee57157e83ea5664670dd Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 3 Apr 2024 20:24:33 -0700 Subject: [PATCH 109/244] style changes --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 2421a629d..963dce37c 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -496,7 +496,7 @@ export const AccessApprovalRequest = ({
)} {!!filteredRequests?.length && - filteredRequests?.map((request) => { + requests?.map((request) => { const details = generateRequestDetails(request); return ( From 22470376d901d778ef8190dfa3c2b25fd82d0e8f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:29:20 -0700 Subject: [PATCH 110/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 963dce37c..2421a629d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -496,7 +496,7 @@ export const AccessApprovalRequest = ({
)} {!!filteredRequests?.length && - requests?.map((request) => { + filteredRequests?.map((request) => { const details = generateRequestDetails(request); return ( From bc810ea567d6cf3c0d9b4d0abfbb3027bb6c37d1 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:33:58 -0700 Subject: [PATCH 111/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 2421a629d..960bdc8b4 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -490,7 +490,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( + {filteredRequests?.length === 0 && ( //
From 8e82bfae868b27b38b5b0b88c2475df3aa9a09fc Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:34:11 -0700 Subject: [PATCH 112/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 960bdc8b4..2421a629d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -490,7 +490,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( // + {filteredRequests?.length === 0 && (
From dab69dcb517f294aa3040560e72c2d89f63e97f7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:19 -0700 Subject: [PATCH 113/244] Removed unnessecary types --- .../MemberRoleForm/SpecificPrivilegeSection.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index b47768d1f..4dc7437c4 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -349,15 +349,9 @@ export const SpecificPrivilegeSecretForm = ({ await requestAccess.mutateAsync({ ...data, ...(data.temporaryAccess.isTemporary && { - temporaryAccessStartTime: data.temporaryAccess.temporaryAccessStartTime, - temporaryAccessEndTime: data.temporaryAccess.temporaryAccessEndTime, - temporaryRange: data.temporaryAccess.temporaryRange, - temporaryMode: "relative" + temporaryRange: data.temporaryAccess.temporaryRange }), - envSlug: data.environmentSlug, - secretPath: data.secretPath, projectSlug: currentWorkspace.slug, - projectMembershipId: projectMembership.id, isTemporary: data.temporaryAccess.isTemporary, permissions: actions .filter(({ allowed }) => allowed) @@ -388,7 +382,7 @@ export const SpecificPrivilegeSecretForm = ({ if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - if (exactTime) { + if (exactTime && !policies) { return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" From c874c943c19c21c9a6f605088281de4e2e733bf0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 20:14:02 -0700 Subject: [PATCH 114/244] Fix: Rebase errors --- .../v1/access-approval-request-router.ts | 4 + .../access-approval-policy-service.ts | 1 + .../SpecificPrivilegeSection.tsx | 77 ------------------- .../components/AccessPolicyForm.tsx | 2 + 4 files changed, 7 insertions(+), 77 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 104f32665..4b173cfa7 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "POST", diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index cca29e431..c384dd71a 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -179,6 +179,7 @@ export const accessApprovalPolicyServiceFactory = ({ ); await verifyApprovers({ + projectId: accessApprovalPolicy.projectId, orgId: actorOrgId, envSlug: accessApprovalPolicy.environment.slug, secretPath: doc.secretPath!, diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 4dc7437c4..2f59e553e 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -238,83 +238,6 @@ export const SpecificPrivilegeSecretForm = ({ } }; - // This is used for requesting access additional privileges, not directly creating a privilege! - const handleRequestAccess = async (data: TSecretPermissionForm) => { - if (!policies) return; - if (!currentWorkspace) { - createNotification({ - type: "error", - text: "No workspace found.", - title: "Error" - }); - return; - } - - if (!data.secretPath) { - createNotification({ - type: "error", - text: "Please select a secret path", - title: "Error" - }); - return; - } - - const actions = [ - { action: ProjectPermissionActions.Read, allowed: data.read }, - { action: ProjectPermissionActions.Create, allowed: data.create }, - { action: ProjectPermissionActions.Delete, allowed: data.delete }, - { action: ProjectPermissionActions.Edit, allowed: data.edit } - ]; - const conditions: Record = { environment: data.environmentSlug }; - if (data.secretPath) { - conditions.secretPath = { $glob: data.secretPath }; - } - await requestAccess.mutateAsync({ - ...(data.temporaryAccess.isTemporary && { - temporaryRange: data.temporaryAccess.temporaryRange - }), - projectSlug: currentWorkspace.slug, - isTemporary: data.temporaryAccess.isTemporary, - permissions: actions - .filter(({ allowed }) => allowed) - .map(({ action }) => ({ - action, - subject: [ProjectPermissionSub.Secrets], - conditions - })) - }); - - createNotification({ - type: "success", - text: "Successfully requested access" - }); - privilegeForm.reset(); - if (onClose) onClose(); - }; - - const handleSubmit = async (data: TSecretPermissionForm) => { - if (privilege) { - handleUpdatePrivilege(data); - } else { - handleRequestAccess(data); - } - }; - - const getAccessLabel = (exactTime = false) => { - if (isExpired) return "Access expired"; - if (!temporaryAccessField?.isTemporary) return "Permanent"; - - if (exactTime && !policies) { - return `Until ${format( - new Date(temporaryAccessField.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}`; - } - return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); - }; - - }; - // This is used for requesting access additional privileges, not directly creating a privilege! const handleRequestAccess = async (data: TSecretPermissionForm) => { if (!policies) return; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index 081556041..a07acb584 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -186,6 +186,8 @@ export const AccessPolicyForm = ({ /> ( Date: Thu, 4 Apr 2024 21:00:02 -0700 Subject: [PATCH 115/244] Update SecretApprovalPage.tsx --- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 5bd6ecacf..d66a6c8cc 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -63,11 +63,6 @@ export const SecretApprovalPage = () => { - - - - - From 01e7ed23bae8935537d09e27ded9280be53ffa4f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 21:12:05 -0700 Subject: [PATCH 116/244] Fixed bugs --- ....ts => 20240330075120_org-memberships-unique-constraint.ts} | 0 .../AccessApprovalPolicyList/components/AccessPolicyForm.tsx | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) rename backend/src/db/migrations/{20240405000045_org-memberships-unique-constraint.ts => 20240330075120_org-memberships-unique-constraint.ts} (100%) diff --git a/backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts b/backend/src/db/migrations/20240330075120_org-memberships-unique-constraint.ts similarity index 100% rename from backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts rename to backend/src/db/migrations/20240330075120_org-memberships-unique-constraint.ts diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index a07acb584..dd51ea53d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -177,6 +177,7 @@ export const AccessPolicyForm = ({ /> ( @@ -186,8 +187,8 @@ export const AccessPolicyForm = ({ /> ( Date: Thu, 4 Apr 2024 22:14:46 -0700 Subject: [PATCH 117/244] Migration improvements --- .../20240330075122_access-approval-policy.ts | 17 ++++++++++++----- .../20240401173320_access_approval_requests.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 8203fb333..79af3aa6e 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -14,8 +14,8 @@ export async function up(knex: Knex): Promise { t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); t.timestamps(true, true, true); }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); } - await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover))) { await knex.schema.createTable(TableName.AccessApprovalPolicyApprover, (t) => { @@ -26,14 +26,21 @@ export async function up(knex: Knex): Promise { t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); t.timestamps(true, true, true); }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); } - - await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); } export async function down(knex: Knex): Promise { + const approverTableExists = await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover); + const policyTableExists = await knex.schema.hasTable(TableName.AccessApprovalPolicy); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); + + if (approverTableExists) { + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); + } + if (policyTableExists) { + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); + } } diff --git a/backend/src/db/migrations/20240401173320_access_approval_requests.ts b/backend/src/db/migrations/20240401173320_access_approval_requests.ts index 901be9a78..c03287b57 100644 --- a/backend/src/db/migrations/20240401173320_access_approval_requests.ts +++ b/backend/src/db/migrations/20240401173320_access_approval_requests.ts @@ -43,9 +43,16 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { + const reviewerTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer); + const requestTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequest); + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer); await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); + if (reviewerTableExists) { + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); + } + if (requestTableExists) { + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); + } } From 1785548a40049e84bb94e4ea396cdd889d46750e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:55:54 -0700 Subject: [PATCH 118/244] Fix: Sort by createdAt --- .../access-approval-request/access-approval-request-dal.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 78fef7c8a..c3f4c72a6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -81,7 +81,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { .as("privilegeTemporaryAccessEndTime"), db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegePermissions") - ); + ) + .orderBy(`${TableName.AccessApprovalRequest}.createdAt`, "desc"); const formattedDocs = sqlNestRelationships({ data: docs, From c7d2dfd351abccb027a2131bc447b360b7fbccaf Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:57:14 -0700 Subject: [PATCH 119/244] Fix: Requesting approvals on previously rejected resources --- .../access-approval-request-service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index b7b61fd8e..d9762d879 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -128,7 +128,15 @@ export const accessApprovalRequestServiceFactory = ({ throw new BadRequestError({ message: "You already have an active privilege with the same criteria" }); } } else { - throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); + const reviewers = await accessApprovalRequestReviewerDAL.find({ + requestId: duplicateRequest.id + }); + + const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED); + + if (!isRejected) { + throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); + } } } From 44370d49e320284f6210956fc7945c34abba44a6 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:57:36 -0700 Subject: [PATCH 120/244] Fix: Add tooltip for clarity and fix wording --- .../SpecificPrivilegeSection.tsx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 2f59e553e..795ccbb24 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -344,20 +344,27 @@ export const SpecificPrivilegeSecretForm = ({ render={({ field }) => { if (policies) { return ( - - - + +
+ + + +
+
); } return ( @@ -515,8 +522,9 @@ export const SpecificPrivilegeSecretForm = ({ ); }} > - {temporaryAccessField.isTemporary ? "Restart" : "Grant"} + {temporaryAccessField.isTemporary && !policies ? "Restart" : "Grant"} + {temporaryAccessField.isTemporary && ( )}
From c911a7cd8115c820e21831f0ff08a3416f2e24df Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:57:59 -0700 Subject: [PATCH 121/244] Fix: Don't display requested by when user has no access to read workspace members --- .../AccessApprovalRequest.tsx | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 2421a629d..b6d69d0c9 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -30,6 +30,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionSub, useProjectPermission, useSubscription, useWorkspace @@ -272,7 +273,7 @@ export const AccessApprovalRequest = ({ "reviewRequest", "upgradePlan" ] as const); - const { membership } = useProjectPermission(); + const { membership, permission } = useProjectPermission(); const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); @@ -460,33 +461,37 @@ export const AccessApprovalRequest = ({ ))} - - - - - - Select an author - {members?.map(({ user, id }) => ( - - setRequestedByFilter((state) => (state === id ? undefined : id)) + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user, id }) => ( + + setRequestedByFilter((state) => (state === id ? undefined : id)) + } + key={`request-filter-member-${id}`} + icon={requestedByFilter === id && } + iconPos="right" + > + {user.email} + + ))} + + + )}
From 4e449f62c0e6bd7c8552d2aa4922153dd53df54c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:58:16 -0700 Subject: [PATCH 122/244] Fix: Don't display requested by when user has no access to read workspace members --- .../SecretApprovalRequest.tsx | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 96976d140..32a7ff6de 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -19,7 +19,13 @@ import { EmptyState, Skeleton } from "@app/components/v2"; -import { useUser, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useUser, + useWorkspace +} from "@app/context"; import { useGetSecretApprovalRequestCount, useGetSecretApprovalRequests, @@ -58,6 +64,7 @@ export const SecretApprovalRequest = () => { const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = useGetSecretApprovalRequestCount({ workspaceId }); const { user: presentUser } = useUser(); + const { permission } = useProjectPermission(); const { data: members } = useGetWorkspaceUsers(workspaceId); const membersGroupById = members?.reduce>( (prev, curr) => ({ ...prev, [curr.id]: curr }), @@ -156,31 +163,37 @@ export const SecretApprovalRequest = () => { ))} - - - - - - Select an author - {members?.map(({ user, id }) => ( - setCommitterFilter((state) => (state === id ? undefined : id))} - key={`request-filter-member-${id}`} - icon={committerFilter === id && } - iconPos="right" + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user, id }) => ( + + setCommitterFilter((state) => (state === id ? undefined : id)) + } + key={`request-filter-member-${id}`} + icon={committerFilter === id && } + iconPos="right" + > + {user.email} + + ))} + + + )}
From dc3014409f39e8896d2fd466ed7e41b1dcaf916e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:59:01 -0700 Subject: [PATCH 123/244] Delete access-approval-request-secret-dal.ts --- .../access-approval-request-secret-dal.ts | 230 ------------------ 1 file changed, 230 deletions(-) delete mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts deleted file mode 100644 index d458d58ff..000000000 --- a/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { Knex } from "knex"; - -import { TDbClient } from "@app/db"; -import { - SecretApprovalRequestsSecretsSchema, - TableName, - TSecretApprovalRequestsSecrets, - TSecretTags -} from "@app/db/schemas"; -import { BadRequestError, DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; - -export type TAccessApprovalRequestSecretDALFactory = ReturnType; - -export const accessApprovalRequestSecretDALFactory = (db: TDbClient) => { - const accessApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret); - const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag); - - const bulkUpdateNoVersionIncrement = async (data: TSecretApprovalRequestsSecrets[], tx?: Knex) => { - try { - const existingApprovalSecrets = await accessApprovalRequestSecretOrm.find( - { - $in: { - id: data.map((el) => el.id) - } - }, - { tx } - ); - - if (existingApprovalSecrets.length !== data.length) { - throw new BadRequestError({ message: "Some of the secret approvals do not exist" }); - } - - if (data.length === 0) return []; - - const updatedApprovalSecrets = await (tx || db)(TableName.SecretApprovalRequestSecret) - .insert(data) - .onConflict("id") // this will cause a conflict then merge the data - .merge() // Merge the data with the existing data - .returning("*"); - - return updatedApprovalSecrets; - } catch (error) { - throw new DatabaseError({ error, name: "bulk update secret" }); - } - }; - - const findByRequestId = async (requestId: string, tx?: Knex) => { - try { - const doc = await (tx || db)({ - secVerTag: TableName.SecretTag - }) - .from(TableName.SecretApprovalRequestSecret) - .where({ requestId }) - .leftJoin( - TableName.SecretApprovalRequestSecretTag, - `${TableName.SecretApprovalRequestSecret}.id`, - `${TableName.SecretApprovalRequestSecretTag}.secretId` - ) - .leftJoin(TableName.SecretTag, `${TableName.SecretApprovalRequestSecretTag}.tagId`, `${TableName.SecretTag}.id`) - .leftJoin(TableName.Secret, `${TableName.SecretApprovalRequestSecret}.secretId`, `${TableName.Secret}.id`) - .leftJoin( - TableName.SecretVersion, - `${TableName.SecretVersion}.id`, - `${TableName.SecretApprovalRequestSecret}.secretVersion` - ) - .leftJoin( - TableName.SecretVersionTag, - `${TableName.SecretVersionTag}.${TableName.SecretVersion}Id`, - `${TableName.SecretVersion}.id` - ) - .leftJoin( - db.ref(TableName.SecretTag).as("secVerTag"), - `${TableName.SecretVersionTag}.${TableName.SecretTag}Id`, - db.ref("id").withSchema("secVerTag") - ) - .select(selectAllTableCols(TableName.SecretApprovalRequestSecret)) - .select({ - secVerTagId: "secVerTag.id", - secVerTagColor: "secVerTag.color", - secVerTagSlug: "secVerTag.slug", - secVerTagName: "secVerTag.name" - }) - .select( - db.ref("id").withSchema(TableName.SecretTag).as("tagId"), - db.ref("id").withSchema(TableName.SecretApprovalRequestSecretTag).as("tagJnId"), - db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") - ) - .select( - db.ref("secretBlindIndex").withSchema(TableName.Secret).as("orgSecBlindIndex"), - db.ref("version").withSchema(TableName.Secret).as("orgSecVersion"), - db.ref("secretKeyIV").withSchema(TableName.Secret).as("orgSecKeyIV"), - db.ref("secretKeyTag").withSchema(TableName.Secret).as("orgSecKeyTag"), - db.ref("secretKeyCiphertext").withSchema(TableName.Secret).as("orgSecKeyCiphertext"), - db.ref("secretValueIV").withSchema(TableName.Secret).as("orgSecValueIV"), - db.ref("secretValueTag").withSchema(TableName.Secret).as("orgSecValueTag"), - db.ref("secretValueCiphertext").withSchema(TableName.Secret).as("orgSecValueCiphertext"), - db.ref("secretCommentIV").withSchema(TableName.Secret).as("orgSecCommentIV"), - db.ref("secretCommentTag").withSchema(TableName.Secret).as("orgSecCommentTag"), - db.ref("secretCommentCiphertext").withSchema(TableName.Secret).as("orgSecCommentCiphertext") - ) - .select( - db.ref("version").withSchema(TableName.SecretVersion).as("secVerVersion"), - db.ref("secretKeyIV").withSchema(TableName.SecretVersion).as("secVerKeyIV"), - db.ref("secretKeyTag").withSchema(TableName.SecretVersion).as("secVerKeyTag"), - db.ref("secretKeyCiphertext").withSchema(TableName.SecretVersion).as("secVerKeyCiphertext"), - db.ref("secretValueIV").withSchema(TableName.SecretVersion).as("secVerValueIV"), - db.ref("secretValueTag").withSchema(TableName.SecretVersion).as("secVerValueTag"), - db.ref("secretValueCiphertext").withSchema(TableName.SecretVersion).as("secVerValueCiphertext"), - db.ref("secretCommentIV").withSchema(TableName.SecretVersion).as("secVerCommentIV"), - db.ref("secretCommentTag").withSchema(TableName.SecretVersion).as("secVerCommentTag"), - db.ref("secretCommentCiphertext").withSchema(TableName.SecretVersion).as("secVerCommentCiphertext") - ); - const formatedDoc = sqlNestRelationships({ - data: doc, - key: "id", - parentMapper: (data) => SecretApprovalRequestsSecretsSchema.omit({ secretVersion: true }).parse(data), - childrenMapper: [ - { - key: "tagJnId", - label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color }) => ({ - id, - name, - slug, - color - }) - }, - { - key: "secretId", - label: "secret" as const, - mapper: ({ - orgSecKeyIV, - orgSecKeyTag, - orgSecValueIV, - orgSecVersion, - orgSecValueTag, - orgSecCommentIV, - orgSecBlindIndex, - orgSecCommentTag, - orgSecKeyCiphertext, - orgSecValueCiphertext, - orgSecCommentCiphertext, - secretId - }) => - secretId - ? { - id: secretId, - version: orgSecVersion, - secretBlindIndex: orgSecBlindIndex, - secretKeyIV: orgSecKeyIV, - secretKeyTag: orgSecKeyTag, - secretKeyCiphertext: orgSecKeyCiphertext, - secretValueIV: orgSecValueIV, - secretValueTag: orgSecValueTag, - secretValueCiphertext: orgSecValueCiphertext, - secretCommentIV: orgSecCommentIV, - secretCommentTag: orgSecCommentTag, - secretCommentCiphertext: orgSecCommentCiphertext - } - : undefined - }, - { - key: "secretVersion", - label: "secretVersion" as const, - mapper: ({ - secVerCommentIV, - secVerCommentCiphertext, - secVerCommentTag, - secVerValueCiphertext, - secVerKeyIV, - secVerKeyTag, - secVerValueIV, - secretVersion, - secVerValueTag, - secVerKeyCiphertext, - secVerVersion - }) => - secretVersion - ? { - version: secVerVersion, - id: secretVersion, - secretKeyIV: secVerKeyIV, - secretKeyTag: secVerKeyTag, - secretKeyCiphertext: secVerKeyCiphertext, - secretValueIV: secVerValueIV, - secretValueTag: secVerValueTag, - secretValueCiphertext: secVerValueCiphertext, - secretCommentIV: secVerCommentIV, - secretCommentTag: secVerCommentTag, - secretCommentCiphertext: secVerCommentCiphertext - } - : undefined, - childrenMapper: [ - { - key: "secVerTagId", - label: "tags" as const, - mapper: ({ secVerTagId: id, secVerTagName: name, secVerTagSlug: slug, secVerTagColor: color }) => ({ - // eslint-disable-next-line - id, - // eslint-disable-next-line - name, - // eslint-disable-next-line - slug, - // eslint-disable-next-line - color - }) - } - ] - } - ] - }); - return formatedDoc?.map(({ secret, secretVersion, ...el }) => ({ - ...el, - secret: secret?.[0], - secretVersion: secretVersion?.[0] - })); - } catch (error) { - throw new DatabaseError({ error, name: "FindByRequestId" }); - } - }; - return { - ...accessApprovalRequestSecretOrm, - findByRequestId, - bulkUpdateNoVersionIncrement, - insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany - }; -}; From 972ecc3e9213da0a2072b08c3b8b92ef5023ffa5 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:18:02 -0700 Subject: [PATCH 124/244] Fix: Improved migrations --- .../20240330075122_access-approval-policy.ts | 11 ++--------- .../20240401173320_access_approval_requests.ts | 11 ++--------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 79af3aa6e..61cb9274c 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -31,16 +31,9 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - const approverTableExists = await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover); - const policyTableExists = await knex.schema.hasTable(TableName.AccessApprovalPolicy); - await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); - if (approverTableExists) { - await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); - } - if (policyTableExists) { - await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); - } + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); } diff --git a/backend/src/db/migrations/20240401173320_access_approval_requests.ts b/backend/src/db/migrations/20240401173320_access_approval_requests.ts index c03287b57..901be9a78 100644 --- a/backend/src/db/migrations/20240401173320_access_approval_requests.ts +++ b/backend/src/db/migrations/20240401173320_access_approval_requests.ts @@ -43,16 +43,9 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - const reviewerTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer); - const requestTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequest); - await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer); await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest); - if (reviewerTableExists) { - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); - } - if (requestTableExists) { - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); - } + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); } From 9d362b8597d49d8acbb51160893ba3b50ffe8acf Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:18:15 -0700 Subject: [PATCH 125/244] Chore: Cleaned up models --- backend/src/db/schemas/models.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index f401e822c..24c6b8914 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -50,20 +50,16 @@ export enum TableName { IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", ScimToken = "scim_tokens", - - // New tables so far AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", AccessApprovalRequest = "access_approval_requests", AccessApprovalRequestReviewer = "access_approval_requests_reviewers", - SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalRequest = "secret_approval_requests", SecretApprovalRequestReviewer = "secret_approval_requests_reviewers", SecretApprovalRequestSecret = "secret_approval_requests_secrets", SecretApprovalRequestSecretTag = "secret_approval_request_secret_tags", - SecretRotation = "secret_rotations", SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", From dd43268506957f0b85cc1df3671a5afd60efae6c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:18:45 -0700 Subject: [PATCH 126/244] Fix: Made API endpoints more REST compliant --- .../ee/routes/v1/access-approval-policy-router.ts | 10 +++++----- backend/src/ee/routes/v1/index.ts | 4 ++-- frontend/src/hooks/api/accessApproval/mutation.tsx | 10 +++++----- frontend/src/hooks/api/accessApproval/queries.tsx | 14 +++++++------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 8a8090042..3b8949d3b 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -59,7 +59,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectId({ + const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectSlug({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -71,7 +71,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }); server.route({ - url: "/policy-count", + url: "/count", method: "GET", schema: { querystring: z.object({ @@ -80,14 +80,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }), response: { 200: z.object({ - policyCount: z.number() + count: z.number() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { policyCount } = await server.services.accessApprovalPolicy.getAccessPolicyCountByEnvSlug({ + const { count } = await server.services.accessApprovalPolicy.getAccessPolicyCountByEnvSlug({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -95,7 +95,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi actorOrgId: req.permission.orgId, envSlug: req.query.envSlug }); - return { policyCount }; + return { count }; } }); diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index c73ed24c5..fc5c0865d 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -43,8 +43,8 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { prefix: "/secret-rotation-providers" }); - await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals" }); - await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approval-requests" }); + await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals/policies" }); + await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approvals/requests" }); await server.register( async (dynamicSecretRouter) => { diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 1bf3841ff..5f595c8a2 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -17,7 +17,7 @@ export const useCreateAccessApprovalPolicy = () => { return useMutation<{}, {}, TCreateAccessPolicyDTO>({ mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => { - const { data } = await apiRequest.post("/api/v1/access-approvals", { + const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { environment, projectSlug, approvals, @@ -38,7 +38,7 @@ export const useUpdateAccessApprovalPolicy = () => { return useMutation<{}, {}, TUpdateAccessPolicyDTO>({ mutationFn: async ({ id, approvers, approvals, name, secretPath }) => { - const { data } = await apiRequest.patch(`/api/v1/access-approvals/${id}`, { + const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, approvers, secretPath, @@ -57,7 +57,7 @@ export const useDeleteAccessApprovalPolicy = () => { return useMutation<{}, {}, TDeleteSecretPolicyDTO>({ mutationFn: async ({ id }) => { - const { data } = await apiRequest.delete(`/api/v1/access-approvals/${id}`); + const { data } = await apiRequest.delete(`/api/v1/access-approvals/policies/${id}`); return data; }, onSuccess: (_, { projectSlug }) => { @@ -71,7 +71,7 @@ export const useCreateAccessRequest = () => { return useMutation<{}, {}, TCreateAccessRequestDTO>({ mutationFn: async ({ projectSlug, ...request }) => { const { data } = await apiRequest.post( - "/api/v1/access-approval-requests", + "/api/v1/access-approvals/requests", { ...request, permissions: request.permissions ? packRules(request.permissions) : undefined @@ -106,7 +106,7 @@ export const useReviewAccessRequest = () => { >({ mutationFn: async ({ requestId, status }) => { const { data } = await apiRequest.post( - `/api/v1/access-approval-requests/${requestId}/review`, + `/api/v1/access-approvals/requests/${requestId}/review`, { status } diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index 112c53fe3..599962e43 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -19,7 +19,7 @@ export const accessApprovalKeys = { [{ workspaceId, environment }, "access-approval-policy"] as const, getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) => - [{ projectSlug, envSlug, requestedBy }, "access-approval-requests"] as const, + [{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const, getAccessApprovalRequestCount: (projectSlug: string) => [{ projectSlug }, "access-approval-request-count"] as const }; @@ -28,13 +28,13 @@ export const fetchPolicyApprovalCount = async ({ projectSlug, envSlug }: TGetAccessPolicyApprovalCountDTO) => { - const { data } = await apiRequest.get<{ policyCount: number }>( - "/api/v1/access-approvals/policy-count", + const { data } = await apiRequest.get<{ count: number }>( + "/api/v1/access-approvals/policies/count", { params: { projectSlug, envSlug } } ); - return data.policyCount; + return data.count; }; export const useGetAccessPolicyApprovalCount = ({ @@ -57,7 +57,7 @@ export const useGetAccessPolicyApprovalCount = ({ const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequestsDTO) => { const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>( - "/api/v1/access-approvals", + "/api/v1/access-approvals/policies", { params: { projectSlug } } ); return data.approvals; @@ -69,7 +69,7 @@ const fetchApprovalRequests = async ({ authorProjectMembershipId }: TGetAccessApprovalRequestsDTO) => { const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>( - "/api/v1/access-approval-requests", + "/api/v1/access-approvals/requests", { params: { projectSlug, envSlug, authorProjectMembershipId } } ); @@ -90,7 +90,7 @@ const fetchApprovalRequests = async ({ const fetchAccessRequestsCount = async (projectSlug: string) => { const { data } = await apiRequest.get( - "/api/v1/access-approval-requests/count", + "/api/v1/access-approvals/requests/count", { params: { projectSlug } } ); return data; From 5884565de74c28d77611e6a5aa77f117ef41fc2d Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:19:17 -0700 Subject: [PATCH 127/244] Fix: Make verifyApprovers independent on memberships --- .../access-approval-policy-fns.ts | 7 +++---- .../access-approval-policy-types.ts | 12 +++--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts index b570c32fa..7b0a2681f 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts @@ -7,7 +7,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/pr import { TVerifyApprovers } from "./access-approval-policy-types"; export const verifyApprovers = async ({ - approverProjectMemberships, + userIds, projectId, orgId, envSlug, @@ -15,12 +15,11 @@ export const verifyApprovers = async ({ secretPath, permissionService }: TVerifyApprovers) => { - for (const approver of approverProjectMemberships) { + for await (const userId of userIds) { try { - // eslint-disable-next-line no-await-in-loop const { permission: approverPermission } = await permissionService.getProjectPermission( ActorType.USER, - approver.userId, + userId, projectId, actorAuthMethod, orgId diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index c7b452771..601561b68 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -1,11 +1,10 @@ -import { TProjectMemberships } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod } from "@app/services/auth/auth-type"; import { TPermissionServiceFactory } from "../permission/permission-service"; export type TVerifyApprovers = { - approverProjectMemberships: TProjectMemberships[]; + userIds: string[]; permissionService: Pick; envSlug: string; actorAuthMethod: ActorAuthMethod; @@ -16,7 +15,7 @@ export type TVerifyApprovers = { export type TCreateAccessApprovalPolicy = { approvals: number; - secretPath?: string | null; + secretPath: string; environment: string; approvers: string[]; projectSlug: string; @@ -26,7 +25,7 @@ export type TCreateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; - approvers: string[]; + approvers?: string[]; secretPath?: string; name?: string; } & Omit; @@ -43,8 +42,3 @@ export type TGetAccessPolicyCountByEnvironmentDTO = { export type TListAccessApprovalPoliciesDTO = { projectSlug: string; } & Omit; - -export type TGetBoardAccessApprovalPolicy = { - projectId: string; - environment: string; -} & Omit; From 2c1eecaf85acdaa1a6358b7d14604b87d6155891 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:19:43 -0700 Subject: [PATCH 128/244] Chore: Moved verifyApprovers --- .../access-approval-policy-service.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index c384dd71a..51a51abb5 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -54,7 +54,6 @@ export const accessApprovalPolicyServiceFactory = ({ if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); - if (!secretPath) throw new BadRequestError({ message: "Secret path is required" }); const { permission } = await permissionService.getProjectPermission( actor, @@ -75,6 +74,10 @@ export const accessApprovalPolicyServiceFactory = ({ $in: { id: approvers } }); + if (secretApprovers.length !== approvers.length) { + throw new BadRequestError({ message: "Approver not found in project" }); + } + await verifyApprovers({ projectId: project.id, orgId: actorOrgId, @@ -82,13 +85,9 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, actorAuthMethod, permissionService, - approverProjectMemberships: secretApprovers + userIds: secretApprovers.map((approver) => approver.userId) }); - if (secretApprovers.length !== approvers.length) { - throw new BadRequestError({ message: "Approver not found in project" }); - } - const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.create( { @@ -111,7 +110,7 @@ export const accessApprovalPolicyServiceFactory = ({ return { ...accessApproval, environment: env, projectId: project.id }; }; - const getAccessApprovalPolicyByProjectId = async ({ + const getAccessApprovalPolicyByProjectSlug = async ({ actorId, actor, actorOrgId, @@ -185,7 +184,7 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath: doc.secretPath!, actorAuthMethod, permissionService, - approverProjectMemberships: secretApprovers + userIds: secretApprovers.map((approver) => approver.userId) }); if (secretApprovers.length !== approvers.length) @@ -261,7 +260,7 @@ export const accessApprovalPolicyServiceFactory = ({ const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); if (!policies) throw new BadRequestError({ message: "No policies found" }); - return { policyCount: policies.length }; + return { count: policies.length }; }; return { @@ -269,6 +268,6 @@ export const accessApprovalPolicyServiceFactory = ({ createAccessApprovalPolicy, deleteAccessApprovalPolicy, updateAccessApprovalPolicy, - getAccessApprovalPolicyByProjectId + getAccessApprovalPolicyByProjectSlug }; }; From 0c1103e778af82262dcd3515e0b773bf3dfb70b2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:19:58 -0700 Subject: [PATCH 129/244] Fix: Pick --- .../access-approval-request-service.ts | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index d9762d879..f0714ea34 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -16,7 +16,6 @@ import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/acces import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; -import { TProjectUserAdditionalPrivilegeServiceFactory } from "../project-user-additional-privilege/project-user-additional-privilege-service"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; import { verifyRequestedPermissions } from "./access-approval-request-fns"; @@ -30,18 +29,29 @@ import { } from "./access-approval-request-types"; type TSecretApprovalRequestServiceFactoryDep = { - additionalPrivilegeService: TProjectUserAdditionalPrivilegeServiceFactory; - additionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; - permissionService: TPermissionServiceFactory; - accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + additionalPrivilegeDAL: Pick; + permissionService: Pick; + accessApprovalPolicyApproverDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick; - accessApprovalRequestDAL: TAccessApprovalRequestDALFactory; - accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; - accessApprovalRequestReviewerDAL: TAccessApprovalRequestReviewerDALFactory; - projectMembershipDAL: TProjectMembershipDALFactory; - smtpService: TSmtpService; - userDAL: TUserDALFactory; + accessApprovalRequestDAL: Pick< + TAccessApprovalRequestDALFactory, + | "create" + | "findRequestsWithPrivilegeByPolicyIds" + | "findById" + | "transaction" + | "updateById" + | "findOne" + | "getCount" + >; + accessApprovalPolicyDAL: Pick; + accessApprovalRequestReviewerDAL: Pick< + TAccessApprovalRequestReviewerDALFactory, + "create" | "find" | "findOne" | "transaction" + >; + projectMembershipDAL: Pick; + smtpService: Pick; + userDAL: Pick; }; export type TAccessApprovalRequestServiceFactory = ReturnType; @@ -107,10 +117,6 @@ export const accessApprovalRequestServiceFactory = ({ approvers.map((approver) => approver.approverId) ); - if (approverUsers.length !== approvers.length) { - throw new BadRequestError({ message: "Some approvers were not found" }); - } - const duplicateRequest = await accessApprovalRequestDAL.findOne({ policyId: policy.id, requestedBy: membership.id, @@ -252,7 +258,7 @@ export const accessApprovalRequestServiceFactory = ({ secretPath: accessApprovalRequest.policy.secretPath!, actorAuthMethod, permissionService, - approverProjectMemberships: [reviewerProjectMembership] + userIds: [reviewerProjectMembership.userId] }); const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); From 24a286e8986f34cbdc949fef052607079557e74a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:20:05 -0700 Subject: [PATCH 130/244] Update index.ts --- backend/src/server/routes/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 63c5609be..269a97c49 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -625,7 +625,6 @@ export const registerRoutes = async ( accessApprovalRequestReviewerDAL, additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, projectMembershipDAL, - additionalPrivilegeService: projectUserAdditionalPrivilegeService, accessApprovalPolicyDAL, accessApprovalRequestDAL, projectEnvDAL, From f038b28c1c1c21836bcf6ca1304e70609d381d26 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:20:18 -0700 Subject: [PATCH 131/244] Fix: Moved Divider to v2 --- frontend/src/components/{basic => v2/Divider}/Divider.tsx | 4 +--- frontend/src/components/v2/Divider/index.tsx | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) rename frontend/src/components/{basic => v2/Divider}/Divider.tsx (78%) create mode 100644 frontend/src/components/v2/Divider/index.tsx diff --git a/frontend/src/components/basic/Divider.tsx b/frontend/src/components/v2/Divider/Divider.tsx similarity index 78% rename from frontend/src/components/basic/Divider.tsx rename to frontend/src/components/v2/Divider/Divider.tsx index f1a570d7b..39b0f84c5 100644 --- a/frontend/src/components/basic/Divider.tsx +++ b/frontend/src/components/v2/Divider/Divider.tsx @@ -4,12 +4,10 @@ interface IProps { className?: string; } -const Divider = ({ className }: IProps): JSX.Element => { +export const Divider = ({ className }: IProps): JSX.Element => { return (
); }; - -export default Divider; diff --git a/frontend/src/components/v2/Divider/index.tsx b/frontend/src/components/v2/Divider/index.tsx new file mode 100644 index 000000000..ac407aa37 --- /dev/null +++ b/frontend/src/components/v2/Divider/index.tsx @@ -0,0 +1 @@ +export { Divider } from "./Divider"; From 6aab28c4c7055730c100867da35ceed95e33a62c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:21:13 -0700 Subject: [PATCH 132/244] Feat: Badge component --- frontend/src/components/v2/Badge/Badge.tsx | 32 ++++++++++++++++++++++ frontend/src/components/v2/Badge/index.tsx | 1 + 2 files changed, 33 insertions(+) create mode 100644 frontend/src/components/v2/Badge/Badge.tsx create mode 100644 frontend/src/components/v2/Badge/index.tsx diff --git a/frontend/src/components/v2/Badge/Badge.tsx b/frontend/src/components/v2/Badge/Badge.tsx new file mode 100644 index 000000000..321c03296 --- /dev/null +++ b/frontend/src/components/v2/Badge/Badge.tsx @@ -0,0 +1,32 @@ +import { cva, VariantProps } from "cva"; +import { twMerge } from "tailwind-merge"; + +interface IProps { + children: React.ReactNode; + className?: string; +} + +const badgeVariants = cva( + [ + "inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-xs text-yellow opacity-80 hover:opacity-100" + ], + { + variants: { + variant: { + primary: "bg-yellow/20 text-yellow", + danger: "bg-red/20 text-red", + success: "bg-green/20 text-green" + } + } + } +); + +export type BadgeProps = VariantProps & IProps; + +export const Badge = ({ children, className, variant }: BadgeProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/frontend/src/components/v2/Badge/index.tsx b/frontend/src/components/v2/Badge/index.tsx new file mode 100644 index 000000000..5c7042709 --- /dev/null +++ b/frontend/src/components/v2/Badge/index.tsx @@ -0,0 +1 @@ +export { Badge } from "./Badge"; From 3d3b1eb21acb50b1aac29420de4ab866b71cd34c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:21:26 -0700 Subject: [PATCH 133/244] Fix: Use username instead of email --- frontend/src/pages/login/select-organization.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 586ed62f9..de866a323 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -35,8 +35,6 @@ export default function LoginPage() { const selectOrg = useSelectOrganization(); const { user, isLoading: userLoading } = useUser(); - - const queryParams = new URLSearchParams(window.location.search); const logout = useLogoutUser(true); @@ -153,7 +151,7 @@ export default function LoginPage() {

- You‘re currently logged in as {user.email} + You‘re currently logged in as {user.username}

Not you?{" "} From cdd836d58f156150b303405cc8bb16cc05543b47 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:21:35 -0700 Subject: [PATCH 134/244] Fix: Columns --- .../AccessApprovalPolicyList/AccessApprovalPolicyList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx index 3e8fdb3b9..aa47cce80 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx @@ -30,7 +30,7 @@ import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/quer import { TAccessApprovalPolicy } from "@app/hooks/api/types"; import { AccessApprovalPolicyRow } from "./components/AccessApprovalPolicyRow"; -import { AccessPolicyForm } from "./components/AccessPolicyForm"; +import { AccessPolicyForm } from "./components/AccessPolicyModal"; interface IProps { workspaceId: string; @@ -127,11 +127,11 @@ export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { {isPoliciesLoading && ( - + )} {!isPoliciesLoading && !policies?.length && ( - + From 8ed3c0cd68df192750fcbf120045f9bc01538a29 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:21:47 -0700 Subject: [PATCH 135/244] Fix: Use username instead of email --- .../components/AccessApprovalPolicyRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx index 8554e928e..8476bac8d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx @@ -98,7 +98,7 @@ export const AccessApprovalPolicyRow = ({ iconPos="right" icon={isChecked && } > - {user.email} + {user.username} ); })} From 3637152a6bd628caa432005842afb03bc40a61ed Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:21:57 -0700 Subject: [PATCH 136/244] Chore: Remove unused files --- .../components/AccessPolicyForm.tsx | 266 ------------------ .../components/AccessApprovalPolicyRow.tsx | 146 ---------- .../components/AccessPolicyForm.tsx | 261 ----------------- 3 files changed, 673 deletions(-) delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx deleted file mode 100644 index dd51ea53d..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - useCreateAccessApprovalPolicy, - useUpdateAccessApprovalPolicy -} from "@app/hooks/api/accessApproval"; -import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - isOpen?: boolean; - onToggle: (isOpen: boolean) => void; - members?: TWorkspaceUser[]; - projectSlug: string; - editValues?: TAccessApprovalPolicy; -}; - -const formSchema = z - .object({ - environment: z.string(), - name: z.string().optional(), - secretPath: z.string().optional(), - approvals: z.number().min(1), - approvers: z.string().array().min(1) - }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." - }); - -type TFormSchema = z.infer; - -export const AccessPolicyForm = ({ - isOpen, - onToggle, - members = [], - projectSlug, - editValues -}: Props) => { - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined - }); - const { currentWorkspace } = useWorkspace(); - - const environments = currentWorkspace?.environments || []; - useEffect(() => { - if (!isOpen) reset({}); - }, [isOpen]); - - const isEditMode = Boolean(editValues); - - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); - const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); - - const handleCreatePolicy = async (data: TFormSchema) => { - if (!projectSlug) return; - - try { - await createAccessApprovalPolicy({ - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully created policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create policy" - }); - } - }; - - const handleUpdatePolicy = async (data: TFormSchema) => { - if (!projectSlug) return; - if (!editValues?.id) return; - - try { - await updateAccessApprovalPolicy({ - id: editValues?.id, - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully updated policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "failed to update policy" - }); - } - }; - - const handleFormSubmit = async (data: TFormSchema) => { - if (isEditMode) { - await handleUpdatePolicy(data); - } else { - await handleCreatePolicy(data); - } - }; - - return ( - - - - ( - - - - )} - /> - ( - - - - )} - /> - - ( - - - - )} - /> - - ( - - - - - - - - Select members that are allowed to approve changes - - {members.map(({ id, user }) => { - const isChecked = value?.includes(id); - return ( - { - evt.preventDefault(); - onChange( - isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.email} - - ); - })} - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> -

- - -
- - - - ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx deleted file mode 100644 index 8554e928e..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useState } from "react"; -import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - IconButton, - Input, - Td, - Tr -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; -import { useUpdateAccessApprovalPolicy } from "@app/hooks/api"; -import { TAccessApprovalPolicy } from "@app/hooks/api/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - policy: TAccessApprovalPolicy; - members?: TWorkspaceUser[]; - projectSlug: string; - onEdit: () => void; - onDelete: () => void; -}; - -export const AccessApprovalPolicyRow = ({ - policy, - members = [], - projectSlug, - onEdit, - onDelete -}: Props) => { - const [selectedApprovers, setSelectedApprovers] = useState([]); - const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); - const { permission } = useProjectPermission(); - - return ( - - {policy.name} - {policy.environment.slug} - {policy.secretPath || "*"} - - { - if (!isOpen) { - updateAccessApprovalPolicy( - { - projectSlug, - id: policy.id, - approvers: selectedApprovers - }, - { - onSettled: () => { - setSelectedApprovers([]); - } - } - ); - } else { - setSelectedApprovers(policy.approvers); - } - }} - > - - - - - - Select members that are allowed to approve changes - - {members?.map(({ id, user }) => { - const isChecked = selectedApprovers.includes(id); - return ( - { - evt.preventDefault(); - setSelectedApprovers((state) => - isChecked ? state.filter((el) => el !== id) : [...state, id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.email} - - ); - })} - - - - {policy.approvals} - -
- - {(isAllowed) => ( - - - - )} - - - {(isAllowed) => ( - - - - )} - -
- - - ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx deleted file mode 100644 index 7e88da100..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - useCreateAccessApprovalPolicy, - useUpdateAccessApprovalPolicy -} from "@app/hooks/api/accessApproval"; -import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - isOpen?: boolean; - onToggle: (isOpen: boolean) => void; - members?: TWorkspaceUser[]; - projectSlug: string; - editValues?: TAccessApprovalPolicy; -}; - -const formSchema = z - .object({ - environment: z.string(), - name: z.string().optional(), - secretPath: z.string().optional(), - approvals: z.number().min(1), - approvers: z.string().array().min(1) - }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." - }); - -type TFormSchema = z.infer; - -export const AccessPolicyForm = ({ - isOpen, - onToggle, - members = [], - projectSlug, - editValues -}: Props) => { - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined - }); - const { currentWorkspace } = useWorkspace(); - - const environments = currentWorkspace?.environments || []; - useEffect(() => { - if (!isOpen) reset({}); - }, [isOpen]); - - const isEditMode = Boolean(editValues); - - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); - const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); - - const handleCreatePolicy = async (data: TFormSchema) => { - try { - await createAccessApprovalPolicy({ - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully created policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create policy" - }); - } - }; - - const handleUpdatePolicy = async (data: TFormSchema) => { - if (!editValues?.id) return; - try { - await updateAccessApprovalPolicy({ - id: editValues?.id, - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully updated policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "failed to update policy" - }); - } - }; - - const handleFormSubmit = async (data: TFormSchema) => { - if (isEditMode) { - await handleUpdatePolicy(data); - } else { - await handleCreatePolicy(data); - } - }; - - return ( - - -
- ( - - - - )} - /> - ( - - - - )} - /> - - ( - - - - )} - /> - - ( - - - - - - - - Select members that are allowed to approve changes - - {members.map(({ id, user }) => { - const isChecked = value?.includes(id); - return ( - { - evt.preventDefault(); - onChange( - isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.email} - - ); - })} - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> -
- - -
- -
-
- ); -}; From c325674da0ccea8490202cfb9f31f4eb06cf7cab Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:22:21 -0700 Subject: [PATCH 137/244] Fix: Move standalone components to individual files --- .../components/AccessPolicyModal.tsx | 266 ++++++++++++++++++ .../components/RequestAccessModal.tsx | 25 ++ .../components/ReviewAccessModal.tsx | 158 +++++++++++ 3 files changed, 449 insertions(+) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx new file mode 100644 index 000000000..6c0ee3fb6 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx @@ -0,0 +1,266 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + projectSlug: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z + .object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approvers: z.string().array().min(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + projectSlug, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined + }); + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + + try { + await createAccessApprovalPolicy({ + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + if (!editValues?.id) return; + + try { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + + + + + Select members that are allowed to approve changes + + {members.map(({ id, user }) => { + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.username} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx new file mode 100644 index 000000000..735ca3b53 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx @@ -0,0 +1,25 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; +import { SpecificPrivilegeSecretForm } from "@app/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection"; + +export const RequestAccessModal = ({ + isOpen, + onOpenChange, + policies +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + policies: TAccessApprovalPolicy[]; +}) => { + return ( + + + onOpenChange(false)} policies={policies} /> + + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx new file mode 100644 index 000000000..481cee9c6 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -0,0 +1,158 @@ +import { useCallback, useMemo, useState } from "react"; +import ms from "ms"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalContent } from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { ProjectPermissionActions } from "@app/context"; +import { useReviewAccessRequest } from "@app/hooks/api"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +export const ReviewAccessRequestModal = ({ + isOpen, + onOpenChange, + request, + projectSlug, + selectedRequester, + selectedEnvSlug +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }; + projectSlug: string; + selectedRequester: string | undefined; + selectedEnvSlug: string | undefined; +}) => { + const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); + + const accessDetails = { + env: request.environmentName, + // secret path will be inside $glob operator + secretPath: request.policy.secretPath, + read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)), + edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)), + create: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + + temporaryAccess: { + isTemporary: request.isTemporary, + temporaryRange: request.temporaryRange + } + }; + + const requestedAccess = useMemo(() => { + const access: string[] = []; + if (accessDetails.read) access.push("Read"); + if (accessDetails.edit) access.push("Edit"); + if (accessDetails.create) access.push("Create"); + if (accessDetails.delete) access.push("Delete"); + + return access.join(", "); + }, [accessDetails]); + + const getAccessLabel = () => { + if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange) + return "Permanent"; + + // convert the range to human readable format + ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true }); + + return ( + + {`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), { + long: true + })} after approval`} + + ); + }; + + const reviewAccessRequest = useReviewAccessRequest(); + + const handleReview = useCallback(async (status: "approved" | "rejected") => { + setIsLoading(status); + try { + await reviewAccessRequest.mutateAsync({ + requestId: request.id, + status, + projectSlug, + envSlug: selectedEnvSlug, + requestedBy: selectedRequester + }); + } catch (error) { + console.error(error); + setIsLoading(null); + return; + } + + createNotification({ + title: `Request ${status}`, + text: `The request has been ${status}`, + type: status === "approved" ? "success" : "info" + }); + + setIsLoading(null); + onOpenChange(false); + }, []); + + return ( + + +
+ + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + {" "} + is requesting access to the following resource: + + +
+
+ Requested path: + {accessDetails.env + accessDetails.secretPath || ""} +
+ +
+ Permissions: + {requestedAccess} +
+ +
+ Access Type: + {getAccessLabel()} +
+
+ +
+ + +
+
+
+
+ ); +}; From a3d7c5f5996121d856e0b615c850c343aa38c247 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:22:33 -0700 Subject: [PATCH 138/244] Cleanup --- .../AccessApprovalRequest.tsx | 229 ++---------------- 1 file changed, 22 insertions(+), 207 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index b6d69d0c9..235737d4a 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-nested-ternary */ /* eslint-disable react/jsx-no-useless-fragment */ -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { faCheck, faCheckCircle, @@ -11,10 +11,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { formatDistance } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; -import ms from "ms"; -import { twMerge } from "tailwind-merge"; -import { createNotification } from "@app/components/notifications"; import { Button, DropdownMenu, @@ -23,11 +20,10 @@ import { DropdownMenuLabel, DropdownMenuTrigger, EmptyState, - Modal, - ModalContent, Tooltip, UpgradePlanModal } from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -36,7 +32,7 @@ import { useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useGetWorkspaceUsers, useReviewAccessRequest } from "@app/hooks/api"; +import { useGetWorkspaceUsers } from "@app/hooks/api"; import { accessApprovalKeys, useGetAccessApprovalPolicies, @@ -44,192 +40,11 @@ import { useGetAccessRequestsCount } from "@app/hooks/api/accessApproval/queries"; import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; -import { ApprovalStatus, TAccessApprovalPolicy, TWorkspaceUser } from "@app/hooks/api/types"; +import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; import { queryClient } from "@app/reactQuery"; -import { SpecificPrivilegeSecretForm } from "@app/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection"; -const DisplayBadge = ({ text, className }: { text: string; className?: string }) => { - return ( -
- {text} -
- ); -}; - -const ReviewRequestModal = ({ - isOpen, - onOpenChange, - request, - projectSlug, - selectedRequester, - selectedEnvSlug -}: { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }; - projectSlug: string; - selectedRequester: string | undefined; - selectedEnvSlug: string | undefined; -}) => { - const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); - - const accessDetails = { - env: request.environmentName, - // secret path will be inside $glob operator - secretPath: request.policy.secretPath, - read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)), - edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)), - create: request.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Create) - ), - delete: request.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Delete) - ), - - temporaryAccess: { - isTemporary: request.isTemporary, - temporaryRange: request.temporaryRange - } - }; - - const requestedAccess = useMemo(() => { - const access: string[] = []; - if (accessDetails.read) access.push("Read"); - if (accessDetails.edit) access.push("Edit"); - if (accessDetails.create) access.push("Create"); - if (accessDetails.delete) access.push("Delete"); - - return access.join(", "); - }, [accessDetails]); - - const getAccessLabel = () => { - if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange) - return "Permanent"; - - // convert the range to human readable format - ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true }); - - return ( - - ); - }; - - const reviewAccessRequest = useReviewAccessRequest(); - - const handleReview = useCallback(async (status: "approved" | "rejected") => { - setIsLoading(status); - try { - await reviewAccessRequest.mutateAsync({ - requestId: request.id, - status, - projectSlug, - envSlug: selectedEnvSlug, - requestedBy: selectedRequester - }); - } catch (error) { - console.error(error); - setIsLoading(null); - return; - } - - createNotification({ - title: `Request ${status}`, - text: `The request has been ${status}`, - type: status === "approved" ? "success" : "info" - }); - - setIsLoading(null); - onOpenChange(false); - }, []); - - return ( - - -
- - - {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) - {" "} - is requesting access to the following resource: - - -
-
- Requested path: - -
- -
- Permissions: - -
- -
- Access Type: - {getAccessLabel()} -
-
- -
- - -
-
-
-
- ); -}; - -const SelectAccessModal = ({ - isOpen, - onOpenChange, - policies -}: { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - policies: TAccessApprovalPolicy[]; -}) => { - return ( - - - onOpenChange(false)} policies={policies} /> - - - ); -}; +import { RequestAccessModal } from "./components/RequestAccessModal"; +import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; const generateRequestText = (request: TAccessApprovalRequest, membershipId: string) => { const { isTemporary } = request; @@ -249,7 +64,7 @@ const generateRequestText = (request: TAccessApprovalRequest, membershipId: stri
{request.requestedBy === membershipId && ( - + Requested By You )}
@@ -331,29 +146,30 @@ export const AccessApprovalRequest = ({ ({ member }) => member === membership.id )?.status; - let displayData: { label: string; colorClass: string } = { label: "", colorClass: "" }; + let displayData: { label: string; type: "primary" | "danger" | "success" } = { + label: "", + type: "primary" + }; const isExpired = request.privilege && request.isApproved && new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); - if (isExpired) displayData = { label: "Access Expired", colorClass: "bg-red/20 text-red" }; - else if (isAccepted) - displayData = { label: "Access Granted", colorClass: "bg-green/20 text-green" }; - else if (isRejectedByAnyone) - displayData = { label: "Rejected", colorClass: "bg-red/20 text-red" }; + if (isExpired) displayData = { label: "Access Expired", type: "danger" }; + else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; + else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; else if (userReviewStatus === ApprovalStatus.APPROVED) { displayData = { label: `Pending ${request.policy.approvals - request.reviewers.length} review${ request.policy.approvals - request.reviewers.length > 1 ? "s" : "" }`, - colorClass: "bg-yellow/20 text-yellow" + type: "primary" }; } else if (!isReviewedByUser) displayData = { label: "Review Required", - colorClass: "bg-yellow/20 text-yellow" + type: "primary" }; return { @@ -486,7 +302,7 @@ export const AccessApprovalRequest = ({ icon={requestedByFilter === id && } iconPos="right" > - {user.email} + {user.username} ))} @@ -564,10 +380,9 @@ export const AccessApprovalRequest = ({
{details.isApprover && ( - + + {details.displayData.label} + )}
@@ -581,7 +396,7 @@ export const AccessApprovalRequest = ({ {!!policies && ( - { @@ -598,7 +413,7 @@ export const AccessApprovalRequest = ({ )} {!!selectedRequest && ( - Date: Tue, 9 Apr 2024 00:22:47 -0700 Subject: [PATCH 139/244] Fix: Moved from email to username --- .../components/SecretApprovalPolicyRow.tsx | 2 +- .../SecretApprovalPolicyList/components/SecretPolicyForm.tsx | 2 +- .../components/SecretApprovalRequest/SecretApprovalRequest.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx index c256af1c7..e0e4dd5fc 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx @@ -98,7 +98,7 @@ export const SecretApprovalPolicyRow = ({ iconPos="right" icon={isChecked && } > - {user.email} + {user.username} ); })} diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx index 185c596c2..db59761c1 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx @@ -222,7 +222,7 @@ export const SecretPolicyForm = ({ iconPos="right" icon={isChecked && } > - {user.email} + {user.username} ); })} diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 32a7ff6de..0d0c6213a 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -188,7 +188,7 @@ export const SecretApprovalRequest = () => { icon={committerFilter === id && } iconPos="right" > - {user.email} + {user.username} ))} From 0bbdf2a8f48db323358d8773f8ba3759186e32c2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:22:52 -0700 Subject: [PATCH 140/244] Update SecretApprovalPage.tsx --- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index d66a6c8cc..3878ecdca 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Divider from "@app/components/basic/Divider"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { Divider } from "@app/components/v2/Divider"; import { useWorkspace } from "@app/context"; import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList"; From d659b5a6249bf34594d4312ac52ab79b8342e5e2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 12 Apr 2024 03:58:50 +0200 Subject: [PATCH 141/244] Fix: Duplicate access request check --- .../access-approval-request-service.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index f0714ea34..becdb78da 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -37,6 +37,7 @@ type TSecretApprovalRequestServiceFactoryDep = { accessApprovalRequestDAL: Pick< TAccessApprovalRequestDALFactory, | "create" + | "find" | "findRequestsWithPrivilegeByPolicyIds" | "findById" | "transaction" @@ -117,31 +118,33 @@ export const accessApprovalRequestServiceFactory = ({ approvers.map((approver) => approver.approverId) ); - const duplicateRequest = await accessApprovalRequestDAL.findOne({ + const duplicateRequests = await accessApprovalRequestDAL.find({ policyId: policy.id, requestedBy: membership.id, permissions: JSON.stringify(requestedPermissions), isTemporary }); - if (duplicateRequest) { - if (duplicateRequest.privilegeId) { - const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); + if (duplicateRequests?.length > 0) { + for await (const duplicateRequest of duplicateRequests) { + if (duplicateRequest.privilegeId) { + const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); - const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); + const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); - if (!isExpired || !privilege.isTemporary) { - throw new BadRequestError({ message: "You already have an active privilege with the same criteria" }); - } - } else { - const reviewers = await accessApprovalRequestReviewerDAL.find({ - requestId: duplicateRequest.id - }); + if (!isExpired || !privilege.isTemporary) { + throw new BadRequestError({ message: "You already have an active privilege with the same criteria" }); + } + } else { + const reviewers = await accessApprovalRequestReviewerDAL.find({ + requestId: duplicateRequest.id + }); - const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED); + const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED); - if (!isRejected) { - throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); + if (!isRejected) { + throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); + } } } } From 7ac3bb20df65beddee029ae0e54c0aeb6cfa58e8 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 12 Apr 2024 11:27:20 -0700 Subject: [PATCH 142/244] Update instance recognition of offline license --- backend/src/ee/services/license/license-service.ts | 4 ++-- backend/src/ee/services/license/license-types.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index e81f6dc12..47b46d010 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -121,8 +121,8 @@ export const licenseServiceFactory = ({ if (isValidOfflineLicense) { onPremFeatures = contents.license.features; - instanceType = InstanceType.EnterpriseOnPrem; - logger.info(`Instance type: ${InstanceType.EnterpriseOnPrem}`); + instanceType = InstanceType.EnterpriseOnPremOffline; + logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`); isValidLicense = true; return; } diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index a2379ddaa..0c8fdc197 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -3,6 +3,7 @@ import { TOrgPermission } from "@app/lib/types"; export enum InstanceType { OnPrem = "self-hosted", EnterpriseOnPrem = "enterprise-self-hosted", + EnterpriseOnPremOffline = "enterprise-self-hosted-offline", Cloud = "cloud" } From 73d9fcc0de3b632cf0b22190f41e26d3d6bdd927 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 29 Apr 2024 19:11:13 +0200 Subject: [PATCH 143/244] Draft --- .../MemberRoleForm/SpecificPrivilegeSection.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 795ccbb24..d39a88aa2 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -369,13 +369,13 @@ export const SpecificPrivilegeSecretForm = ({ } return ( - - + + ); }} /> From 2aacd5411636a9040dfd653d0366eed60b228768 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 29 Apr 2024 19:11:36 +0200 Subject: [PATCH 144/244] Update SpecificPrivilegeSection.tsx --- .../MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index d39a88aa2..6fb97063b 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -134,7 +134,6 @@ export const SpecificPrivilegeSecretForm = ({ }); const temporaryAccessField = privilegeForm.watch("temporaryAccess"); - const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug"); const selectedEnvironment = privilegeForm.watch("environmentSlug"); const secretPath = privilegeForm.watch("secretPath"); @@ -373,7 +372,7 @@ export const SpecificPrivilegeSecretForm = ({ {...field} isDisabled={isMemberEditDisabled} containerClassName="w-48" - environment={selectedEnvironmentSlug} + environment={selectedEnvironment} /> ); From 5894df4370556728099e394780d8595295dbd0e4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 29 Mar 2024 16:24:15 +0100 Subject: [PATCH 145/244] Draft --- backend/src/ee/services/license/licence-fns.ts | 2 +- frontend/src/components/basic/Divider.tsx | 15 +++++++++++++++ .../permissions/PermissionDeniedBanner.tsx | 5 +++++ .../SecretApprovalPage/SecretApprovalPage.tsx | 6 ++++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/basic/Divider.tsx diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..492d09ec3 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true }); diff --git a/frontend/src/components/basic/Divider.tsx b/frontend/src/components/basic/Divider.tsx new file mode 100644 index 000000000..f1a570d7b --- /dev/null +++ b/frontend/src/components/basic/Divider.tsx @@ -0,0 +1,15 @@ +import { twMerge } from "tailwind-merge"; + +interface IProps { + className?: string; +} + +const Divider = ({ className }: IProps): JSX.Element => { + return ( +
+ + ); +}; + +export default Divider; diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index b3c7a4f53..f9707d634 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,6 +3,8 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { Button } from "../v2"; + type Props = { containerClassName?: string; className?: string; @@ -32,6 +34,9 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children )}
+
); diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 3878ecdca..fa083b908 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -56,13 +56,19 @@ export const SecretApprovalPage = () => { + + + + + + From 810f670e64fea1f6096320a6166d3c0a99fc3b4b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 08:22:46 -0700 Subject: [PATCH 146/244] Feat: Request Access --- backend/src/ee/routes/v1/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index fc5c0865d..16e23eb88 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,6 +1,6 @@ -import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; +import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; From 3331699f56be77e8bfd85d167c27db1e1e959acf Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:49:22 -0700 Subject: [PATCH 147/244] Feat: Request access --- backend/scripts/generate-schema-types.ts | 2 +- .../20240330075122_access-approval-policy.ts | 3 +- .../access-approval-policy-service.ts | 1 - .../SecretApprovalPage/SecretApprovalPage.tsx | 7 - .../components/AccessPolicyForm.tsx | 250 ++++++++++++++++++ 5 files changed, 253 insertions(+), 10 deletions(-) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991f..28b736152 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env.migration") + path: path.join(__dirname, "../../.env") }); const db = knex({ diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 61cb9274c..7aa9da6a0 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -9,8 +9,9 @@ export async function up(knex: Knex): Promise { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.string("name").notNullable(); t.integer("approvals").defaultTo(1).notNullable(); - t.uuid("envId").notNullable(); t.string("secretPath"); + + t.uuid("envId").notNullable(); t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); t.timestamps(true, true, true); }); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 51a51abb5..f5cce5db4 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -154,7 +154,6 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index fa083b908..9c273848f 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -53,22 +53,15 @@ export const SecretApprovalPage = () => { Access Requests Access Request Policies - - - - - - - diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx new file mode 100644 index 000000000..56a4bdef5 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -0,0 +1,250 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + workspaceId: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z + .object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional().nullable(), + approvals: z.number().min(1), + approvers: z.string().array().min(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + workspaceId, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined + }); + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + try { + await createAccessApprovalPolicy({ + ...data, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!editValues?.id) return; + try { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + + Select members that are allowed to approve changes + + {members.map(({ id, user }) => { + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; From 448f89fd1c4d536dfc4a15c53bebb469393ee067 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:35:51 -0700 Subject: [PATCH 148/244] Feat: Request Access (migrations) --- .../src/db/migrations/20240330075122_access-approval-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 7aa9da6a0..e6d47d864 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -12,6 +12,7 @@ export async function up(knex: Knex): Promise { t.string("secretPath"); t.uuid("envId").notNullable(); + t.string("secretPath"); t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); t.timestamps(true, true, true); }); @@ -34,7 +35,6 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); } From 5c69bbf515f8c8debba601f6b6c8bb33328d1ea4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:37:49 -0700 Subject: [PATCH 149/244] Feat: Request access (new routes) --- .../v1/access-approval-request-router.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 4b173cfa7..696c8b76f 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -1,7 +1,9 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -11,12 +13,24 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv method: "POST", schema: { body: z.object({ + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(`requested-privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), permissions: z.any().array(), isTemporary: z.boolean(), temporaryRange: z.string().optional() }), querystring: z.object({ - projectSlug: z.string().trim() + projectSlug: z.string().trim(), + secretPath: z.string().trim(), + envSlug: z.string().trim() }), response: { 200: z.object({ @@ -31,7 +45,9 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, permissions: req.body.permissions, + envSlug: req.query.envSlug, actorOrgId: req.permission.orgId, + secretPath: req.query.secretPath, projectSlug: req.query.projectSlug, temporaryRange: req.body.temporaryRange, isTemporary: req.body.isTemporary From cd6be68461efddd5ce084f11e55de9d57f6bcba4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:39:15 -0700 Subject: [PATCH 150/244] Fix: Validate approvers access --- .../access-approval-policy/access-approval-policy-service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index f5cce5db4..f14380869 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -54,6 +54,7 @@ export const accessApprovalPolicyServiceFactory = ({ if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + if (!secretPath) throw new BadRequestError({ message: "Secret path is required" }); const { permission } = await permissionService.getProjectPermission( actor, @@ -154,6 +155,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, actorOrgId ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { From 812cced9d53e154a3a3cab9827386e2dd1dc6748 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:40:05 -0700 Subject: [PATCH 151/244] Feat: Request access --- .../access-approval-request-secret-dal.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts new file mode 100644 index 000000000..d458d58ff --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts @@ -0,0 +1,230 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + SecretApprovalRequestsSecretsSchema, + TableName, + TSecretApprovalRequestsSecrets, + TSecretTags +} from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; + +export type TAccessApprovalRequestSecretDALFactory = ReturnType; + +export const accessApprovalRequestSecretDALFactory = (db: TDbClient) => { + const accessApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret); + const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag); + + const bulkUpdateNoVersionIncrement = async (data: TSecretApprovalRequestsSecrets[], tx?: Knex) => { + try { + const existingApprovalSecrets = await accessApprovalRequestSecretOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingApprovalSecrets.length !== data.length) { + throw new BadRequestError({ message: "Some of the secret approvals do not exist" }); + } + + if (data.length === 0) return []; + + const updatedApprovalSecrets = await (tx || db)(TableName.SecretApprovalRequestSecret) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedApprovalSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const findByRequestId = async (requestId: string, tx?: Knex) => { + try { + const doc = await (tx || db)({ + secVerTag: TableName.SecretTag + }) + .from(TableName.SecretApprovalRequestSecret) + .where({ requestId }) + .leftJoin( + TableName.SecretApprovalRequestSecretTag, + `${TableName.SecretApprovalRequestSecret}.id`, + `${TableName.SecretApprovalRequestSecretTag}.secretId` + ) + .leftJoin(TableName.SecretTag, `${TableName.SecretApprovalRequestSecretTag}.tagId`, `${TableName.SecretTag}.id`) + .leftJoin(TableName.Secret, `${TableName.SecretApprovalRequestSecret}.secretId`, `${TableName.Secret}.id`) + .leftJoin( + TableName.SecretVersion, + `${TableName.SecretVersion}.id`, + `${TableName.SecretApprovalRequestSecret}.secretVersion` + ) + .leftJoin( + TableName.SecretVersionTag, + `${TableName.SecretVersionTag}.${TableName.SecretVersion}Id`, + `${TableName.SecretVersion}.id` + ) + .leftJoin( + db.ref(TableName.SecretTag).as("secVerTag"), + `${TableName.SecretVersionTag}.${TableName.SecretTag}Id`, + db.ref("id").withSchema("secVerTag") + ) + .select(selectAllTableCols(TableName.SecretApprovalRequestSecret)) + .select({ + secVerTagId: "secVerTag.id", + secVerTagColor: "secVerTag.color", + secVerTagSlug: "secVerTag.slug", + secVerTagName: "secVerTag.name" + }) + .select( + db.ref("id").withSchema(TableName.SecretTag).as("tagId"), + db.ref("id").withSchema(TableName.SecretApprovalRequestSecretTag).as("tagJnId"), + db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("name").withSchema(TableName.SecretTag).as("tagName") + ) + .select( + db.ref("secretBlindIndex").withSchema(TableName.Secret).as("orgSecBlindIndex"), + db.ref("version").withSchema(TableName.Secret).as("orgSecVersion"), + db.ref("secretKeyIV").withSchema(TableName.Secret).as("orgSecKeyIV"), + db.ref("secretKeyTag").withSchema(TableName.Secret).as("orgSecKeyTag"), + db.ref("secretKeyCiphertext").withSchema(TableName.Secret).as("orgSecKeyCiphertext"), + db.ref("secretValueIV").withSchema(TableName.Secret).as("orgSecValueIV"), + db.ref("secretValueTag").withSchema(TableName.Secret).as("orgSecValueTag"), + db.ref("secretValueCiphertext").withSchema(TableName.Secret).as("orgSecValueCiphertext"), + db.ref("secretCommentIV").withSchema(TableName.Secret).as("orgSecCommentIV"), + db.ref("secretCommentTag").withSchema(TableName.Secret).as("orgSecCommentTag"), + db.ref("secretCommentCiphertext").withSchema(TableName.Secret).as("orgSecCommentCiphertext") + ) + .select( + db.ref("version").withSchema(TableName.SecretVersion).as("secVerVersion"), + db.ref("secretKeyIV").withSchema(TableName.SecretVersion).as("secVerKeyIV"), + db.ref("secretKeyTag").withSchema(TableName.SecretVersion).as("secVerKeyTag"), + db.ref("secretKeyCiphertext").withSchema(TableName.SecretVersion).as("secVerKeyCiphertext"), + db.ref("secretValueIV").withSchema(TableName.SecretVersion).as("secVerValueIV"), + db.ref("secretValueTag").withSchema(TableName.SecretVersion).as("secVerValueTag"), + db.ref("secretValueCiphertext").withSchema(TableName.SecretVersion).as("secVerValueCiphertext"), + db.ref("secretCommentIV").withSchema(TableName.SecretVersion).as("secVerCommentIV"), + db.ref("secretCommentTag").withSchema(TableName.SecretVersion).as("secVerCommentTag"), + db.ref("secretCommentCiphertext").withSchema(TableName.SecretVersion).as("secVerCommentCiphertext") + ); + const formatedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (data) => SecretApprovalRequestsSecretsSchema.omit({ secretVersion: true }).parse(data), + childrenMapper: [ + { + key: "tagJnId", + label: "tags" as const, + mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color }) => ({ + id, + name, + slug, + color + }) + }, + { + key: "secretId", + label: "secret" as const, + mapper: ({ + orgSecKeyIV, + orgSecKeyTag, + orgSecValueIV, + orgSecVersion, + orgSecValueTag, + orgSecCommentIV, + orgSecBlindIndex, + orgSecCommentTag, + orgSecKeyCiphertext, + orgSecValueCiphertext, + orgSecCommentCiphertext, + secretId + }) => + secretId + ? { + id: secretId, + version: orgSecVersion, + secretBlindIndex: orgSecBlindIndex, + secretKeyIV: orgSecKeyIV, + secretKeyTag: orgSecKeyTag, + secretKeyCiphertext: orgSecKeyCiphertext, + secretValueIV: orgSecValueIV, + secretValueTag: orgSecValueTag, + secretValueCiphertext: orgSecValueCiphertext, + secretCommentIV: orgSecCommentIV, + secretCommentTag: orgSecCommentTag, + secretCommentCiphertext: orgSecCommentCiphertext + } + : undefined + }, + { + key: "secretVersion", + label: "secretVersion" as const, + mapper: ({ + secVerCommentIV, + secVerCommentCiphertext, + secVerCommentTag, + secVerValueCiphertext, + secVerKeyIV, + secVerKeyTag, + secVerValueIV, + secretVersion, + secVerValueTag, + secVerKeyCiphertext, + secVerVersion + }) => + secretVersion + ? { + version: secVerVersion, + id: secretVersion, + secretKeyIV: secVerKeyIV, + secretKeyTag: secVerKeyTag, + secretKeyCiphertext: secVerKeyCiphertext, + secretValueIV: secVerValueIV, + secretValueTag: secVerValueTag, + secretValueCiphertext: secVerValueCiphertext, + secretCommentIV: secVerCommentIV, + secretCommentTag: secVerCommentTag, + secretCommentCiphertext: secVerCommentCiphertext + } + : undefined, + childrenMapper: [ + { + key: "secVerTagId", + label: "tags" as const, + mapper: ({ secVerTagId: id, secVerTagName: name, secVerTagSlug: slug, secVerTagColor: color }) => ({ + // eslint-disable-next-line + id, + // eslint-disable-next-line + name, + // eslint-disable-next-line + slug, + // eslint-disable-next-line + color + }) + } + ] + } + ] + }); + return formatedDoc?.map(({ secret, secretVersion, ...el }) => ({ + ...el, + secret: secret?.[0], + secretVersion: secretVersion?.[0] + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindByRequestId" }); + } + }; + return { + ...accessApprovalRequestSecretOrm, + findByRequestId, + bulkUpdateNoVersionIncrement, + insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany + }; +}; From 1ccd74e1a5104064f9ef6195208cec0de55665a2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:43:48 -0700 Subject: [PATCH 152/244] Fix: Remove redundant code --- .../src/components/permissions/PermissionDeniedBanner.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index f9707d634..b3c7a4f53 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,8 +3,6 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button } from "../v2"; - type Props = { containerClassName?: string; className?: string; @@ -34,9 +32,6 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children )}
-
); From 4eabbb3ac53ee612b693fd198ee6926b4404d160 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:49:00 -0700 Subject: [PATCH 153/244] Fix: Added support for request access --- .../SpecificPrivilegeSection.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 6fb97063b..dd09226eb 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -311,6 +311,91 @@ export const SpecificPrivilegeSecretForm = ({ )}`; } return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + }; + + // This is used for requesting access additional privileges, not directly creating a privilege! + const handleRequestAccess = async (data: TSecretPermissionForm) => { + if (!policies) return; + if (!currentWorkspace) { + createNotification({ + type: "error", + text: "No workspace found.", + title: "Error" + }); + return; + } + + if (!data.secretPath) { + createNotification({ + type: "error", + text: "Please select a secret path", + title: "Error" + }); + return; + } + + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await requestAccess.mutateAsync({ + ...data, + ...(data.temporaryAccess.isTemporary && { + temporaryAccessStartTime: data.temporaryAccess.temporaryAccessStartTime, + temporaryAccessEndTime: data.temporaryAccess.temporaryAccessEndTime, + temporaryRange: data.temporaryAccess.temporaryRange, + temporaryMode: "relative" + }), + envSlug: data.environmentSlug, + secretPath: data.secretPath, + projectSlug: currentWorkspace.slug, + projectMembershipId: projectMembership.id, + isTemporary: data.temporaryAccess.isTemporary, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }); + + createNotification({ + type: "success", + text: "Successfully requested access" + }); + privilegeForm.reset(); + if (onClose) onClose(); + }; + + const handleSubmit = async (data: TSecretPermissionForm) => { + if (privilege) { + handleUpdatePrivilege(data); + } else { + handleRequestAccess(data); + } + }; + + const getAccessLabel = (exactTime = false) => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + + if (exactTime) + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + }; return ( From 2c2afbea7a518c3bbb504df169216a0105cc5b99 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:50:16 -0700 Subject: [PATCH 154/244] Fix: Move to project slug --- .../components/AccessPolicyForm.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index 56a4bdef5..6b53e2f1e 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -32,7 +32,7 @@ type Props = { isOpen?: boolean; onToggle: (isOpen: boolean) => void; members?: TWorkspaceUser[]; - workspaceId: string; + projectSlug: string; editValues?: TAccessApprovalPolicy; }; @@ -40,7 +40,7 @@ const formSchema = z .object({ environment: z.string(), name: z.string().optional(), - secretPath: z.string().optional().nullable(), + secretPath: z.string().optional(), approvals: z.number().min(1), approvers: z.string().array().min(1) }) @@ -55,7 +55,7 @@ export const AccessPolicyForm = ({ isOpen, onToggle, members = [], - workspaceId, + projectSlug, editValues }: Props) => { const { @@ -80,10 +80,12 @@ export const AccessPolicyForm = ({ const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); const handleCreatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + try { await createAccessApprovalPolicy({ ...data, - workspaceId + projectSlug }); createNotification({ type: "success", @@ -100,12 +102,14 @@ export const AccessPolicyForm = ({ }; const handleUpdatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; if (!editValues?.id) return; + try { await updateAccessApprovalPolicy({ id: editValues?.id, ...data, - workspaceId + projectSlug }); createNotification({ type: "success", @@ -154,6 +158,7 @@ export const AccessPolicyForm = ({ errorText={error?.message} > + + )} + /> + Date: Wed, 3 Apr 2024 16:50:47 -0700 Subject: [PATCH 155/244] Feat: Request access --- .../components/AccessApprovalPolicyRow.tsx | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx new file mode 100644 index 000000000..8554e928e --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { useUpdateAccessApprovalPolicy } from "@app/hooks/api"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + policy: TAccessApprovalPolicy; + members?: TWorkspaceUser[]; + projectSlug: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const AccessApprovalPolicyRow = ({ + policy, + members = [], + projectSlug, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState([]); + const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); + const { permission } = useProjectPermission(); + + return ( + + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + { + if (!isOpen) { + updateAccessApprovalPolicy( + { + projectSlug, + id: policy.id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + + Select members that are allowed to approve changes + + {members?.map(({ id, user }) => { + const isChecked = selectedApprovers.includes(id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== id) : [...state, id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + {policy.approvals} + +
+ + {(isAllowed) => ( + + + + )} + + + {(isAllowed) => ( + + + + )} + +
+ + + ); +}; From 1afd120e8ee2e01c054e59553be6f04e1832eae4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:50:56 -0700 Subject: [PATCH 156/244] Feat: Request access --- .../components/AccessPolicyForm.tsx | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx new file mode 100644 index 000000000..9cf756c14 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx @@ -0,0 +1,261 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + projectSlug: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z + .object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approvers: z.string().array().min(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + projectSlug, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined + }); + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + try { + await createAccessApprovalPolicy({ + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!editValues?.id) return; + try { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + + + + + Select members that are allowed to approve changes + + {members.map(({ id, user }) => { + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.email} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; From 3bb50b235d223eccc5391b558eb3e3f092ad338a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:53:02 -0700 Subject: [PATCH 157/244] Update generate-schema-types.ts --- backend/scripts/generate-schema-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 28b736152..8c913991f 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env") + path: path.join(__dirname, "../../.env.migration") }); const db = knex({ From 3f0f45e85388fc796ac58ff4572c279d65c6b9b2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:13:23 -0700 Subject: [PATCH 158/244] Update SpecificPrivilegeSection.tsx --- .../MemberRoleForm/SpecificPrivilegeSection.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index dd09226eb..bb330aa4d 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -388,14 +388,13 @@ export const SpecificPrivilegeSecretForm = ({ if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - if (exactTime) + if (exactTime) { return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" )}`; + } return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); - }; - }; return ( From a3b4b650d1b1949bef7d39fae7d150766baa7371 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:41:17 -0700 Subject: [PATCH 159/244] Removed unused parameter --- .../ee/routes/v1/access-approval-request-router.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 696c8b76f..2a6142c88 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -1,9 +1,7 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; -import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -13,16 +11,6 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv method: "POST", schema: { body: z.object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .default(`requested-privilege-${slugify(alphaNumericNanoId(12))}`) - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }), permissions: z.any().array(), isTemporary: z.boolean(), temporaryRange: z.string().optional() From 27447ddc88d2f7f5b9b896f2b5b7b01044a4879a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:43:45 -0700 Subject: [PATCH 160/244] Update licence-fns.ts --- backend/src/ee/services/license/licence-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 492d09ec3..189a3c4e0 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: true, + secretApproval: false, secretRotation: true }); From 923bf0204623d8d193f414a2b4ab5096451a4201 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 3 Apr 2024 20:24:33 -0700 Subject: [PATCH 161/244] style changes --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 235737d4a..6479117b4 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -317,7 +317,7 @@ export const AccessApprovalRequest = ({
)} {!!filteredRequests?.length && - filteredRequests?.map((request) => { + requests?.map((request) => { const details = generateRequestDetails(request); return ( From 121902e51f761330dd63b76fab88f7dc5719d964 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:29:20 -0700 Subject: [PATCH 162/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 6479117b4..235737d4a 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -317,7 +317,7 @@ export const AccessApprovalRequest = ({
)} {!!filteredRequests?.length && - requests?.map((request) => { + filteredRequests?.map((request) => { const details = generateRequestDetails(request); return ( From 350dd97b98e9ee906608962a2f9ff72808f966a3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:33:58 -0700 Subject: [PATCH 163/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 235737d4a..4896c9a3e 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -311,7 +311,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( + {filteredRequests?.length === 0 && ( //
From 5117f5d3c10ce86dc6ddfc6f75092c4b6fa8328e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:34:11 -0700 Subject: [PATCH 164/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 4896c9a3e..235737d4a 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -311,7 +311,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( // + {filteredRequests?.length === 0 && (
From 7d1dff9e5a19ec9e6cd4bf5ce7e14c67801f1e86 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:00:14 -0700 Subject: [PATCH 165/244] Fix: Security vulnurbility making it possible to spoof env & secret path requested. --- backend/src/ee/routes/v1/access-approval-request-router.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 2a6142c88..4b173cfa7 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -16,9 +16,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv temporaryRange: z.string().optional() }), querystring: z.object({ - projectSlug: z.string().trim(), - secretPath: z.string().trim(), - envSlug: z.string().trim() + projectSlug: z.string().trim() }), response: { 200: z.object({ @@ -33,9 +31,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, permissions: req.body.permissions, - envSlug: req.query.envSlug, actorOrgId: req.permission.orgId, - secretPath: req.query.secretPath, projectSlug: req.query.projectSlug, temporaryRange: req.body.temporaryRange, isTemporary: req.body.isTemporary From 038fe3508c5054608b85c5e03871e2e12a1fe51d Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:19 -0700 Subject: [PATCH 166/244] Removed unnessecary types --- .../MemberRoleForm/SpecificPrivilegeSection.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index bb330aa4d..88ff41cf8 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -269,7 +269,6 @@ export const SpecificPrivilegeSecretForm = ({ conditions.secretPath = { $glob: data.secretPath }; } await requestAccess.mutateAsync({ - ...data, ...(data.temporaryAccess.isTemporary && { temporaryRange: data.temporaryAccess.temporaryRange }), @@ -354,10 +353,7 @@ export const SpecificPrivilegeSecretForm = ({ temporaryRange: data.temporaryAccess.temporaryRange, temporaryMode: "relative" }), - envSlug: data.environmentSlug, - secretPath: data.secretPath, projectSlug: currentWorkspace.slug, - projectMembershipId: projectMembership.id, isTemporary: data.temporaryAccess.isTemporary, permissions: actions .filter(({ allowed }) => allowed) @@ -388,7 +384,7 @@ export const SpecificPrivilegeSecretForm = ({ if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - if (exactTime) { + if (exactTime && !policies) { return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" From e8e1d46f0e03cdf9178faffe1a3e5f532cf29781 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:29 -0700 Subject: [PATCH 167/244] Capitalization --- .../AccessApprovalPolicyList/components/AccessPolicyForm.tsx | 2 +- .../AccessApprovalRequest/components/AccessPolicyForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index 6b53e2f1e..dd51ea53d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -135,7 +135,7 @@ export const AccessPolicyForm = ({ return ( - +
- + Date: Thu, 4 Apr 2024 01:03:44 -0700 Subject: [PATCH 168/244] Style: Fix styling --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 235737d4a..b9d55ff01 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -26,7 +26,6 @@ import { import { Badge } from "@app/components/v2/Badge"; import { ProjectPermissionActions, - ProjectPermissionSub, useProjectPermission, useSubscription, useWorkspace From c66476e2b4b2d15805e2d353f4bcc47debdd8748 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 12:44:49 -0700 Subject: [PATCH 169/244] Fix: Multiple approvers acceptance bug --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index b9d55ff01..235737d4a 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -26,6 +26,7 @@ import { import { Badge } from "@app/components/v2/Badge"; import { ProjectPermissionActions, + ProjectPermissionSub, useProjectPermission, useSubscription, useWorkspace From cb505d15258f044c6b27bf6b7f4cf08ec048348a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 29 Mar 2024 16:24:15 +0100 Subject: [PATCH 170/244] Draft --- backend/src/ee/services/license/licence-fns.ts | 2 +- .../src/components/permissions/PermissionDeniedBanner.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..492d09ec3 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true }); diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index b3c7a4f53..f9707d634 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,6 +3,8 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { Button } from "../v2"; + type Props = { containerClassName?: string; className?: string; @@ -32,6 +34,9 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children )}
+ ); From db0b4a5ad13df058774f04292564fdfa3fea0178 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:49:22 -0700 Subject: [PATCH 171/244] Feat: Request access --- backend/scripts/generate-schema-types.ts | 2 +- .../access-approval-policy/access-approval-policy-service.ts | 2 -- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 4 ++++ .../AccessApprovalPolicyList/components/AccessPolicyForm.tsx | 3 --- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991f..28b736152 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env.migration") + path: path.join(__dirname, "../../.env") }); const db = knex({ diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index f14380869..50e1b04b4 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -155,7 +155,6 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { @@ -179,7 +178,6 @@ export const accessApprovalPolicyServiceFactory = ({ ); await verifyApprovers({ - projectId: accessApprovalPolicy.projectId, orgId: actorOrgId, envSlug: accessApprovalPolicy.environment.slug, secretPath: doc.secretPath!, diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 9c273848f..b3e0b62ca 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -65,6 +65,10 @@ export const SecretApprovalPage = () => { + + + + ); diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index dd51ea53d..081556041 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -177,7 +177,6 @@ export const AccessPolicyForm = ({ /> ( @@ -187,8 +186,6 @@ export const AccessPolicyForm = ({ /> ( Date: Wed, 3 Apr 2024 16:37:49 -0700 Subject: [PATCH 172/244] Feat: Request access (new routes) --- backend/src/ee/routes/v1/access-approval-request-router.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 4b173cfa7..104f32665 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -2,10 +2,6 @@ import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; -import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; - -export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "POST", From 7b8af89beefe33533bb469242bfe6e299e4ceb53 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:39:15 -0700 Subject: [PATCH 173/244] Fix: Validate approvers access --- .../access-approval-policy/access-approval-policy-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 50e1b04b4..ed812fba6 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -155,6 +155,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, actorOrgId ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { From 801c0c5ada2204c9d95c4046a3b32aadff94ceb6 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:43:48 -0700 Subject: [PATCH 174/244] Fix: Remove redundant code --- .../src/components/permissions/PermissionDeniedBanner.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index f9707d634..b3c7a4f53 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -3,8 +3,6 @@ import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button } from "../v2"; - type Props = { containerClassName?: string; className?: string; @@ -34,9 +32,6 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children )} - ); From e53d40f0e58cf364ce7c32f78c9497676758a0e0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:51:21 -0700 Subject: [PATCH 175/244] Update SecretApprovalPage.tsx --- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index b3e0b62ca..7b679a029 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -67,7 +67,7 @@ export const SecretApprovalPage = () => { - + From 0580f37c5ec4be0644567040e20ec2e20d5747e7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:53:02 -0700 Subject: [PATCH 176/244] Update generate-schema-types.ts --- backend/scripts/generate-schema-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 28b736152..8c913991f 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -5,7 +5,7 @@ import knex from "knex"; import { writeFileSync } from "fs"; dotenv.config({ - path: path.join(__dirname, "../../.env") + path: path.join(__dirname, "../../.env.migration") }); const db = knex({ From 9198eb5fbaf64e7cf5f7e7023709530c1305a5fa Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 17:43:45 -0700 Subject: [PATCH 177/244] Update licence-fns.ts --- backend/src/ee/services/license/licence-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 492d09ec3..189a3c4e0 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -33,7 +33,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: true, + secretApproval: false, secretRotation: true }); From 1a2d8e96f3da6e88cbbf554a6293a4071349bcb0 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 3 Apr 2024 20:24:33 -0700 Subject: [PATCH 178/244] style changes --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 235737d4a..6479117b4 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -317,7 +317,7 @@ export const AccessApprovalRequest = ({ )} {!!filteredRequests?.length && - filteredRequests?.map((request) => { + requests?.map((request) => { const details = generateRequestDetails(request); return ( From 949d210263cdf2867ee66c7fb6a72965fc2e97b7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:29:20 -0700 Subject: [PATCH 179/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 6479117b4..235737d4a 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -317,7 +317,7 @@ export const AccessApprovalRequest = ({ )} {!!filteredRequests?.length && - requests?.map((request) => { + filteredRequests?.map((request) => { const details = generateRequestDetails(request); return ( From 6e209bf099ddb39cecbff3e08acba0618984b468 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:33:58 -0700 Subject: [PATCH 180/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 235737d4a..4896c9a3e 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -311,7 +311,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( + {filteredRequests?.length === 0 && ( //
From 29b26e3158983138b33b1a79e95f97438c4cf65b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:34:11 -0700 Subject: [PATCH 181/244] Update AccessApprovalRequest.tsx --- .../components/AccessApprovalRequest/AccessApprovalRequest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 4896c9a3e..235737d4a 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -311,7 +311,7 @@ export const AccessApprovalRequest = ({
- {filteredRequests?.length === 0 && ( // + {filteredRequests?.length === 0 && (
From 56c8b4f5e56b9ac6c5812d85a30697d815159035 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 01:03:19 -0700 Subject: [PATCH 182/244] Removed unnessecary types --- .../MemberRoleForm/SpecificPrivilegeSection.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 88ff41cf8..9252f09e6 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -348,10 +348,7 @@ export const SpecificPrivilegeSecretForm = ({ await requestAccess.mutateAsync({ ...data, ...(data.temporaryAccess.isTemporary && { - temporaryAccessStartTime: data.temporaryAccess.temporaryAccessStartTime, - temporaryAccessEndTime: data.temporaryAccess.temporaryAccessEndTime, - temporaryRange: data.temporaryAccess.temporaryRange, - temporaryMode: "relative" + temporaryRange: data.temporaryAccess.temporaryRange }), projectSlug: currentWorkspace.slug, isTemporary: data.temporaryAccess.isTemporary, From ce1ad6f32e1cf16dd856e50095d7774ea4552c79 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 20:14:02 -0700 Subject: [PATCH 183/244] Fix: Rebase errors --- .../v1/access-approval-request-router.ts | 4 + .../access-approval-policy-service.ts | 1 + .../SpecificPrivilegeSection.tsx | 77 ------------------- .../components/AccessPolicyForm.tsx | 2 + 4 files changed, 7 insertions(+), 77 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 104f32665..4b173cfa7 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "POST", diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index ed812fba6..f14380869 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -179,6 +179,7 @@ export const accessApprovalPolicyServiceFactory = ({ ); await verifyApprovers({ + projectId: accessApprovalPolicy.projectId, orgId: actorOrgId, envSlug: accessApprovalPolicy.environment.slug, secretPath: doc.secretPath!, diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 9252f09e6..6fb97063b 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -237,83 +237,6 @@ export const SpecificPrivilegeSecretForm = ({ } }; - // This is used for requesting access additional privileges, not directly creating a privilege! - const handleRequestAccess = async (data: TSecretPermissionForm) => { - if (!policies) return; - if (!currentWorkspace) { - createNotification({ - type: "error", - text: "No workspace found.", - title: "Error" - }); - return; - } - - if (!data.secretPath) { - createNotification({ - type: "error", - text: "Please select a secret path", - title: "Error" - }); - return; - } - - const actions = [ - { action: ProjectPermissionActions.Read, allowed: data.read }, - { action: ProjectPermissionActions.Create, allowed: data.create }, - { action: ProjectPermissionActions.Delete, allowed: data.delete }, - { action: ProjectPermissionActions.Edit, allowed: data.edit } - ]; - const conditions: Record = { environment: data.environmentSlug }; - if (data.secretPath) { - conditions.secretPath = { $glob: data.secretPath }; - } - await requestAccess.mutateAsync({ - ...(data.temporaryAccess.isTemporary && { - temporaryRange: data.temporaryAccess.temporaryRange - }), - projectSlug: currentWorkspace.slug, - isTemporary: data.temporaryAccess.isTemporary, - permissions: actions - .filter(({ allowed }) => allowed) - .map(({ action }) => ({ - action, - subject: [ProjectPermissionSub.Secrets], - conditions - })) - }); - - createNotification({ - type: "success", - text: "Successfully requested access" - }); - privilegeForm.reset(); - if (onClose) onClose(); - }; - - const handleSubmit = async (data: TSecretPermissionForm) => { - if (privilege) { - handleUpdatePrivilege(data); - } else { - handleRequestAccess(data); - } - }; - - const getAccessLabel = (exactTime = false) => { - if (isExpired) return "Access expired"; - if (!temporaryAccessField?.isTemporary) return "Permanent"; - - if (exactTime && !policies) { - return `Until ${format( - new Date(temporaryAccessField.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}`; - } - return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); - }; - - }; - // This is used for requesting access additional privileges, not directly creating a privilege! const handleRequestAccess = async (data: TSecretPermissionForm) => { if (!policies) return; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index 081556041..a07acb584 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -186,6 +186,8 @@ export const AccessPolicyForm = ({ /> ( Date: Thu, 4 Apr 2024 21:00:02 -0700 Subject: [PATCH 184/244] Update SecretApprovalPage.tsx --- frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 7b679a029..9c273848f 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -65,10 +65,6 @@ export const SecretApprovalPage = () => { - - - -
); From 4a0ccbe69e990d038772f68016d96b99931b6ebc Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Thu, 4 Apr 2024 21:12:05 -0700 Subject: [PATCH 185/244] Fixed bugs --- .../AccessApprovalPolicyList/components/AccessPolicyForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx index a07acb584..dd51ea53d 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx @@ -177,6 +177,7 @@ export const AccessPolicyForm = ({ /> ( @@ -186,8 +187,8 @@ export const AccessPolicyForm = ({ /> ( Date: Thu, 4 Apr 2024 22:14:46 -0700 Subject: [PATCH 186/244] Migration improvements --- .../20240330075122_access-approval-policy.ts | 1 + .../20240401173320_access_approval_requests.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index e6d47d864..2fc11bca9 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -35,6 +35,7 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); } diff --git a/backend/src/db/migrations/20240401173320_access_approval_requests.ts b/backend/src/db/migrations/20240401173320_access_approval_requests.ts index 901be9a78..c03287b57 100644 --- a/backend/src/db/migrations/20240401173320_access_approval_requests.ts +++ b/backend/src/db/migrations/20240401173320_access_approval_requests.ts @@ -43,9 +43,16 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { + const reviewerTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer); + const requestTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequest); + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer); await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); + if (reviewerTableExists) { + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); + } + if (requestTableExists) { + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); + } } From fb6c4acf3199b48c5c3c60097a0db0e654ee9311 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 5 Apr 2024 18:59:01 -0700 Subject: [PATCH 187/244] Delete access-approval-request-secret-dal.ts --- .../access-approval-request-secret-dal.ts | 230 ------------------ 1 file changed, 230 deletions(-) delete mode 100644 backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts deleted file mode 100644 index d458d58ff..000000000 --- a/backend/src/ee/services/access-approval-request/access-approval-request-secret-dal.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { Knex } from "knex"; - -import { TDbClient } from "@app/db"; -import { - SecretApprovalRequestsSecretsSchema, - TableName, - TSecretApprovalRequestsSecrets, - TSecretTags -} from "@app/db/schemas"; -import { BadRequestError, DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; - -export type TAccessApprovalRequestSecretDALFactory = ReturnType; - -export const accessApprovalRequestSecretDALFactory = (db: TDbClient) => { - const accessApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret); - const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag); - - const bulkUpdateNoVersionIncrement = async (data: TSecretApprovalRequestsSecrets[], tx?: Knex) => { - try { - const existingApprovalSecrets = await accessApprovalRequestSecretOrm.find( - { - $in: { - id: data.map((el) => el.id) - } - }, - { tx } - ); - - if (existingApprovalSecrets.length !== data.length) { - throw new BadRequestError({ message: "Some of the secret approvals do not exist" }); - } - - if (data.length === 0) return []; - - const updatedApprovalSecrets = await (tx || db)(TableName.SecretApprovalRequestSecret) - .insert(data) - .onConflict("id") // this will cause a conflict then merge the data - .merge() // Merge the data with the existing data - .returning("*"); - - return updatedApprovalSecrets; - } catch (error) { - throw new DatabaseError({ error, name: "bulk update secret" }); - } - }; - - const findByRequestId = async (requestId: string, tx?: Knex) => { - try { - const doc = await (tx || db)({ - secVerTag: TableName.SecretTag - }) - .from(TableName.SecretApprovalRequestSecret) - .where({ requestId }) - .leftJoin( - TableName.SecretApprovalRequestSecretTag, - `${TableName.SecretApprovalRequestSecret}.id`, - `${TableName.SecretApprovalRequestSecretTag}.secretId` - ) - .leftJoin(TableName.SecretTag, `${TableName.SecretApprovalRequestSecretTag}.tagId`, `${TableName.SecretTag}.id`) - .leftJoin(TableName.Secret, `${TableName.SecretApprovalRequestSecret}.secretId`, `${TableName.Secret}.id`) - .leftJoin( - TableName.SecretVersion, - `${TableName.SecretVersion}.id`, - `${TableName.SecretApprovalRequestSecret}.secretVersion` - ) - .leftJoin( - TableName.SecretVersionTag, - `${TableName.SecretVersionTag}.${TableName.SecretVersion}Id`, - `${TableName.SecretVersion}.id` - ) - .leftJoin( - db.ref(TableName.SecretTag).as("secVerTag"), - `${TableName.SecretVersionTag}.${TableName.SecretTag}Id`, - db.ref("id").withSchema("secVerTag") - ) - .select(selectAllTableCols(TableName.SecretApprovalRequestSecret)) - .select({ - secVerTagId: "secVerTag.id", - secVerTagColor: "secVerTag.color", - secVerTagSlug: "secVerTag.slug", - secVerTagName: "secVerTag.name" - }) - .select( - db.ref("id").withSchema(TableName.SecretTag).as("tagId"), - db.ref("id").withSchema(TableName.SecretApprovalRequestSecretTag).as("tagJnId"), - db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), - db.ref("name").withSchema(TableName.SecretTag).as("tagName") - ) - .select( - db.ref("secretBlindIndex").withSchema(TableName.Secret).as("orgSecBlindIndex"), - db.ref("version").withSchema(TableName.Secret).as("orgSecVersion"), - db.ref("secretKeyIV").withSchema(TableName.Secret).as("orgSecKeyIV"), - db.ref("secretKeyTag").withSchema(TableName.Secret).as("orgSecKeyTag"), - db.ref("secretKeyCiphertext").withSchema(TableName.Secret).as("orgSecKeyCiphertext"), - db.ref("secretValueIV").withSchema(TableName.Secret).as("orgSecValueIV"), - db.ref("secretValueTag").withSchema(TableName.Secret).as("orgSecValueTag"), - db.ref("secretValueCiphertext").withSchema(TableName.Secret).as("orgSecValueCiphertext"), - db.ref("secretCommentIV").withSchema(TableName.Secret).as("orgSecCommentIV"), - db.ref("secretCommentTag").withSchema(TableName.Secret).as("orgSecCommentTag"), - db.ref("secretCommentCiphertext").withSchema(TableName.Secret).as("orgSecCommentCiphertext") - ) - .select( - db.ref("version").withSchema(TableName.SecretVersion).as("secVerVersion"), - db.ref("secretKeyIV").withSchema(TableName.SecretVersion).as("secVerKeyIV"), - db.ref("secretKeyTag").withSchema(TableName.SecretVersion).as("secVerKeyTag"), - db.ref("secretKeyCiphertext").withSchema(TableName.SecretVersion).as("secVerKeyCiphertext"), - db.ref("secretValueIV").withSchema(TableName.SecretVersion).as("secVerValueIV"), - db.ref("secretValueTag").withSchema(TableName.SecretVersion).as("secVerValueTag"), - db.ref("secretValueCiphertext").withSchema(TableName.SecretVersion).as("secVerValueCiphertext"), - db.ref("secretCommentIV").withSchema(TableName.SecretVersion).as("secVerCommentIV"), - db.ref("secretCommentTag").withSchema(TableName.SecretVersion).as("secVerCommentTag"), - db.ref("secretCommentCiphertext").withSchema(TableName.SecretVersion).as("secVerCommentCiphertext") - ); - const formatedDoc = sqlNestRelationships({ - data: doc, - key: "id", - parentMapper: (data) => SecretApprovalRequestsSecretsSchema.omit({ secretVersion: true }).parse(data), - childrenMapper: [ - { - key: "tagJnId", - label: "tags" as const, - mapper: ({ tagId: id, tagName: name, tagSlug: slug, tagColor: color }) => ({ - id, - name, - slug, - color - }) - }, - { - key: "secretId", - label: "secret" as const, - mapper: ({ - orgSecKeyIV, - orgSecKeyTag, - orgSecValueIV, - orgSecVersion, - orgSecValueTag, - orgSecCommentIV, - orgSecBlindIndex, - orgSecCommentTag, - orgSecKeyCiphertext, - orgSecValueCiphertext, - orgSecCommentCiphertext, - secretId - }) => - secretId - ? { - id: secretId, - version: orgSecVersion, - secretBlindIndex: orgSecBlindIndex, - secretKeyIV: orgSecKeyIV, - secretKeyTag: orgSecKeyTag, - secretKeyCiphertext: orgSecKeyCiphertext, - secretValueIV: orgSecValueIV, - secretValueTag: orgSecValueTag, - secretValueCiphertext: orgSecValueCiphertext, - secretCommentIV: orgSecCommentIV, - secretCommentTag: orgSecCommentTag, - secretCommentCiphertext: orgSecCommentCiphertext - } - : undefined - }, - { - key: "secretVersion", - label: "secretVersion" as const, - mapper: ({ - secVerCommentIV, - secVerCommentCiphertext, - secVerCommentTag, - secVerValueCiphertext, - secVerKeyIV, - secVerKeyTag, - secVerValueIV, - secretVersion, - secVerValueTag, - secVerKeyCiphertext, - secVerVersion - }) => - secretVersion - ? { - version: secVerVersion, - id: secretVersion, - secretKeyIV: secVerKeyIV, - secretKeyTag: secVerKeyTag, - secretKeyCiphertext: secVerKeyCiphertext, - secretValueIV: secVerValueIV, - secretValueTag: secVerValueTag, - secretValueCiphertext: secVerValueCiphertext, - secretCommentIV: secVerCommentIV, - secretCommentTag: secVerCommentTag, - secretCommentCiphertext: secVerCommentCiphertext - } - : undefined, - childrenMapper: [ - { - key: "secVerTagId", - label: "tags" as const, - mapper: ({ secVerTagId: id, secVerTagName: name, secVerTagSlug: slug, secVerTagColor: color }) => ({ - // eslint-disable-next-line - id, - // eslint-disable-next-line - name, - // eslint-disable-next-line - slug, - // eslint-disable-next-line - color - }) - } - ] - } - ] - }); - return formatedDoc?.map(({ secret, secretVersion, ...el }) => ({ - ...el, - secret: secret?.[0], - secretVersion: secretVersion?.[0] - })); - } catch (error) { - throw new DatabaseError({ error, name: "FindByRequestId" }); - } - }; - return { - ...accessApprovalRequestSecretOrm, - findByRequestId, - bulkUpdateNoVersionIncrement, - insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany - }; -}; From af0d31db2cfe32bfc6fd26f07bcd8ccbc59228b9 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:18:02 -0700 Subject: [PATCH 188/244] Fix: Improved migrations --- .../20240401173320_access_approval_requests.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/backend/src/db/migrations/20240401173320_access_approval_requests.ts b/backend/src/db/migrations/20240401173320_access_approval_requests.ts index c03287b57..901be9a78 100644 --- a/backend/src/db/migrations/20240401173320_access_approval_requests.ts +++ b/backend/src/db/migrations/20240401173320_access_approval_requests.ts @@ -43,16 +43,9 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - const reviewerTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer); - const requestTableExists = await knex.schema.hasTable(TableName.AccessApprovalRequest); - await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer); await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest); - if (reviewerTableExists) { - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); - } - if (requestTableExists) { - await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); - } + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); } From a579598b6dc7ea9d75348cf1797caba64088159b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:19:43 -0700 Subject: [PATCH 189/244] Chore: Moved verifyApprovers --- .../access-approval-policy/access-approval-policy-service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index f14380869..51a51abb5 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -54,7 +54,6 @@ export const accessApprovalPolicyServiceFactory = ({ if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); - if (!secretPath) throw new BadRequestError({ message: "Secret path is required" }); const { permission } = await permissionService.getProjectPermission( actor, From 9c2591f3a61108b1cd57fe5a8b3bcd64842bfa3e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:20:18 -0700 Subject: [PATCH 190/244] Fix: Moved Divider to v2 --- frontend/src/components/basic/Divider.tsx | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 frontend/src/components/basic/Divider.tsx diff --git a/frontend/src/components/basic/Divider.tsx b/frontend/src/components/basic/Divider.tsx deleted file mode 100644 index f1a570d7b..000000000 --- a/frontend/src/components/basic/Divider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { twMerge } from "tailwind-merge"; - -interface IProps { - className?: string; -} - -const Divider = ({ className }: IProps): JSX.Element => { - return ( -
- - ); -}; - -export default Divider; From 95c1fff7d3a77e9e7af09f86a31e3aab12ae0d98 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:21:57 -0700 Subject: [PATCH 191/244] Chore: Remove unused files --- .../components/AccessPolicyForm.tsx | 266 ------------------ .../components/AccessApprovalPolicyRow.tsx | 146 ---------- .../components/AccessPolicyForm.tsx | 261 ----------------- 3 files changed, 673 deletions(-) delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx deleted file mode 100644 index dd51ea53d..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyForm.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - useCreateAccessApprovalPolicy, - useUpdateAccessApprovalPolicy -} from "@app/hooks/api/accessApproval"; -import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - isOpen?: boolean; - onToggle: (isOpen: boolean) => void; - members?: TWorkspaceUser[]; - projectSlug: string; - editValues?: TAccessApprovalPolicy; -}; - -const formSchema = z - .object({ - environment: z.string(), - name: z.string().optional(), - secretPath: z.string().optional(), - approvals: z.number().min(1), - approvers: z.string().array().min(1) - }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." - }); - -type TFormSchema = z.infer; - -export const AccessPolicyForm = ({ - isOpen, - onToggle, - members = [], - projectSlug, - editValues -}: Props) => { - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined - }); - const { currentWorkspace } = useWorkspace(); - - const environments = currentWorkspace?.environments || []; - useEffect(() => { - if (!isOpen) reset({}); - }, [isOpen]); - - const isEditMode = Boolean(editValues); - - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); - const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); - - const handleCreatePolicy = async (data: TFormSchema) => { - if (!projectSlug) return; - - try { - await createAccessApprovalPolicy({ - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully created policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create policy" - }); - } - }; - - const handleUpdatePolicy = async (data: TFormSchema) => { - if (!projectSlug) return; - if (!editValues?.id) return; - - try { - await updateAccessApprovalPolicy({ - id: editValues?.id, - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully updated policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "failed to update policy" - }); - } - }; - - const handleFormSubmit = async (data: TFormSchema) => { - if (isEditMode) { - await handleUpdatePolicy(data); - } else { - await handleCreatePolicy(data); - } - }; - - return ( - - - - ( - - - - )} - /> - ( - - - - )} - /> - - ( - - - - )} - /> - - ( - - - - - - - - Select members that are allowed to approve changes - - {members.map(({ id, user }) => { - const isChecked = value?.includes(id); - return ( - { - evt.preventDefault(); - onChange( - isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.email} - - ); - })} - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> -
- - -
- -
-
- ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx deleted file mode 100644 index 8554e928e..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessApprovalPolicyRow.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useState } from "react"; -import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - IconButton, - Input, - Td, - Tr -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; -import { useUpdateAccessApprovalPolicy } from "@app/hooks/api"; -import { TAccessApprovalPolicy } from "@app/hooks/api/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - policy: TAccessApprovalPolicy; - members?: TWorkspaceUser[]; - projectSlug: string; - onEdit: () => void; - onDelete: () => void; -}; - -export const AccessApprovalPolicyRow = ({ - policy, - members = [], - projectSlug, - onEdit, - onDelete -}: Props) => { - const [selectedApprovers, setSelectedApprovers] = useState([]); - const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); - const { permission } = useProjectPermission(); - - return ( - - {policy.name} - {policy.environment.slug} - {policy.secretPath || "*"} - - { - if (!isOpen) { - updateAccessApprovalPolicy( - { - projectSlug, - id: policy.id, - approvers: selectedApprovers - }, - { - onSettled: () => { - setSelectedApprovers([]); - } - } - ); - } else { - setSelectedApprovers(policy.approvers); - } - }} - > - - - - - - Select members that are allowed to approve changes - - {members?.map(({ id, user }) => { - const isChecked = selectedApprovers.includes(id); - return ( - { - evt.preventDefault(); - setSelectedApprovers((state) => - isChecked ? state.filter((el) => el !== id) : [...state, id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.email} - - ); - })} - - - - {policy.approvals} - -
- - {(isAllowed) => ( - - - - )} - - - {(isAllowed) => ( - - - - )} - -
- - - ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx deleted file mode 100644 index 7e88da100..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/AccessPolicyForm.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - useCreateAccessApprovalPolicy, - useUpdateAccessApprovalPolicy -} from "@app/hooks/api/accessApproval"; -import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - isOpen?: boolean; - onToggle: (isOpen: boolean) => void; - members?: TWorkspaceUser[]; - projectSlug: string; - editValues?: TAccessApprovalPolicy; -}; - -const formSchema = z - .object({ - environment: z.string(), - name: z.string().optional(), - secretPath: z.string().optional(), - approvals: z.number().min(1), - approvers: z.string().array().min(1) - }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." - }); - -type TFormSchema = z.infer; - -export const AccessPolicyForm = ({ - isOpen, - onToggle, - members = [], - projectSlug, - editValues -}: Props) => { - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined - }); - const { currentWorkspace } = useWorkspace(); - - const environments = currentWorkspace?.environments || []; - useEffect(() => { - if (!isOpen) reset({}); - }, [isOpen]); - - const isEditMode = Boolean(editValues); - - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); - const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); - - const handleCreatePolicy = async (data: TFormSchema) => { - try { - await createAccessApprovalPolicy({ - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully created policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create policy" - }); - } - }; - - const handleUpdatePolicy = async (data: TFormSchema) => { - if (!editValues?.id) return; - try { - await updateAccessApprovalPolicy({ - id: editValues?.id, - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully updated policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "failed to update policy" - }); - } - }; - - const handleFormSubmit = async (data: TFormSchema) => { - if (isEditMode) { - await handleUpdatePolicy(data); - } else { - await handleCreatePolicy(data); - } - }; - - return ( - - -
- ( - - - - )} - /> - ( - - - - )} - /> - - ( - - - - )} - /> - - ( - - - - - - - - Select members that are allowed to approve changes - - {members.map(({ id, user }) => { - const isChecked = value?.includes(id); - return ( - { - evt.preventDefault(); - onChange( - isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.email} - - ); - })} - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> -
- - -
- -
-
- ); -}; From 9a585ad9309ee89d12f83c55c2e965e732ef6db1 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 6 May 2024 11:24:04 +0200 Subject: [PATCH 192/244] Fix: Rebase error --- .../src/db/migrations/20240330075122_access-approval-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240330075122_access-approval-policy.ts index 2fc11bca9..feeecd25b 100644 --- a/backend/src/db/migrations/20240330075122_access-approval-policy.ts +++ b/backend/src/db/migrations/20240330075122_access-approval-policy.ts @@ -12,7 +12,6 @@ export async function up(knex: Knex): Promise { t.string("secretPath"); t.uuid("envId").notNullable(); - t.string("secretPath"); t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); t.timestamps(true, true, true); }); @@ -24,6 +23,7 @@ export async function up(knex: Knex): Promise { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("approverId").notNullable(); t.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.uuid("policyId").notNullable(); t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); t.timestamps(true, true, true); From c08fcc6f5e5dc35ee4acb8ca68855b16a1175719 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 00:12:55 +0800 Subject: [PATCH 193/244] adjustment: finalized notification text --- .../components/SelectionPanel/SelectionPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 4f331e989..89fcef58c 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -114,7 +114,7 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa handlePopUpClose("bulkDeleteEntries"); createNotification({ type: "info", - text: "No changes have been made. Ensure that you have sufficient access." + text: "You don't have access to delete selected items" }); } else if (areEntriesDeleted) { handlePopUpClose("bulkDeleteEntries"); From 306cf8733ee718be11252bf4437e9915062278e5 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 7 May 2024 16:21:40 +0000 Subject: [PATCH 194/244] chore: renamed new migration files to latest timestamp (gh-action) --- ...pproval-policy.ts => 20240507162140_access-approval-policy.ts} | 0 ...01173320_access_approval_requests.ts => 20240507162141_access} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240330075122_access-approval-policy.ts => 20240507162140_access-approval-policy.ts} (100%) rename backend/src/db/migrations/{20240401173320_access_approval_requests.ts => 20240507162141_access} (100%) diff --git a/backend/src/db/migrations/20240330075122_access-approval-policy.ts b/backend/src/db/migrations/20240507162140_access-approval-policy.ts similarity index 100% rename from backend/src/db/migrations/20240330075122_access-approval-policy.ts rename to backend/src/db/migrations/20240507162140_access-approval-policy.ts diff --git a/backend/src/db/migrations/20240401173320_access_approval_requests.ts b/backend/src/db/migrations/20240507162141_access similarity index 100% rename from backend/src/db/migrations/20240401173320_access_approval_requests.ts rename to backend/src/db/migrations/20240507162141_access From 8bab14a67286e37a48e8309a4fc711137132142a Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 00:43:14 +0800 Subject: [PATCH 195/244] misc: added handling of input focus --- .../components/v2/SecretPathInput/SecretPathInput.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index ed97ad8dd..270ebf351 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -31,6 +31,7 @@ export const SecretPathInput = ({ const [inputValue, setInputValue] = useState(propValue ?? ""); const [secretPath, setSecretPath] = useState("/"); const [suggestions, setSuggestions] = useState([]); + const [isInputFocused, setIsInputFocus] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); const debouncedInputValue = useDebounce(inputValue, 200); @@ -108,7 +109,7 @@ export const SecretPathInput = ({ return ( 0 && inputValue.length > 1} + open={suggestions.length > 0 && isInputFocused} onOpenChange={() => { setHighlightedIndex(-1); }} @@ -119,6 +120,8 @@ export const SecretPathInput = ({ type="text" autoComplete="off" onKeyDown={handleKeyDown} + onFocus={() => setIsInputFocus(true)} + onBlur={() => setIsInputFocus(false)} value={inputValue} onChange={handleInputChange} className={containerClassName} @@ -150,8 +153,9 @@ export const SecretPathInput = ({ key={`secret-reference-secret-${i + 1}`} >
From 3ce91b8a202fed7ce1114ade5e67e83b3c661cf3 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 7 May 2024 22:25:36 +0530 Subject: [PATCH 196/244] doc: fixed typo in api privilege documentation --- backend/src/lib/api-docs/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 10006b9ef..61df46d92 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -465,7 +465,7 @@ export const SECRET_TAGS = { export const IDENTITY_ADDITIONAL_PRIVILEGE = { CREATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the identity to create.", slug: "The slug of the privilege to create.", permissions: `The permission object for the privilege. - Read secrets From f957b9d970024896a41791269848e40edc5fe6b4 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 01:03:41 +0800 Subject: [PATCH 197/244] misc: migrated to react-state --- .../pages/project/[id]/secrets/overview.tsx | 5 +- .../SecretOverviewPage.store.tsx | 78 ------------------- .../SecretOverviewPage/SecretOverviewPage.tsx | 70 +++++++++++++---- .../SelectionPanel/SelectionPanel.tsx | 20 ++++- 4 files changed, 74 insertions(+), 99 deletions(-) delete mode 100644 frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx diff --git a/frontend/src/pages/project/[id]/secrets/overview.tsx b/frontend/src/pages/project/[id]/secrets/overview.tsx index eaf915120..69e2bc196 100644 --- a/frontend/src/pages/project/[id]/secrets/overview.tsx +++ b/frontend/src/pages/project/[id]/secrets/overview.tsx @@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next"; import Head from "next/head"; import { SecretOverviewPage } from "@app/views/SecretOverviewPage"; -import { StoreProvider } from "@app/views/SecretOverviewPage/SecretOverviewPage.store"; const Dashboard = () => { const { t } = useTranslation(); @@ -17,9 +16,7 @@ const Dashboard = () => {
- - - +
); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx deleted file mode 100644 index db544c7e8..000000000 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { createContext, ReactNode, useContext, useEffect, useRef } from "react"; -import { useRouter } from "next/router"; -import { createStore, StateCreator, StoreApi, useStore } from "zustand"; - -export enum EntryType { - FOLDER = "folder", - SECRET = "secret" -} - -type SelectedEntriesState = { - selectedEntries: { - [EntryType.FOLDER]: Record; - [EntryType.SECRET]: Record; - }; - action: { - toggle: (type: EntryType, key: string) => void; - reset: () => void; - }; -}; - -const createSelectedSecretStore: StateCreator = (set) => ({ - selectedEntries: { - [EntryType.FOLDER]: {}, - [EntryType.SECRET]: {} - }, - action: { - toggle: (type: EntryType, key: string) => - set((state) => { - const isChecked = Boolean(state.selectedEntries[type]?.[key]); - const newChecks = { ...state.selectedEntries }; - // remove selection if its present else add it - if (isChecked) delete newChecks[type][key]; - else newChecks[type][key] = true; - return { selectedEntries: newChecks }; - }), - reset: () => - set({ - selectedEntries: { - [EntryType.FOLDER]: {}, - [EntryType.SECRET]: {} - } - }) - } -}); - -const StoreContext = createContext | null>(null); -export const StoreProvider = ({ children }: { children: ReactNode }) => { - const storeRef = useRef>(); - const router = useRouter(); - if (!storeRef.current) { - storeRef.current = createStore((...a) => ({ - ...createSelectedSecretStore(...a) - })); - } - - useEffect(() => { - const onRouteChangeStart = () => { - const state = storeRef.current?.getState(); - state?.action.reset(); - }; - - router.events.on("routeChangeStart", onRouteChangeStart); - return () => { - router.events.off("routeChangeStart", onRouteChangeStart); - }; - }, []); - - return {children}; -}; - -const useStoreContext = (selector: (state: SelectedEntriesState) => T): T => { - const ctx = useContext(StoreContext); - if (!ctx) throw new Error("Missing context provider"); - return useStore(ctx, selector); -}; - -export const useSelectedEntries = () => useStoreContext((state) => state.selectedEntries); -export const useSelectedEntryActions = () => useStoreContext((state) => state.action); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index cd00af883..fab7ebecf 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -71,7 +71,11 @@ import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynam import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; -import { EntryType, useSelectedEntries, useSelectedEntryActions } from "./SecretOverviewPage.store"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} export const SecretOverviewPage = () => { const { t } = useTranslation(); @@ -84,15 +88,6 @@ export const SecretOverviewPage = () => { const [expandableTableWidth, setExpandableTableWidth] = useState(0); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); - useEffect(() => { - const handleParentTableWidthResize = () => { - setExpandableTableWidth(parentTableRef.current?.clientWidth || 0); - }; - - window.addEventListener("resize", handleParentTableWidthResize); - return () => window.removeEventListener("resize", handleParentTableWidthResize); - }, []); - useEffect(() => { if (parentTableRef.current) { setExpandableTableWidth(parentTableRef.current.clientWidth); @@ -107,8 +102,55 @@ export const SecretOverviewPage = () => { const [searchFilter, setSearchFilter] = useState(""); const secretPath = (router.query?.secretPath as string) || "/"; - const selectedEntries = useSelectedEntries(); - const { toggle: toggleSelectedEntry } = useSelectedEntryActions(); + const [selectedEntries, setSelectedEntries] = useState<{ + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }>({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + + const toggleSelectedEntry = useCallback( + (type: EntryType, key: string) => { + const isChecked = Boolean(selectedEntries[type]?.[key]); + const newChecks = { ...selectedEntries }; + + // remove selection if its present else add it + if (isChecked) { + delete newChecks[type][key]; + } else { + newChecks[type][key] = true; + } + + setSelectedEntries(newChecks); + }, + [selectedEntries] + ); + + const resetSelectedEntries = useCallback(() => { + setSelectedEntries({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + }, []); + + useEffect(() => { + const handleParentTableWidthResize = () => { + setExpandableTableWidth(parentTableRef.current?.clientWidth || 0); + }; + + const onRouteChangeStart = () => { + resetSelectedEntries(); + }; + + router.events.on("routeChangeStart", onRouteChangeStart); + + window.addEventListener("resize", handleParentTableWidthResize); + return () => { + window.removeEventListener("resize", handleParentTableWidthResize); + router.events.off("routeChangeStart", onRouteChangeStart); + }; + }, []); useEffect(() => { if (!isWorkspaceLoading && !workspaceId && router.isReady) { @@ -553,6 +595,8 @@ export const SecretOverviewPage = () => { secretPath={secretPath} getSecretByKey={getSecretByKey} getFolderByNameAndEnv={getFolderByNameAndEnv} + selectedEntries={selectedEntries} + resetSelectedEntries={resetSelectedEntries} />
diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 89fcef58c..6a2fe9bf6 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -15,23 +15,35 @@ import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types"; -import { useSelectedEntries, useSelectedEntryActions } from "../../SecretOverviewPage.store"; +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} type Props = { secretPath: string; getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined; + resetSelectedEntries: () => void; + selectedEntries: { + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }; }; -export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPath }: Props) => { +export const SelectionPanel = ({ + getFolderByNameAndEnv, + getSecretByKey, + secretPath, + resetSelectedEntries, + selectedEntries +}: Props) => { const { permission } = useProjectPermission(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "bulkDeleteEntries" ] as const); - const selectedEntries = useSelectedEntries(); - const { reset: resetSelectedEntries } = useSelectedEntryActions(); const selectedCount = Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length; From 3638645b8a079e0c08d66486cd30f3f1ee52049f Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:15:15 -0400 Subject: [PATCH 198/244] get closed by user --- ...update-be-new-migration-latest-timestamp.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/update-be-new-migration-latest-timestamp.yml b/.github/workflows/update-be-new-migration-latest-timestamp.yml index 160828473..f35c33142 100644 --- a/.github/workflows/update-be-new-migration-latest-timestamp.yml +++ b/.github/workflows/update-be-new-migration-latest-timestamp.yml @@ -38,6 +38,23 @@ jobs: rm added_files.txt git commit -m "chore: renamed new migration files to latest timestamp (gh-action)" + - name: Get the username of the person who closed the PR + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + REPO_NAME=${{ github.repository }} + GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} + + # Use GitHub API to fetch PR data + PR_DATA=$(curl \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/$REPO_NAME/pulls/$PR_NUMBER) + + # Extract the username of the person who closed the PR + CLOSED_BY=$(echo $PR_DATA | jq -r .closed_by.login) + + echo "Pull Request #$PR_NUMBER was closed by $CLOSED_BY" + - name: Create Pull Request if: env.SKIP_RENAME != 'true' uses: peter-evans/create-pull-request@v6 From f5f0bf3c833b3df2d11674579618f790b4a1d5ee Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:16:42 -0400 Subject: [PATCH 199/244] Create 20240507162180_test --- backend/src/db/migrations/20240507162180_test | 1 + 1 file changed, 1 insertion(+) create mode 100644 backend/src/db/migrations/20240507162180_test diff --git a/backend/src/db/migrations/20240507162180_test b/backend/src/db/migrations/20240507162180_test new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/backend/src/db/migrations/20240507162180_test @@ -0,0 +1 @@ + From ba238a8f3b4d41a8bd0fc4780b30dcc567fa7191 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:25:10 -0400 Subject: [PATCH 200/244] get pr details by pr number --- ...date-be-new-migration-latest-timestamp.yml | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/.github/workflows/update-be-new-migration-latest-timestamp.yml b/.github/workflows/update-be-new-migration-latest-timestamp.yml index f35c33142..d193c8722 100644 --- a/.github/workflows/update-be-new-migration-latest-timestamp.yml +++ b/.github/workflows/update-be-new-migration-latest-timestamp.yml @@ -39,22 +39,13 @@ jobs: git commit -m "chore: renamed new migration files to latest timestamp (gh-action)" - name: Get the username of the person who closed the PR - run: | + run: | PR_NUMBER=${{ github.event.pull_request.number }} - REPO_NAME=${{ github.repository }} - GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} - - # Use GitHub API to fetch PR data - PR_DATA=$(curl \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/$REPO_NAME/pulls/$PR_NUMBER) + PR_CLOSER=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | jq -r '.closed_by.login') + echo "PR Number: $PR_NUMBER" + echo "PR Closer: $PR_CLOSER" + echo "pr_closer=$PR_CLOSER" >> $GITHUB_OUTPUT - # Extract the username of the person who closed the PR - CLOSED_BY=$(echo $PR_DATA | jq -r .closed_by.login) - - echo "Pull Request #$PR_NUMBER was closed by $CLOSED_BY" - - name: Create Pull Request if: env.SKIP_RENAME != 'true' uses: peter-evans/create-pull-request@v6 From c8433f39ed48adb4f16823e6337d5e5b79f06b30 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:26:42 -0400 Subject: [PATCH 201/244] Delete backend/src/db/migrations/20240507162180_test --- backend/src/db/migrations/20240507162180_test | 1 - 1 file changed, 1 deletion(-) delete mode 100644 backend/src/db/migrations/20240507162180_test diff --git a/backend/src/db/migrations/20240507162180_test b/backend/src/db/migrations/20240507162180_test deleted file mode 100644 index 8b1378917..000000000 --- a/backend/src/db/migrations/20240507162180_test +++ /dev/null @@ -1 +0,0 @@ - From 7fdaa1543adfaafa2698881901d17720b5c02d5a Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:26:52 -0400 Subject: [PATCH 202/244] Create 20240507162180_test --- backend/src/db/migrations/20240507162180_test | 1 + 1 file changed, 1 insertion(+) create mode 100644 backend/src/db/migrations/20240507162180_test diff --git a/backend/src/db/migrations/20240507162180_test b/backend/src/db/migrations/20240507162180_test new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/backend/src/db/migrations/20240507162180_test @@ -0,0 +1 @@ + From 02c158b4ed2889252265a6f11f7909bb5bda030a Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:47:25 -0400 Subject: [PATCH 203/244] Delete backend/src/db/migrations/20240507162180_test --- backend/src/db/migrations/20240507162180_test | 1 - 1 file changed, 1 deletion(-) delete mode 100644 backend/src/db/migrations/20240507162180_test diff --git a/backend/src/db/migrations/20240507162180_test b/backend/src/db/migrations/20240507162180_test deleted file mode 100644 index 8b1378917..000000000 --- a/backend/src/db/migrations/20240507162180_test +++ /dev/null @@ -1 +0,0 @@ - From c87620109b69f8c297024335aa420667ca30d82c Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:58:10 -0400 Subject: [PATCH 204/244] Rename 20240507162141_access to 20240507162141_access.ts --- .../{20240507162141_access => 20240507162141_access.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240507162141_access => 20240507162141_access.ts} (100%) diff --git a/backend/src/db/migrations/20240507162141_access b/backend/src/db/migrations/20240507162141_access.ts similarity index 100% rename from backend/src/db/migrations/20240507162141_access rename to backend/src/db/migrations/20240507162141_access.ts From 80f7ff1ea898d6d4659ee081bddcd5d805d5eca9 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 14:09:38 -0400 Subject: [PATCH 205/244] Create 20240507162149_test.ts --- backend/src/db/migrations/20240507162149_test.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 backend/src/db/migrations/20240507162149_test.ts diff --git a/backend/src/db/migrations/20240507162149_test.ts b/backend/src/db/migrations/20240507162149_test.ts new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/backend/src/db/migrations/20240507162149_test.ts @@ -0,0 +1 @@ + From 183bde55ca705120707b501b8dd21e3aa7ed0e33 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 13:43:29 -0400 Subject: [PATCH 206/244] correctly fetch merged by user login --- .../update-be-new-migration-latest-timestamp.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/update-be-new-migration-latest-timestamp.yml b/.github/workflows/update-be-new-migration-latest-timestamp.yml index d193c8722..684c78654 100644 --- a/.github/workflows/update-be-new-migration-latest-timestamp.yml +++ b/.github/workflows/update-be-new-migration-latest-timestamp.yml @@ -38,13 +38,15 @@ jobs: rm added_files.txt git commit -m "chore: renamed new migration files to latest timestamp (gh-action)" - - name: Get the username of the person who closed the PR - run: | + - name: Get PR details + id: pr_details + run: | PR_NUMBER=${{ github.event.pull_request.number }} - PR_CLOSER=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | jq -r '.closed_by.login') + PR_MERGER=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | jq -r '.merged_by.login') + echo "PR Number: $PR_NUMBER" - echo "PR Closer: $PR_CLOSER" - echo "pr_closer=$PR_CLOSER" >> $GITHUB_OUTPUT + echo "PR Merger: $PR_MERGER" + echo "pr_merger=$PR_MERGER" >> $GITHUB_OUTPUT - name: Create Pull Request if: env.SKIP_RENAME != 'true' @@ -54,3 +56,4 @@ jobs: commit-message: 'chore: renamed new migration files to latest UTC (gh-action)' title: 'GH Action: rename new migration file timestamp' branch-suffix: timestamp + reviewers: ${{ steps.pr_details.outputs.pr_merger }} From 00faa6257ff3723627792c3452f21ab922c95718 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 14:27:33 -0400 Subject: [PATCH 207/244] Delete backend/src/db/migrations/20240507162149_test.ts --- backend/src/db/migrations/20240507162149_test.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 backend/src/db/migrations/20240507162149_test.ts diff --git a/backend/src/db/migrations/20240507162149_test.ts b/backend/src/db/migrations/20240507162149_test.ts deleted file mode 100644 index 8b1378917..000000000 --- a/backend/src/db/migrations/20240507162149_test.ts +++ /dev/null @@ -1 +0,0 @@ - From 0383ae9e8b2a1b15e3ffb4fe06d5f98fa7931f76 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 14:27:44 -0400 Subject: [PATCH 208/244] Create 20240507162149_test.ts --- backend/src/db/migrations/20240507162149_test.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 backend/src/db/migrations/20240507162149_test.ts diff --git a/backend/src/db/migrations/20240507162149_test.ts b/backend/src/db/migrations/20240507162149_test.ts new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/backend/src/db/migrations/20240507162149_test.ts @@ -0,0 +1 @@ + From 3a1cdc4f447fdd71cd3ab8fe2c227b91b71900ba Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 May 2024 15:41:09 -0400 Subject: [PATCH 209/244] Delete backend/src/db/migrations/20240507162149_test.ts --- backend/src/db/migrations/20240507162149_test.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 backend/src/db/migrations/20240507162149_test.ts diff --git a/backend/src/db/migrations/20240507162149_test.ts b/backend/src/db/migrations/20240507162149_test.ts deleted file mode 100644 index 8b1378917..000000000 --- a/backend/src/db/migrations/20240507162149_test.ts +++ /dev/null @@ -1 +0,0 @@ - From cebd22da8e3d5b4c0cc1cd196bd592694ffa8f21 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 7 May 2024 21:06:55 +0000 Subject: [PATCH 210/244] chore: renamed new migration files to latest timestamp (gh-action) --- ...ty-aws-iam-auth.ts => 20240507210655_identity-aws-iam-auth.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240507055915_identity-aws-iam-auth.ts => 20240507210655_identity-aws-iam-auth.ts} (100%) diff --git a/backend/src/db/migrations/20240507055915_identity-aws-iam-auth.ts b/backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts similarity index 100% rename from backend/src/db/migrations/20240507055915_identity-aws-iam-auth.ts rename to backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts From 14f38eb961d557827b59fc83c4bfb293a24c9eb7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:16:51 +0200 Subject: [PATCH 211/244] Feat: Recursive mode types --- k8-operator/api/v1alpha1/infisicalsecret_types.go | 4 ++++ .../crd/bases/secrets.infisical.com_infisicalsecrets.yaml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index c29761b06..f6a7ef022 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -38,6 +38,8 @@ type SecretScopeInWorkspace struct { SecretsPath string `json:"secretsPath"` // +kubebuilder:validation:Required EnvSlug string `json:"envSlug"` + // +kubebuilder:validation:Optional + Recursive bool `json:"recursive"` } type MachineIdentityScopeInWorkspace struct { @@ -47,6 +49,8 @@ type MachineIdentityScopeInWorkspace struct { EnvSlug string `json:"envSlug"` // +kubebuilder:validation:Required ProjectSlug string `json:"projectSlug"` + // +kubebuilder:validation:Optional + Recursive bool `json:"recursive"` } type KubeSecretReference struct { diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml index 811eb9669..855bff7a9 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml @@ -67,6 +67,8 @@ spec: properties: envSlug: type: string + recursive: + type: boolean secretsPath: type: string required: @@ -111,6 +113,8 @@ spec: type: string projectSlug: type: string + recursive: + type: boolean secretsPath: type: string required: From 4a02520147a6dc629221bee81d771ab41ff18f04 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:18:26 +0200 Subject: [PATCH 212/244] Update sample --- k8-operator/config/samples/sample.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/k8-operator/config/samples/sample.yaml b/k8-operator/config/samples/sample.yaml index 65347dec7..1c8a9a3f0 100644 --- a/k8-operator/config/samples/sample.yaml +++ b/k8-operator/config/samples/sample.yaml @@ -19,12 +19,14 @@ spec: secretsScope: envSlug: secretsPath: # Root is "/" + recursive: true # Wether or not to use recursive mode (Fetches all secrets in an environment from a given secret path, and all folders inside the path) / defaults to false universalAuth: secretsScope: projectSlug: envSlug: # "dev", "staging", "prod", etc.. secretsPath: "" # Root is "/" + recursive: true # Wether or not to use recursive mode (Fetches all secrets in an environment from a given secret path, and all folders inside the path) / defaults to false credentialsRef: secretName: universal-auth-credentials From 1f6abc7f27b840c2f9219c4b571600bb0b71a300 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:18:40 +0200 Subject: [PATCH 213/244] Feat: Recursive mode and fix error formatting --- k8-operator/controllers/infisicalsecret_helper.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret_helper.go index 4ef378fe2..c14f724eb 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret_helper.go @@ -269,7 +269,7 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context } else if infisicalMachineIdentityCreds.ClientId != "" && infisicalMachineIdentityCreds.ClientSecret != "" { authStrategy = AuthStrategy.UNIVERSAL_MACHINE_IDENTITY } else { - return fmt.Errorf("no authentication method provided. You must provide either a valid service token or a service account details to fetch secrets") + return fmt.Errorf("no authentication method provided. You must provide either a valid service token or a service account details to fetch secrets\n") } r.SetInfisicalTokenLoadCondition(ctx, &infisicalSecret, err) @@ -312,8 +312,9 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context } else if authStrategy == AuthStrategy.SERVICE_TOKEN { // Service Tokens (deprecated) envSlug := infisicalSecret.Spec.Authentication.ServiceToken.SecretsScope.EnvSlug secretsPath := infisicalSecret.Spec.Authentication.ServiceToken.SecretsScope.SecretsPath + recursive := infisicalSecret.Spec.Authentication.ServiceToken.SecretsScope.Recursive - plainTextSecretsFromApi, updateDetails, err = util.GetPlainTextSecretsViaServiceToken(infisicalToken, secretVersionBasedOnETag, envSlug, secretsPath) + plainTextSecretsFromApi, updateDetails, err = util.GetPlainTextSecretsViaServiceToken(infisicalToken, secretVersionBasedOnETag, envSlug, secretsPath, recursive) if err != nil { return fmt.Errorf("\nfailed to get secrets because [err=%v]", err) } From 6b83326d0067cbfce28b463a52823ac07f60e401 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:18:53 +0200 Subject: [PATCH 214/244] Feat: Recursive mode support --- k8-operator/packages/api/api.go | 16 ++++++++++++---- k8-operator/packages/api/models.go | 2 ++ k8-operator/packages/util/secrets.go | 6 ++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/k8-operator/packages/api/api.go b/k8-operator/packages/api/api.go index 05a58e8cb..233925dda 100644 --- a/k8-operator/packages/api/api.go +++ b/k8-operator/packages/api/api.go @@ -66,6 +66,10 @@ func CallGetSecretsV3(httpClient *resty.Client, request GetEncryptedSecretsV3Req httpRequest.SetQueryParam("secretPath", request.SecretPath) } + if request.Recursive { + httpRequest.SetQueryParam("recursive", "true") + } + response, err := httpRequest.Get(fmt.Sprintf("%v/v3/secrets", API_HOST_URL)) if err != nil { @@ -148,19 +152,23 @@ func CallUniversalMachineIdentityRefreshAccessToken(request MachineIdentityUnive func CallGetDecryptedSecretsV3(httpClient *resty.Client, request GetDecryptedSecretsV3Request) (GetDecryptedSecretsV3Response, error) { var decryptedSecretsResponse GetDecryptedSecretsV3Response - response, err := httpClient. + req := httpClient. R(). SetResult(&decryptedSecretsResponse). SetHeader("User-Agent", USER_AGENT_NAME). SetQueryParam("secretPath", request.SecretPath). SetQueryParam("workspaceSlug", request.ProjectSlug). - SetQueryParam("environment", request.Environment). - Get(fmt.Sprintf("%v/v3/secrets/raw", API_HOST_URL)) + SetQueryParam("environment", request.Environment) + + if request.Recursive { + req.SetQueryParam("recursive", "true") + } + + response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", API_HOST_URL)) if err != nil { return GetDecryptedSecretsV3Response{}, fmt.Errorf("CallGetDecryptedSecretsV3: Unable to complete api request [err=%s]", err) } - if response.IsError() { return GetDecryptedSecretsV3Response{}, fmt.Errorf("CallGetDecryptedSecretsV3: Unsuccessful response: [response=%s]", response) } diff --git a/k8-operator/packages/api/models.go b/k8-operator/packages/api/models.go index ff4ea3e10..ccb8d0d00 100644 --- a/k8-operator/packages/api/models.go +++ b/k8-operator/packages/api/models.go @@ -31,6 +31,7 @@ type GetEncryptedWorkspaceKeyResponse struct { type GetEncryptedSecretsV3Request struct { Environment string `json:"environment"` WorkspaceId string `json:"workspaceId"` + Recursive bool `json:"recursive"` SecretPath string `json:"secretPath"` IncludeImport bool `json:"include_imports"` ETag string `json:"etag,omitempty"` @@ -100,6 +101,7 @@ type GetDecryptedSecretsV3Request struct { ProjectSlug string `json:"workspaceSlug"` Environment string `json:"environment"` SecretPath string `json:"secretPath"` + Recursive bool `json:"recursive"` ETag string `json:"etag,omitempty"` } diff --git a/k8-operator/packages/util/secrets.go b/k8-operator/packages/util/secrets.go index a8c4a4cc5..290e1340c 100644 --- a/k8-operator/packages/util/secrets.go +++ b/k8-operator/packages/util/secrets.go @@ -60,6 +60,7 @@ func GetPlainTextSecretsViaUniversalAuth(accessToken string, etag string, secret secretsResponse, err := api.CallGetDecryptedSecretsV3(httpClient, api.GetDecryptedSecretsV3Request{ ProjectSlug: secretScope.ProjectSlug, Environment: secretScope.EnvSlug, + Recursive: secretScope.Recursive, SecretPath: secretScope.SecretsPath, ETag: etag, }) @@ -85,7 +86,7 @@ func GetPlainTextSecretsViaUniversalAuth(accessToken string, etag string, secret }, nil } -func GetPlainTextSecretsViaServiceToken(fullServiceToken string, etag string, envSlug string, secretPath string) ([]model.SingleEnvironmentVariable, model.RequestUpdateUpdateDetails, error) { +func GetPlainTextSecretsViaServiceToken(fullServiceToken string, etag string, envSlug string, secretPath string, recursive bool) ([]model.SingleEnvironmentVariable, model.RequestUpdateUpdateDetails, error) { serviceTokenParts := strings.SplitN(fullServiceToken, ".", 4) if len(serviceTokenParts) < 4 { return nil, model.RequestUpdateUpdateDetails{}, fmt.Errorf("invalid service token entered. Please double check your service token and try again") @@ -106,6 +107,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, etag string, en encryptedSecretsResponse, err := api.CallGetSecretsV3(httpClient, api.GetEncryptedSecretsV3Request{ WorkspaceId: serviceTokenDetails.Workspace, Environment: envSlug, + Recursive: recursive, ETag: etag, SecretPath: secretPath, }) @@ -376,7 +378,7 @@ func ExpandSecrets(secrets []model.SingleEnvironmentVariable, infisicalToken str if crossRefSec, ok := crossEnvRefSecs[uniqKey]; !ok { // if not in cross reference cache, fetch it from server - refSecs, _, err := GetPlainTextSecretsViaServiceToken(infisicalToken, "", env, secPath) + refSecs, _, err := GetPlainTextSecretsViaServiceToken(infisicalToken, "", env, secPath, false) if err != nil { fmt.Printf("Could not fetch secrets in environment: %s secret-path: %s", env, secPath) // HandleError(err, fmt.Sprintf("Could not fetch secrets in environment: %s secret-path: %s", env, secPath), "If you are using a service token to fetch secrets, please ensure it is valid") From b0421ccad0fa6576b04198223bbe58a7f0f484cc Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:21:08 +0200 Subject: [PATCH 215/244] Docs: Add recursive to example --- docs/integrations/platforms/kubernetes.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index d1cff4e75..3d1b72331 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -77,6 +77,8 @@ spec: projectSlug: envSlug: # "dev", "staging", "prod", etc.. secretsPath: "" # Root is "/" + recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. + credentialsRef: secretName: universal-auth-credentials secretNamespace: default @@ -89,6 +91,7 @@ spec: secretsScope: envSlug: secretsPath: # Root is "/" + recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. managedSecretReference: secretName: managed-secret From 0b012c5dfbf3f58cd4c226c052a90ab47a603ab7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:23:50 +0200 Subject: [PATCH 216/244] Chore: Helm --- .../secrets-operator/templates/infisicalsecret-crd.yaml | 4 ++++ helm-charts/secrets-operator/values.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index 61e7a6719..edc3eaaae 100644 --- a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml @@ -67,6 +67,8 @@ spec: properties: envSlug: type: string + recursive: + type: boolean secretsPath: type: string required: @@ -111,6 +113,8 @@ spec: type: string projectSlug: type: string + recursive: + type: boolean secretsPath: type: string required: diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index 26f7a1938..e7d27306b 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: v0.5.0 # fixed to prevent accidental upgrade + tag: v0.5.1 # fixed to prevent accidental upgrade (manually bump when adding new versions) resources: limits: cpu: 500m From 829dbb9970a6bf58ad70aab7d3977d8f29387501 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:41:53 +0200 Subject: [PATCH 217/244] Update values.yaml --- helm-charts/secrets-operator/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index e7d27306b..8054b1b0c 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: v0.5.1 # fixed to prevent accidental upgrade (manually bump when adding new versions) + tag: v0.5.1 # fixed to prevent accidental upgrade resources: limits: cpu: 500m From be2fc4fec4915812e1ea08ff9cec484463bbc87a Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Wed, 8 May 2024 00:42:38 +0200 Subject: [PATCH 218/244] Update Chart.yaml --- helm-charts/secrets-operator/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index b007891b0..9c4f83248 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,7 +13,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.5.0 +version: v0.5.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. From e7f19421ef278cbf2e3a9cbc86bb6daea21386f8 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 17:24:06 +0800 Subject: [PATCH 219/244] misc: resolved auto-popup of suggestions --- .../src/components/v2/SecretPathInput/SecretPathInput.tsx | 6 ++++-- frontend/src/hooks/api/secretFolders/queries.tsx | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 270ebf351..9dfb5ff62 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -56,7 +56,9 @@ export const SecretPathInput = ({ ) { setSecretPath(debouncedInputValue); } + }, [debouncedInputValue]); + useEffect(() => { // filter suggestions based on matching const searchFragment = debouncedInputValue.split("/").pop() || ""; const filteredSuggestions = folders @@ -66,7 +68,7 @@ export const SecretPathInput = ({ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); setSuggestions(filteredSuggestions); - }, [debouncedInputValue]); + }, [debouncedInputValue, folders]); const handleSuggestionSelect = (selectedIndex: number) => { if (!suggestions[selectedIndex]) { @@ -76,7 +78,7 @@ export const SecretPathInput = ({ const validPaths = inputValue.split("/"); validPaths.pop(); - const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; + const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}/`; onChange?.(newValue); setInputValue(newValue); setSecretPath(newValue); diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index bcda2b0a4..1bbbfb112 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -79,7 +79,7 @@ export const useGetFoldersByEnv = ({ }); }); return [...names]; - }, [(folders || []).map((folder) => folder.data)]); + }, [...(folders || []).map((folder) => folder.data)]); const isFolderPresentInEnv = useCallback( (name: string, env: string) => { @@ -91,7 +91,7 @@ export const useGetFoldersByEnv = ({ } return false; }, - [(folders || []).map((folder) => folder.data)] + [...(folders || []).map((folder) => folder.data)] ); return { folders, folderNames, isFolderPresentInEnv }; From e461787c78b20129d3c0e9744f6216969b1d0b50 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 23:24:33 +0800 Subject: [PATCH 220/244] feat: added support for renaming folders in the overview page --- .../SecretOverviewPage/SecretOverviewPage.tsx | 81 ++++++++++++++++++- .../SecretOverviewFolderRow.tsx | 18 ++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index fab7ebecf..e7517b37e 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -47,6 +47,7 @@ import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, + useProjectPermission, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; @@ -59,8 +60,10 @@ import { useGetImportedSecretsAllEnvs, useGetProjectSecretsAllEnv, useGetUserWsKey, + useUpdateFolder, useUpdateSecretV3 } from "@app/hooks/api"; +import { TSecretFolder } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; @@ -87,6 +90,7 @@ export const SecretOverviewPage = () => { const parentTableRef = useRef(null); const [expandableTableWidth, setExpandableTableWidth] = useState(0); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const { permission } = useProjectPermission(); useEffect(() => { if (parentTableRef.current) { @@ -201,11 +205,13 @@ export const SecretOverviewPage = () => { const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: updateFolder } = useUpdateFolder(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addSecretsInAllEnvs", "addFolder", - "misc" + "misc", + "updateFolder" ] as const); const handleFolderCreate = async (folderName: string) => { @@ -236,6 +242,64 @@ export const SecretOverviewPage = () => { } }; + const handleFolderUpdate = async (newFolderName: string) => { + const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder; + let processedUpdates = 0; + let totalFolderMatchCount = 0; + + await Promise.allSettled( + userAvailableEnvs.map(async (env) => { + if ( + permission.cannot( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) + ) { + return; + } + + const folder = getFolderByNameAndEnv(oldFolderName, env.slug); + if (folder) { + totalFolderMatchCount += 1; + + await updateFolder({ + folderId: folder.id, + name: newFolderName, + path: secretPath, + environment: env.slug, + projectId: workspaceId + }); + + processedUpdates += 1; + } + }) + ); + + if (totalFolderMatchCount === 0) { + createNotification({ + type: "info", + text: "You don't have access to rename selected folder" + }); + } else if (processedUpdates === totalFolderMatchCount) { + createNotification({ + type: "success", + text: "Successfully renamed folder across environments" + }); + } else if (processedUpdates > 0) { + createNotification({ + type: "warning", + text: "Partially renamed folder across environments" + }); + } else { + createNotification({ + type: "error", + text: "Failed to rename folder across environments" + }); + } + + handlePopUpClose("updateFolder"); + }; + const handleSecretCreate = async (env: string, key: string, value: string) => { try { // create folder if not existing @@ -726,6 +790,9 @@ export const SecretOverviewPage = () => { environments={visibleEnvs} key={`overview-${folderName}-${index + 1}`} onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name }) + } /> ))} {!isTableLoading && @@ -800,6 +867,18 @@ export const SecretOverviewPage = () => { + handlePopUpToggle("updateFolder", isOpen)} + > + + )?.name} + onUpdateFolder={handleFolderUpdate} + /> + + ); }; diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx index c60aff912..2625d14c6 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx @@ -1,8 +1,8 @@ -import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Checkbox, Td, Tr } from "@app/components/v2"; +import { Checkbox, IconButton, Td, Tr } from "@app/components/v2"; type Props = { folderName: string; @@ -11,6 +11,7 @@ type Props = { onClick: (path: string) => void; isSelected: boolean; onToggleFolderSelect: (folderName: string) => void; + onToggleFolderEdit: (name: string) => void; }; export const SecretOverviewFolderRow = ({ @@ -19,6 +20,7 @@ export const SecretOverviewFolderRow = ({ isFolderPresentInEnv, isSelected, onToggleFolderSelect, + onToggleFolderEdit, onClick }: Props) => { return ( @@ -43,6 +45,18 @@ export const SecretOverviewFolderRow = ({ />
{folderName}
+ { + onToggleFolderEdit(folderName); + e.stopPropagation(); + }} + > + +
{environments.map(({ slug }, i) => { From 522dd0836e455c36981d6f1bd77fd639eaec1632 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 23:25:33 +0800 Subject: [PATCH 221/244] feat: added validation for folder name duplicates --- .../secret-folder/secret-folder-service.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index c925d2587..9f602172f 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -151,6 +151,21 @@ export const secretFolderServiceFactory = ({ .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); if (!folder) throw new BadRequestError({ message: "Folder not found" }); + if (name !== folder.name) { + // ensure that new folder name is unique + const folderToCheck = await folderDAL.findOne({ + name, + envId: env.id, + parentId: parentFolder.id + }); + + if (folderToCheck) { + throw new BadRequestError({ + message: "Folder with specified name already exists", + name: "Update folder" + }); + } + } const newFolder = await folderDAL.transaction(async (tx) => { const [doc] = await folderDAL.update({ envId: env.id, id: folder.id, parentId: parentFolder.id }, { name }, tx); From a12ad91e5922f2eec0bd6de39042db298f7954d0 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 9 May 2024 00:15:42 -0400 Subject: [PATCH 222/244] Update build-staging-and-deploy-aws.yml --- .github/workflows/build-staging-and-deploy-aws.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml index 78a193d30..1addd7ad6 100644 --- a/.github/workflows/build-staging-and-deploy-aws.yml +++ b/.github/workflows/build-staging-and-deploy-aws.yml @@ -135,6 +135,6 @@ jobs: uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.render-web-container.outputs.task-definition }} - service: infisical-prod-platform - cluster: infisical-prod-platform + service: infisical-core-platform + cluster: infisical-core-platform wait-for-service-stability: true From 86d4d88b58724464b0cfccff82bcf9f60d41040d Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 9 May 2024 00:19:26 -0400 Subject: [PATCH 223/244] package json lock --- backend/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 5928f64a5..6c674474b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -49,7 +49,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.4", + "mysql2": "^3.9.7", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", @@ -10102,9 +10102,9 @@ } }, "node_modules/mysql2": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.4.tgz", - "integrity": "sha512-OEESQuwxMza803knC1YSt7NMuc1BrK9j7gZhCSs2WAyxr1vfiI7QLaLOKTh5c9SWGz98qVyQUbK8/WckevNQhg==", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", + "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", From 15ea96815ce4fda5da8fd6faad04b4caf814ebe0 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 8 May 2024 21:22:23 -0700 Subject: [PATCH 224/244] Rename AWS IAM auth to AWS Auth with IAM type --- backend/src/@types/fastify.d.ts | 4 +- backend/src/@types/knex.d.ts | 14 +-- .../20240507210655_identity-aws-iam-auth.ts | 11 +- .../db/schemas/access-approval-policies.ts | 2 +- ...aws-iam-auths.ts => identity-aws-auths.ts} | 9 +- backend/src/db/schemas/index.ts | 2 +- backend/src/db/schemas/models.ts | 4 +- backend/src/db/schemas/users.ts | 2 +- .../ee/services/audit-log/audit-log-types.ts | 34 ++--- backend/src/lib/api-docs/constants.ts | 2 +- backend/src/server/routes/index.ts | 12 +- .../routes/v1/identity-aws-iam-auth-router.ts | 110 ++++++++-------- backend/src/server/routes/v1/index.ts | 4 +- .../identity-aws-auth-dal.ts | 11 ++ .../identity-aws-auth-fns.ts} | 0 .../identity-aws-auth-service.ts} | 117 +++++++++--------- .../identity-aws-auth-types.ts} | 10 +- .../identity-aws-auth-validators.ts} | 0 .../identity-aws-iam-auth-dal.ts | 11 -- .../getting-started/introduction.mdx | 2 +- .../{aws-iam-auth.mdx => aws-auth.mdx} | 28 ++--- .../identities/machine-identities.mdx | 6 +- ...identities-org-create-aws-auth-method.png} | Bin docs/mint.json | 2 +- .../src/hooks/api/identities/constants.tsx | 2 +- frontend/src/hooks/api/identities/enums.tsx | 2 +- frontend/src/hooks/api/identities/index.tsx | 12 +- .../src/hooks/api/identities/mutations.tsx | 33 ++--- frontend/src/hooks/api/identities/queries.tsx | 16 +-- frontend/src/hooks/api/identities/types.ts | 7 +- .../IdentityAuthMethodModal.tsx | 8 +- ...amAuthForm.tsx => IdentityAwsAuthForm.tsx} | 14 +-- 32 files changed, 246 insertions(+), 245 deletions(-) rename backend/src/db/schemas/{identity-aws-iam-auths.ts => identity-aws-auths.ts} (65%) create mode 100644 backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts rename backend/src/services/{identity-aws-iam-auth/identity-aws-iam-auth-fns.ts => identity-aws-auth/identity-aws-auth-fns.ts} (100%) rename backend/src/services/{identity-aws-iam-auth/identity-aws-iam-auth-service.ts => identity-aws-auth/identity-aws-auth-service.ts} (71%) rename backend/src/services/{identity-aws-iam-auth/identity-aws-iam-auth-types.ts => identity-aws-auth/identity-aws-auth-types.ts} (86%) rename backend/src/services/{identity-aws-iam-auth/identity-aws-iam-auth-validators.ts => identity-aws-auth/identity-aws-auth-validators.ts} (100%) delete mode 100644 backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts rename docs/documentation/platform/identities/{aws-iam-auth.mdx => aws-auth.mdx} (92%) rename docs/images/platform/identities/{identities-org-create-aws-iam-auth-method.png => identities-org-create-aws-auth-method.png} (100%) rename frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/{IdentityAwsIamAuthForm.tsx => IdentityAwsAuthForm.tsx} (97%) diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index da966218f..b39847f27 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -32,7 +32,7 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; -import { TIdentityAwsIamAuthServiceFactory } from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-service"; +import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; @@ -116,7 +116,7 @@ declare module "fastify" { identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; - identityAwsIamAuth: TIdentityAwsIamAuthServiceFactory; + identityAwsAuth: TIdentityAwsAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index b597bd250..02d2e3981 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -59,9 +59,9 @@ import { TIdentityAccessTokens, TIdentityAccessTokensInsert, TIdentityAccessTokensUpdate, - TIdentityAwsIamAuths, - TIdentityAwsIamAuthsInsert, - TIdentityAwsIamAuthsUpdate, + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate, TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, @@ -329,10 +329,10 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; - [TableName.IdentityAwsIamAuth]: Knex.CompositeTableType< - TIdentityAwsIamAuths, - TIdentityAwsIamAuthsInsert, - TIdentityAwsIamAuthsUpdate + [TableName.IdentityAwsAuth]: Knex.CompositeTableType< + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, diff --git a/backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts b/backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts index 0728d4f28..f182425c3 100644 --- a/backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts +++ b/backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts @@ -4,8 +4,8 @@ import { TableName } from "../schemas"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.IdentityAwsIamAuth))) { - await knex.schema.createTable(TableName.IdentityAwsIamAuth, (t) => { + if (!(await knex.schema.hasTable(TableName.IdentityAwsAuth))) { + await knex.schema.createTable(TableName.IdentityAwsAuth, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); @@ -14,16 +14,17 @@ export async function up(knex: Knex): Promise { t.timestamps(true, true, true); t.uuid("identityId").notNullable().unique(); t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("type").notNullable(); t.string("stsEndpoint").notNullable(); t.string("allowedPrincipalArns").notNullable(); t.string("allowedAccountIds").notNullable(); }); } - await createOnUpdateTrigger(knex, TableName.IdentityAwsIamAuth); + await createOnUpdateTrigger(knex, TableName.IdentityAwsAuth); } export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.IdentityAwsIamAuth); - await dropOnUpdateTrigger(knex, TableName.IdentityAwsIamAuth); + await knex.schema.dropTableIfExists(TableName.IdentityAwsAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAwsAuth); } diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index bf7e74ff2..69068d23b 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -11,8 +11,8 @@ export const AccessApprovalPoliciesSchema = z.object({ id: z.string().uuid(), name: z.string(), approvals: z.number().default(1), - envId: z.string().uuid(), secretPath: z.string().nullable().optional(), + envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/db/schemas/identity-aws-iam-auths.ts b/backend/src/db/schemas/identity-aws-auths.ts similarity index 65% rename from backend/src/db/schemas/identity-aws-iam-auths.ts rename to backend/src/db/schemas/identity-aws-auths.ts index 8912c71b3..f4444b00f 100644 --- a/backend/src/db/schemas/identity-aws-iam-auths.ts +++ b/backend/src/db/schemas/identity-aws-auths.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { TImmutableDBKeys } from "./models"; -export const IdentityAwsIamAuthsSchema = z.object({ +export const IdentityAwsAuthsSchema = z.object({ id: z.string().uuid(), accessTokenTTL: z.coerce.number().default(7200), accessTokenMaxTTL: z.coerce.number().default(7200), @@ -16,11 +16,12 @@ export const IdentityAwsIamAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), identityId: z.string().uuid(), + type: z.string(), stsEndpoint: z.string(), allowedPrincipalArns: z.string(), allowedAccountIds: z.string() }); -export type TIdentityAwsIamAuths = z.infer; -export type TIdentityAwsIamAuthsInsert = Omit, TImmutableDBKeys>; -export type TIdentityAwsIamAuthsUpdate = Partial, TImmutableDBKeys>>; +export type TIdentityAwsAuths = z.infer; +export type TIdentityAwsAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAwsAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index e5c9a9ceb..8bd00ff62 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -17,7 +17,7 @@ export * from "./group-project-memberships"; export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; -export * from "./identity-aws-iam-auths"; +export * from "./identity-aws-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 4c080535d..053c6411d 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -45,7 +45,7 @@ export enum TableName { IdentityAccessToken = "identity_access_tokens", IdentityUniversalAuth = "identity_universal_auths", IdentityUaClientSecret = "identity_ua_client_secrets", - IdentityAwsIamAuth = "identity_aws_iam_auths", + IdentityAwsAuth = "identity_aws_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -144,5 +144,5 @@ export enum ProjectUpgradeStatus { export enum IdentityAuthMethod { Univeral = "universal-auth", - AWS_IAM_AUTH = "aws-iam-auth" + AWS_AUTH = "aws-auth" } diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 3eee2683f..d5a4d5b49 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -22,7 +22,7 @@ export const UsersSchema = z.object({ updatedAt: z.date(), isGhost: z.boolean().default(false), username: z.string(), - isEmailVerified: z.boolean().nullable().optional() + isEmailVerified: z.boolean().default(false).nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 1e79984e4..1bebd96d6 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -66,10 +66,10 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", - LOGIN_IDENTITY_AWS_IAM_AUTH = "login-identity-aws-iam-auth", - ADD_IDENTITY_AWS_IAM_AUTH = "add-identity-aws-iam-auth", - UPDATE_IDENTITY_AWS_IAM_AUTH = "update-identity-aws-iam-auth", - GET_IDENTITY_AWS_IAM_AUTH = "get-identity-aws-iam-auth", + LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", + ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", + UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", + GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -410,17 +410,17 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } -interface LoginIdentityAwsIamAuthEvent { - type: EventType.LOGIN_IDENTITY_AWS_IAM_AUTH; +interface LoginIdentityAwsAuthEvent { + type: EventType.LOGIN_IDENTITY_AWS_AUTH; metadata: { identityId: string; - identityAwsIamAuthId: string; + identityAwsAuthId: string; identityAccessTokenId: string; }; } -interface AddIdentityAwsIamAuthEvent { - type: EventType.ADD_IDENTITY_AWS_IAM_AUTH; +interface AddIdentityAwsAuthEvent { + type: EventType.ADD_IDENTITY_AWS_AUTH; metadata: { identityId: string; stsEndpoint: string; @@ -433,8 +433,8 @@ interface AddIdentityAwsIamAuthEvent { }; } -interface UpdateIdentityAwsIamAuthEvent { - type: EventType.UPDATE_IDENTITY_AWS_IAM_AUTH; +interface UpdateIdentityAwsAuthEvent { + type: EventType.UPDATE_IDENTITY_AWS_AUTH; metadata: { identityId: string; stsEndpoint?: string; @@ -447,8 +447,8 @@ interface UpdateIdentityAwsIamAuthEvent { }; } -interface GetIdentityAwsIamAuthEvent { - type: EventType.GET_IDENTITY_AWS_IAM_AUTH; +interface GetIdentityAwsAuthEvent { + type: EventType.GET_IDENTITY_AWS_AUTH; metadata: { identityId: string; }; @@ -708,10 +708,10 @@ export type Event = | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent - | LoginIdentityAwsIamAuthEvent - | AddIdentityAwsIamAuthEvent - | UpdateIdentityAwsIamAuthEvent - | GetIdentityAwsIamAuthEvent + | LoginIdentityAwsAuthEvent + | AddIdentityAwsAuthEvent + | UpdateIdentityAwsAuthEvent + | GetIdentityAwsAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 336385472..f1e419e85 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -92,7 +92,7 @@ export const UNIVERSAL_AUTH = { } } as const; -export const AWS_IAM_AUTH = { +export const AWS_AUTH = { LOGIN: { identityId: "The ID of the identity to login.", iamHttpRequestMethod: "The HTTP request method used in the signed request.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 62dad3cf4..7aecf35c8 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -78,8 +78,8 @@ import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; -import { identityAwsIamAuthDALFactory } from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-dal"; -import { identityAwsIamAuthServiceFactory } from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-service"; +import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; +import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -203,7 +203,7 @@ export const registerRoutes = async ( const identityUaDAL = identityUaDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); - const identityAwsIamAuthDAL = identityAwsIamAuthDALFactory(db); + const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); const auditLogStreamDAL = auditLogStreamDALFactory(db); @@ -702,9 +702,9 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); - const identityAWSIAMAuthService = identityAwsIamAuthServiceFactory({ + const identityAwsAuthService = identityAwsAuthServiceFactory({ identityAccessTokenDAL, - identityAwsIamAuthDAL, + identityAwsAuthDAL, identityOrgMembershipDAL, identityDAL, licenseService, @@ -779,7 +779,7 @@ export const registerRoutes = async ( identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityUa: identityUaService, - identityAwsIamAuth: identityAWSIAMAuthService, + identityAwsAuth: identityAwsAuthService, secretApprovalPolicy: sapService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index 12003a158..f8c045168 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import { IdentityAwsIamAuthsSchema } from "@app/db/schemas"; +import { IdentityAwsAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { AWS_IAM_AUTH } from "@app/lib/api-docs"; +import { AWS_AUTH } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -10,22 +10,22 @@ import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { validateAccountIds, validatePrincipalArns -} from "@app/services/identity-aws-iam-auth/identity-aws-iam-auth-validators"; +} from "@app/services/identity-aws-auth/identity-aws-auth-validators"; -export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvider) => { +export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/aws-iam-auth/login", + url: "/aws-auth/login", config: { rateLimit: writeLimit }, schema: { - description: "Login with AWS IAM Auth", + description: "Login with AWS Auth", body: z.object({ - identityId: z.string().describe(AWS_IAM_AUTH.LOGIN.identityId), - iamHttpRequestMethod: z.string().default("POST").describe(AWS_IAM_AUTH.LOGIN.iamHttpRequestMethod), - iamRequestBody: z.string().describe(AWS_IAM_AUTH.LOGIN.iamRequestBody), - iamRequestHeaders: z.string().describe(AWS_IAM_AUTH.LOGIN.iamRequestHeaders) + identityId: z.string().describe(AWS_AUTH.LOGIN.identityId), + iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), + iamRequestBody: z.string().describe(AWS_AUTH.LOGIN.iamRequestBody), + iamRequestHeaders: z.string().describe(AWS_AUTH.LOGIN.iamRequestHeaders) }), response: { 200: z.object({ @@ -37,18 +37,18 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide } }, handler: async (req) => { - const { identityAwsIamAuth, accessToken, identityAccessToken, identityMembershipOrg } = - await server.services.identityAwsIamAuth.login(req.body); + const { identityAwsAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAwsAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: identityMembershipOrg?.orgId, event: { - type: EventType.LOGIN_IDENTITY_AWS_IAM_AUTH, + type: EventType.LOGIN_IDENTITY_AWS_AUTH, metadata: { - identityId: identityAwsIamAuth.identityId, + identityId: identityAwsAuth.identityId, identityAccessTokenId: identityAccessToken.id, - identityAwsIamAuthId: identityAwsIamAuth.id + identityAwsAuthId: identityAwsAuth.id } } }); @@ -56,21 +56,21 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide return { accessToken, tokenType: "Bearer" as const, - expiresIn: identityAwsIamAuth.accessTokenTTL, - accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL + expiresIn: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL }; } }); server.route({ method: "POST", - url: "/aws-iam-auth/identities/:identityId", + url: "/aws-auth/identities/:identityId", config: { rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "Attach AWS IAM Auth configuration onto identity", + description: "Attach AWS Auth configuration onto identity", security: [ { bearerAuth: [] @@ -109,12 +109,12 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide }), response: { 200: z.object({ - identityAwsIamAuth: IdentityAwsIamAuthsSchema + identityAwsAuth: IdentityAwsAuthsSchema }) } }, handler: async (req) => { - const identityAwsIamAuth = await server.services.identityAwsIamAuth.attachAwsIamAuth({ + const identityAwsAuth = await server.services.identityAwsAuth.attachAwsAuth({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -125,35 +125,35 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityAwsIamAuth.orgId, + orgId: identityAwsAuth.orgId, event: { - type: EventType.ADD_IDENTITY_AWS_IAM_AUTH, + type: EventType.ADD_IDENTITY_AWS_AUTH, metadata: { - identityId: identityAwsIamAuth.identityId, - stsEndpoint: identityAwsIamAuth.stsEndpoint, - allowedPrincipalArns: identityAwsIamAuth.allowedPrincipalArns, - allowedAccountIds: identityAwsIamAuth.allowedAccountIds, - accessTokenTTL: identityAwsIamAuth.accessTokenTTL, - accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL, - accessTokenTrustedIps: identityAwsIamAuth.accessTokenTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityAwsIamAuth.accessTokenNumUsesLimit + identityId: identityAwsAuth.identityId, + stsEndpoint: identityAwsAuth.stsEndpoint, + allowedPrincipalArns: identityAwsAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsAuth.allowedAccountIds, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit } } }); - return { identityAwsIamAuth }; + return { identityAwsAuth }; } }); server.route({ method: "PATCH", - url: "/aws-iam-auth/identities/:identityId", + url: "/aws-auth/identities/:identityId", config: { rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "Update AWS IAM Auth configuration on identity", + description: "Update AWS Auth configuration on identity", security: [ { bearerAuth: [] @@ -185,12 +185,12 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide }), response: { 200: z.object({ - identityAwsIamAuth: IdentityAwsIamAuthsSchema + identityAwsAuth: IdentityAwsAuthsSchema }) } }, handler: async (req) => { - const identityAwsIamAuth = await server.services.identityAwsIamAuth.updateAwsIamAuth({ + const identityAwsAuth = await server.services.identityAwsAuth.updateAwsAuth({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -201,35 +201,35 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityAwsIamAuth.orgId, + orgId: identityAwsAuth.orgId, event: { - type: EventType.UPDATE_IDENTITY_AWS_IAM_AUTH, + type: EventType.UPDATE_IDENTITY_AWS_AUTH, metadata: { - identityId: identityAwsIamAuth.identityId, - stsEndpoint: identityAwsIamAuth.stsEndpoint, - allowedPrincipalArns: identityAwsIamAuth.allowedPrincipalArns, - allowedAccountIds: identityAwsIamAuth.allowedAccountIds, - accessTokenTTL: identityAwsIamAuth.accessTokenTTL, - accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL, - accessTokenTrustedIps: identityAwsIamAuth.accessTokenTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityAwsIamAuth.accessTokenNumUsesLimit + identityId: identityAwsAuth.identityId, + stsEndpoint: identityAwsAuth.stsEndpoint, + allowedPrincipalArns: identityAwsAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsAuth.allowedAccountIds, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit } } }); - return { identityAwsIamAuth }; + return { identityAwsAuth }; } }); server.route({ method: "GET", - url: "/aws-iam-auth/identities/:identityId", + url: "/aws-auth/identities/:identityId", config: { rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "Retrieve AWS IAM Auth configuration on identity", + description: "Retrieve AWS Auth configuration on identity", security: [ { bearerAuth: [] @@ -240,12 +240,12 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide }), response: { 200: z.object({ - identityAwsIamAuth: IdentityAwsIamAuthsSchema + identityAwsAuth: IdentityAwsAuthsSchema }) } }, handler: async (req) => { - const identityAwsIamAuth = await server.services.identityAwsIamAuth.getAwsIamAuth({ + const identityAwsAuth = await server.services.identityAwsAuth.getAwsAuth({ identityId: req.params.identityId, actor: req.permission.type, actorId: req.permission.id, @@ -255,15 +255,15 @@ export const registerIdentityAwsIamAuthRouter = async (server: FastifyZodProvide await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityAwsIamAuth.orgId, + orgId: identityAwsAuth.orgId, event: { - type: EventType.GET_IDENTITY_AWS_IAM_AUTH, + type: EventType.GET_IDENTITY_AWS_AUTH, metadata: { - identityId: identityAwsIamAuth.identityId + identityId: identityAwsAuth.identityId } } }); - return { identityAwsIamAuth }; + return { identityAwsAuth }; } }); }; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 4fbaabdfc..6907fd040 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -2,7 +2,7 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; -import { registerIdentityAwsIamAuthRouter } from "./identity-aws-iam-auth-router"; +import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; @@ -29,7 +29,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerAuthRoutes); await authRouter.register(registerIdentityUaRouter); await authRouter.register(registerIdentityAccessTokenRouter); - await authRouter.register(registerIdentityAwsIamAuthRouter); + await authRouter.register(registerIdentityAwsAuthRouter); }, { prefix: "/auth" } ); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts new file mode 100644 index 000000000..6ce215c58 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAwsAuthDALFactory = ReturnType; + +export const identityAwsAuthDALFactory = (db: TDbClient) => { + const awsAuthOrm = ormify(db, TableName.IdentityAwsAuth); + + return awsAuthOrm; +}; diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-fns.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts similarity index 100% rename from backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-fns.ts rename to backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts similarity index 71% rename from backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts rename to backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index 95deba2d8..a58944909 100644 --- a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -16,48 +16,43 @@ import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; -import { TIdentityAwsIamAuthDALFactory } from "./identity-aws-iam-auth-dal"; -import { extractPrincipalArn } from "./identity-aws-iam-auth-fns"; +import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal"; +import { extractPrincipalArn } from "./identity-aws-auth-fns"; import { - TAttachAWSIAMAuthDTO, - TAWSGetCallerIdentityHeaders, - TGetAWSIAMAuthDTO, + TAttachAwsAuthDTO, + TAwsGetCallerIdentityHeaders, + TGetAwsAuthDTO, TGetCallerIdentityResponse, - TLoginAWSIAMAuthDTO, - TUpdateAWSIAMAuthDTO -} from "./identity-aws-iam-auth-types"; + TLoginAwsAuthDTO, + TUpdateAwsAuthDTO +} from "./identity-aws-auth-types"; -type TIdentityAwsIamAuthServiceFactoryDep = { +type TIdentityAwsAuthServiceFactoryDep = { identityAccessTokenDAL: Pick; - identityAwsIamAuthDAL: Pick; + identityAwsAuthDAL: Pick; identityOrgMembershipDAL: Pick; identityDAL: Pick; licenseService: Pick; permissionService: Pick; }; -export type TIdentityAwsIamAuthServiceFactory = ReturnType; +export type TIdentityAwsAuthServiceFactory = ReturnType; -export const identityAwsIamAuthServiceFactory = ({ +export const identityAwsAuthServiceFactory = ({ identityAccessTokenDAL, - identityAwsIamAuthDAL, + identityAwsAuthDAL, identityOrgMembershipDAL, identityDAL, licenseService, permissionService -}: TIdentityAwsIamAuthServiceFactoryDep) => { - const login = async ({ - identityId, - iamHttpRequestMethod, - iamRequestBody, - iamRequestHeaders - }: TLoginAWSIAMAuthDTO) => { - const identityAwsIamAuth = await identityAwsIamAuthDAL.findOne({ identityId }); - if (!identityAwsIamAuth) throw new UnauthorizedError(); +}: TIdentityAwsAuthServiceFactoryDep) => { + const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { + const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); + if (!identityAwsAuth) throw new UnauthorizedError(); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsIamAuth.identityId }); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsAuth.identityId }); - const headers: TAWSGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); + const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const body: string = Buffer.from(iamRequestBody, "base64").toString(); const { @@ -68,15 +63,15 @@ export const identityAwsIamAuthServiceFactory = ({ } }: { data: TGetCallerIdentityResponse } = await axios({ method: iamHttpRequestMethod, - url: identityAwsIamAuth.stsEndpoint, + url: identityAwsAuth.stsEndpoint, headers, data: body }); - if (identityAwsIamAuth.allowedAccountIds) { + if (identityAwsAuth.allowedAccountIds) { // validate if Account is in the list of allowed Account IDs - const isAccountAllowed = identityAwsIamAuth.allowedAccountIds + const isAccountAllowed = identityAwsAuth.allowedAccountIds .split(",") .map((accountId) => accountId.trim()) .some((accountId) => accountId === Account); @@ -84,10 +79,10 @@ export const identityAwsIamAuthServiceFactory = ({ if (!isAccountAllowed) throw new UnauthorizedError(); } - if (identityAwsIamAuth.allowedPrincipalArns) { + if (identityAwsAuth.allowedPrincipalArns) { // validate if Arn is in the list of allowed Principal ARNs - const isArnAllowed = identityAwsIamAuth.allowedPrincipalArns + const isArnAllowed = identityAwsAuth.allowedPrincipalArns .split(",") .map((principalArn) => principalArn.trim()) .some((principalArn) => { @@ -100,15 +95,15 @@ export const identityAwsIamAuthServiceFactory = ({ if (!isArnAllowed) throw new UnauthorizedError(); } - const identityAccessToken = await identityAwsIamAuthDAL.transaction(async (tx) => { + const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { const newToken = await identityAccessTokenDAL.create( { - identityId: identityAwsIamAuth.identityId, + identityId: identityAwsAuth.identityId, isAccessTokenRevoked: false, - accessTokenTTL: identityAwsIamAuth.accessTokenTTL, - accessTokenMaxTTL: identityAwsIamAuth.accessTokenMaxTTL, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityAwsIamAuth.accessTokenNumUsesLimit + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit }, tx ); @@ -118,7 +113,7 @@ export const identityAwsIamAuthServiceFactory = ({ const appCfg = getConfig(); const accessToken = jwt.sign( { - identityId: identityAwsIamAuth.identityId, + identityId: identityAwsAuth.identityId, identityAccessTokenId: identityAccessToken.id, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN } as TIdentityAccessTokenJwtPayload, @@ -131,10 +126,10 @@ export const identityAwsIamAuthServiceFactory = ({ } ); - return { accessToken, identityAwsIamAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityAwsAuth, identityAccessToken, identityMembershipOrg }; }; - const attachAwsIamAuth = async ({ + const attachAwsAuth = async ({ identityId, stsEndpoint, allowedPrincipalArns, @@ -147,12 +142,12 @@ export const identityAwsIamAuthServiceFactory = ({ actorAuthMethod, actor, actorOrgId - }: TAttachAWSIAMAuthDTO) => { + }: TAttachAwsAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); if (identityMembershipOrg.identity.authMethod) throw new BadRequestError({ - message: "Failed to add AWS IAM Auth to already configured identity" + message: "Failed to add AWS Auth to already configured identity" }); if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { @@ -186,10 +181,11 @@ export const identityAwsIamAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); - const identityAwsIamAuth = await identityAwsIamAuthDAL.transaction(async (tx) => { - const doc = await identityAwsIamAuthDAL.create( + const identityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { + const doc = await identityAwsAuthDAL.create( { identityId: identityMembershipOrg.identityId, + type: "iam", stsEndpoint, allowedPrincipalArns, allowedAccountIds, @@ -203,16 +199,16 @@ export const identityAwsIamAuthServiceFactory = ({ await identityDAL.updateById( identityMembershipOrg.identityId, { - authMethod: IdentityAuthMethod.AWS_IAM_AUTH + authMethod: IdentityAuthMethod.AWS_AUTH }, tx ); return doc; }); - return { ...identityAwsIamAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityAwsAuth, orgId: identityMembershipOrg.orgId }; }; - const updateAwsIamAuth = async ({ + const updateAwsAuth = async ({ identityId, stsEndpoint, allowedPrincipalArns, @@ -225,20 +221,19 @@ export const identityAwsIamAuthServiceFactory = ({ actorAuthMethod, actor, actorOrgId - }: TUpdateAWSIAMAuthDTO) => { + }: TUpdateAwsAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); - if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_IAM_AUTH) + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) throw new BadRequestError({ - message: "Failed to update AWS IAM Auth" + message: "Failed to update AWS Auth" }); - const identityAwsIamAuth = await identityAwsIamAuthDAL.findOne({ identityId }); + const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); if ( - (accessTokenMaxTTL || identityAwsIamAuth.accessTokenMaxTTL) > 0 && - (accessTokenTTL || identityAwsIamAuth.accessTokenMaxTTL) > - (accessTokenMaxTTL || identityAwsIamAuth.accessTokenMaxTTL) + (accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityAwsAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL) ) { throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } @@ -270,7 +265,7 @@ export const identityAwsIamAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); - const updatedAwsIamAuth = await identityAwsIamAuthDAL.updateById(identityAwsIamAuth.id, { + const updatedAwsAuth = await identityAwsAuthDAL.updateById(identityAwsAuth.id, { stsEndpoint, allowedPrincipalArns, allowedAccountIds, @@ -282,18 +277,18 @@ export const identityAwsIamAuthServiceFactory = ({ : undefined }); - return { ...updatedAwsIamAuth, orgId: identityMembershipOrg.orgId }; + return { ...updatedAwsAuth, orgId: identityMembershipOrg.orgId }; }; - const getAwsIamAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAWSIAMAuthDTO) => { + const getAwsAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAwsAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); - if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_IAM_AUTH) + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) throw new BadRequestError({ - message: "The identity does not have AWS IAM Auth attached" + message: "The identity does not have AWS Auth attached" }); - const awsIamIdentityAuth = await identityAwsIamAuthDAL.findOne({ identityId }); + const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId }); const { permission } = await permissionService.getOrgPermission( actor, @@ -303,13 +298,13 @@ export const identityAwsIamAuthServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); - return { ...awsIamIdentityAuth, orgId: identityMembershipOrg.orgId }; + return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId }; }; return { login, - attachAwsIamAuth, - updateAwsIamAuth, - getAwsIamAuth + attachAwsAuth, + updateAwsAuth, + getAwsAuth }; }; diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-types.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts similarity index 86% rename from backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-types.ts rename to backend/src/services/identity-aws-auth/identity-aws-auth-types.ts index 19f27f430..e45783ae1 100644 --- a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-types.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts @@ -1,13 +1,13 @@ import { TProjectPermission } from "@app/lib/types"; -export type TLoginAWSIAMAuthDTO = { +export type TLoginAwsAuthDTO = { identityId: string; iamHttpRequestMethod: string; iamRequestBody: string; iamRequestHeaders: string; }; -export type TAttachAWSIAMAuthDTO = { +export type TAttachAwsAuthDTO = { identityId: string; stsEndpoint: string; allowedPrincipalArns: string; @@ -18,7 +18,7 @@ export type TAttachAWSIAMAuthDTO = { accessTokenTrustedIps: { ipAddress: string }[]; } & Omit; -export type TUpdateAWSIAMAuthDTO = { +export type TUpdateAwsAuthDTO = { identityId: string; stsEndpoint?: string; allowedPrincipalArns?: string; @@ -29,11 +29,11 @@ export type TUpdateAWSIAMAuthDTO = { accessTokenTrustedIps?: { ipAddress: string }[]; } & Omit; -export type TGetAWSIAMAuthDTO = { +export type TGetAwsAuthDTO = { identityId: string; } & Omit; -export type TAWSGetCallerIdentityHeaders = { +export type TAwsGetCallerIdentityHeaders = { "Content-Type": string; Host: string; "X-Amz-Date": string; diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts similarity index 100% rename from backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-validators.ts rename to backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts diff --git a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts b/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts deleted file mode 100644 index 584ac775f..000000000 --- a/backend/src/services/identity-aws-iam-auth/identity-aws-iam-auth-dal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TIdentityAwsIamAuthDALFactory = ReturnType; - -export const identityAwsIamAuthDALFactory = (db: TDbClient) => { - const awsIamAuthOrm = ormify(db, TableName.IdentityAwsIamAuth); - - return awsIamAuthOrm; -}; diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index 144691ac4..06455092d 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -51,7 +51,7 @@ As a result, the 3 main concepts that are important to understand are: - **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. - **[Clients](/integrations/platforms/kubernetes)**: Infisical-developed tools for managing secrets in various infrastructure components (e.g., [Kubernetes Operator](/integrations/platforms/kubernetes), [Infisical Agent](/integrations/platforms/infisical-agent), [CLI](/cli/usage), [SDKs](/sdks/overview), [API](/api-reference/overview/introduction), [Web Dashboard](/documentation/platform/organization)). -- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, AWS IAM Auth etc.). +- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, AWS Auth etc.). ## How to get started with Infisical? diff --git a/docs/documentation/platform/identities/aws-iam-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx similarity index 92% rename from docs/documentation/platform/identities/aws-iam-auth.mdx rename to docs/documentation/platform/identities/aws-auth.mdx index 510aa0d01..e4c6ff0de 100644 --- a/docs/documentation/platform/identities/aws-iam-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -1,34 +1,34 @@ --- -title: AWS IAM Auth +title: AWS Auth description: "Learn how to authenticate with Infisical for EC2 instances, Lambda functions, and other IAM principals." --- -**AWS IAM Auth** is an AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to access Infisical. +**AWS Auth** is an AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to access Infisical. ## Concept -At a high-level, Infisical authenticates an IAM principal by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed IAM principal ARN) at the `/api/v1/auth/aws-iam-auth/login` endpoint. If successful, +At a high-level, Infisical authenticates an IAM principal by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed IAM principal ARN) at the `/api/v1/auth/aws-auth/login` endpoint. If successful, then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. -In AWS IAM Auth, an IAM principal signs a `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html); this is done using the credentials from the AWS environment where the IAM principal is running. +In AWS Auth, an IAM principal signs a `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html); this is done using the credentials from the AWS environment where the IAM principal is running. The query data including the request method, request body, and request headers are sent to Infisical afterwhich Infisical forwards the signed query to AWS STS API via the [sts:GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) method to verify and obtain the identity of the IAM principal. Once obtained, the identity information is verified against specified requirements such as if the associated IAM principal ARN is allowed to authenticate with Infisical. If all is well, Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. We recommend using one of Infisical's clients like SDKs or the Infisical Agent -to authenticate with Infisical using AWS IAM Auth as they handle the +to authenticate with Infisical using AWS Auth as they handle the authentication process including the signed `GetCallerIdentity` query construction for you. Also, note that Infisical needs network-level access to send requests to the AWS STS API -as part of the AWS IAM Auth workflow. +as part of the AWS Auth workflow. ## Workflow In the following steps, we explore how to create and use identities for your workloads and applications on AWS to -access the Infisical API using the AWS IAM authentication method. +access the Infisical API using the AWS Auth authentication method. @@ -45,9 +45,9 @@ access the Infisical API using the AWS IAM authentication method. - Name (required): A friendly name for the identity. - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. - Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **AWS IAM Auth**. + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **AWS Auth**. - ![identities create iam auth method](/images/platform/identities/identities-org-create-aws-iam-auth-method.png) + ![identities create aws auth method](/images/platform/identities/identities-org-create-aws-auth-method.png) Here's some more guidance on each field: @@ -71,7 +71,7 @@ access the Infisical API using the AWS IAM authentication method. ![identities project create](/images/platform/identities/identities-project-create.png) - To access the Infisical API as the identity, you need to construct a signed `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html) and make a request to the `/api/v1/auth/aws-iam-auth/login` endpoint containing the query data + To access the Infisical API as the identity, you need to construct a signed `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html) and make a request to the `/api/v1/auth/aws-auth/login` endpoint containing the query data in exchange for an access token. We provide a few code examples below of how you can authenticate with Infisical from inside a Lambda function, EC2 instance, etc. and obtain an access token to access the [Infisical API](/api-reference/overview/introduction). @@ -119,7 +119,7 @@ access the Infisical API using the AWS IAM authentication method. const identityId = ""; const { data } = await axios.post( - `${infisicalUrl}/api/v1/auth/aws-iam-auth/login`, + `${infisicalUrl}/api/v1/auth/aws-auth/login`, { identityId, iamHttpRequestMethod: "POST", @@ -191,7 +191,7 @@ access the Infisical API using the AWS IAM authentication method. const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL const identityId = ""; - const { data } = await axios.post(`${infisicalUrl}/api/v1/auth/aws-iam-auth/login`, { + const { data } = await axios.post(`${infisicalUrl}/api/v1/auth/aws-auth/login`, { identityId, iamHttpRequestMethod: "POST", iamRequestUrl: Buffer.from(iamRequestURL).toString("base64"), @@ -239,7 +239,7 @@ access the Infisical API using the AWS IAM authentication method. #### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/auth/aws-iam-auth/login' \ + curl --location --request POST 'https://app.infisical.com/api/v1/auth/aws-auth/login' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'identityId=...' \ --data-urlencode 'iamHttpRequestMethod=...' \ @@ -263,7 +263,7 @@ access the Infisical API using the AWS IAM authentication method. - We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using AWS IAM Auth as they handle the authentication process including the signed `GetCallerIdentity` query construction for you. + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using AWS Auth as they handle the authentication process including the signed `GetCallerIdentity` query construction for you. diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 9168cb230..e70458c9c 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -7,7 +7,7 @@ description: "Learn how to use Machine Identities to programmatically interact w An Infisical machine identity is an entity that represents a workload or application that require access to various resources in Infisical. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). -Each identity must authenticate using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS IAM Auth](/documentation/platform/identities/aws-iam-auth) to get back a short-lived access token to be used in subsequent requests. +Each identity must authenticate using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS Auth](/documentation/platform/identities/aws-auth) to get back a short-lived access token to be used in subsequent requests. ![organization identities](/images/platform/organization/organization-machine-identities.png) @@ -21,7 +21,7 @@ Key Features: A typical workflow for using identities consists of four steps: 1. Creating the identity with a name and [role](/documentation/platform/role-based-access-controls) in Organization Access Control > Machine Identities. - This step also involves configuring an authentication method for it such as [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS IAM Auth](/documentation/platform/identities/aws-iam-auth). + This step also involves configuring an authentication method for it such as [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS Auth](/documentation/platform/identities/aws-auth). 2. Adding the identity to the project(s) you want it to have access to. 3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. 4. Authenticating subsequent requests with the Infisical API using the short-lived access token. @@ -38,7 +38,7 @@ Machine Identity support for the rest of the clients is planned to be released i To interact with various resources in Infisical, Machine Identities are able to authenticate using: - [Universal Auth](/documentation/platform/identities/universal-auth): A platform-agnostic authentication method that can be configured on an identity suitable to authenticate from any platform/environment. -- [AWS IAM Auth](/documentation/platform/identities/aws-iam-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. +- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. ## FAQ diff --git a/docs/images/platform/identities/identities-org-create-aws-iam-auth-method.png b/docs/images/platform/identities/identities-org-create-aws-auth-method.png similarity index 100% rename from docs/images/platform/identities/identities-org-create-aws-iam-auth-method.png rename to docs/images/platform/identities/identities-org-create-aws-auth-method.png diff --git a/docs/mint.json b/docs/mint.json index 71fe02a1f..d499f3cac 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -153,7 +153,7 @@ "documentation/platform/auth-methods/email-password", "documentation/platform/token", "documentation/platform/identities/universal-auth", - "documentation/platform/identities/aws-iam-auth", + "documentation/platform/identities/aws-auth", "documentation/platform/mfa", { "group": "SSO", diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 5e8ae069f..64f9469c6 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -2,5 +2,5 @@ import { IdentityAuthMethod } from "./enums"; export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", - [IdentityAuthMethod.AWS_IAM_AUTH]: "AWS IAM Auth" + [IdentityAuthMethod.AWS_AUTH]: "AWS Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 614549da3..dc619f722 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -1,4 +1,4 @@ export enum IdentityAuthMethod { UNIVERSAL_AUTH = "universal-auth", - AWS_IAM_AUTH = "aws-iam-auth" + AWS_AUTH = "aws-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 16fc56782..cef827d9f 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -1,16 +1,18 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { - useAddIdentityAwsIamAuth, + useAddIdentityAwsAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, useDeleteIdentity, useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, - useUpdateIdentityAwsIamAuth, - useUpdateIdentityUniversalAuth} from "./mutations"; + useUpdateIdentityAwsAuth, + useUpdateIdentityUniversalAuth +} from "./mutations"; export { - useGetIdentityAwsIamAuth, + useGetIdentityAwsAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets} from "./queries"; + useGetIdentityUniversalAuthClientSecrets +} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index fa956af13..8780f178d 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { - AddIdentityAwsIamAuthDTO, + AddIdentityAwsAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, @@ -14,11 +14,12 @@ import { DeleteIdentityDTO, DeleteIdentityUniversalAuthClientSecretDTO, Identity, - IdentityAwsIamAuth, + IdentityAwsAuth, IdentityUniversalAuth, - UpdateIdentityAwsIamAuthDTO, + UpdateIdentityAwsAuthDTO, UpdateIdentityDTO, - UpdateIdentityUniversalAuthDTO} from "./types"; + UpdateIdentityUniversalAuthDTO +} from "./types"; export const useCreateIdentity = () => { const queryClient = useQueryClient(); @@ -172,9 +173,9 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { }); }; -export const useAddIdentityAwsIamAuth = () => { +export const useAddIdentityAwsAuth = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async ({ identityId, stsEndpoint, @@ -186,9 +187,9 @@ export const useAddIdentityAwsIamAuth = () => { accessTokenTrustedIps }) => { const { - data: { identityAwsIamAuth } - } = await apiRequest.post<{ identityAwsIamAuth: IdentityAwsIamAuth }>( - `/api/v1/auth/aws-iam-auth/identities/${identityId}`, + data: { identityAwsAuth } + } = await apiRequest.post<{ identityAwsAuth: IdentityAwsAuth }>( + `/api/v1/auth/aws-auth/identities/${identityId}`, { stsEndpoint, allowedPrincipalArns, @@ -200,7 +201,7 @@ export const useAddIdentityAwsIamAuth = () => { } ); - return identityAwsIamAuth; + return identityAwsAuth; }, onSuccess: (_, { organizationId }) => { queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); @@ -208,9 +209,9 @@ export const useAddIdentityAwsIamAuth = () => { }); }; -export const useUpdateIdentityAwsIamAuth = () => { +export const useUpdateIdentityAwsAuth = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async ({ identityId, stsEndpoint, @@ -222,9 +223,9 @@ export const useUpdateIdentityAwsIamAuth = () => { accessTokenTrustedIps }) => { const { - data: { identityAwsIamAuth } - } = await apiRequest.patch<{ identityAwsIamAuth: IdentityAwsIamAuth }>( - `/api/v1/auth/aws-iam-auth/identities/${identityId}`, + data: { identityAwsAuth } + } = await apiRequest.patch<{ identityAwsAuth: IdentityAwsAuth }>( + `/api/v1/auth/aws-auth/identities/${identityId}`, { stsEndpoint, allowedPrincipalArns, @@ -235,7 +236,7 @@ export const useUpdateIdentityAwsIamAuth = () => { accessTokenTrustedIps } ); - return identityAwsIamAuth; + return identityAwsAuth; }, onSuccess: (_, { organizationId }) => { queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 5d4a4aeef..26880d5ea 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -2,14 +2,14 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ClientSecretData, IdentityAwsIamAuth,IdentityUniversalAuth } from "./types"; +import { ClientSecretData, IdentityAwsAuth, IdentityUniversalAuth } from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, getIdentityUniversalAuthClientSecrets: (identityId: string) => [{ identityId }, "identity-universal-auth-client-secrets"] as const, - getIdentityAwsIamAuth: (identityId: string) => [{ identityId }, "identity-aws-iam-auth"] as const + getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { @@ -42,17 +42,17 @@ export const useGetIdentityUniversalAuthClientSecrets = (identityId: string) => }); }; -export const useGetIdentityAwsIamAuth = (identityId: string) => { +export const useGetIdentityAwsAuth = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), - queryKey: identitiesKeys.getIdentityAwsIamAuth(identityId), + queryKey: identitiesKeys.getIdentityAwsAuth(identityId), queryFn: async () => { const { - data: { identityAwsIamAuth } - } = await apiRequest.get<{ identityAwsIamAuth: IdentityAwsIamAuth }>( - `/api/v1/auth/aws-iam-auth/identities/${identityId}` + data: { identityAwsAuth } + } = await apiRequest.get<{ identityAwsAuth: IdentityAwsAuth }>( + `/api/v1/auth/aws-auth/identities/${identityId}` ); - return identityAwsIamAuth; + return identityAwsAuth; } }); }; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 02b0e9f53..1a7ba263f 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -113,8 +113,9 @@ export type UpdateIdentityUniversalAuthDTO = { }[]; }; -export type IdentityAwsIamAuth = { +export type IdentityAwsAuth = { identityId: string; + type: "iam"; stsEndpoint: string; allowedPrincipalArns: string; allowedAccountIds: string; @@ -124,7 +125,7 @@ export type IdentityAwsIamAuth = { accessTokenTrustedIps: IdentityTrustedIp[]; }; -export type AddIdentityAwsIamAuthDTO = { +export type AddIdentityAwsAuthDTO = { organizationId: string; identityId: string; stsEndpoint: string; @@ -138,7 +139,7 @@ export type AddIdentityAwsIamAuthDTO = { }[]; }; -export type UpdateIdentityAwsIamAuthDTO = { +export type UpdateIdentityAwsAuthDTO = { organizationId: string; identityId: string; stsEndpoint?: string; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx index 2cd4cc334..22fbfee5e 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -14,7 +14,7 @@ import { import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; -import { IdentityAwsIamAuthForm } from "./IdentityAwsIamAuthForm"; +import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; type Props = { @@ -28,7 +28,7 @@ type Props = { const identityAuthMethods = [ { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, - { label: "AWS IAM Auth", value: IdentityAuthMethod.AWS_IAM_AUTH } + { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH } ]; const schema = yup @@ -66,9 +66,9 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog const renderIdentityAuthForm = () => { switch (identityAuthMethodData?.authMethod ?? authMethod) { - case IdentityAuthMethod.AWS_IAM_AUTH: { + case IdentityAuthMethod.AWS_AUTH: { return ( - Date: Thu, 9 May 2024 00:27:21 -0400 Subject: [PATCH 225/244] Update build-staging-and-deploy-aws.yml --- .github/workflows/build-staging-and-deploy-aws.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml index 1addd7ad6..49728038e 100644 --- a/.github/workflows/build-staging-and-deploy-aws.yml +++ b/.github/workflows/build-staging-and-deploy-aws.yml @@ -80,15 +80,15 @@ jobs: uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: task-definition.json - container-name: infisical-prod-platform + container-name: infisical-core-platform image: infisical/staging_infisical:${{ steps.commit.outputs.short }} environment-variables: "LOG_LEVEL=info" - name: Deploy to Amazon ECS service uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.render-web-container.outputs.task-definition }} - service: infisical-prod-platform - cluster: infisical-prod-platform + service: infisical-core-platform + cluster: infisical-core-platform wait-for-service-stability: true production-postgres-deployment: From 79704e9c98187a60e298c8f23fe2e76b19bed341 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Wed, 8 May 2024 21:49:09 -0700 Subject: [PATCH 226/244] add option to not delete secrets in parameter store --- backend/src/lib/api-docs/constants.ts | 3 +- .../server/routes/v1/integration-router.ts | 3 +- .../integration-sync-secret.ts | 30 ++++++++++--------- .../services/integration/integration-types.ts | 1 + .../src/hooks/api/integrations/queries.tsx | 1 + .../aws-parameter-store/create.tsx | 13 +++++++- pg-migrator/src/models/integration/types.ts | 1 + 7 files changed, 35 insertions(+), 17 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 336385472..83e5c11ae 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -625,7 +625,8 @@ export const INTEGRATION = { shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", - kmsKeyId: "The ID of the encryption key from AWS KMS." + kmsKeyId: "The ID of the encryption key from AWS KMS.", + shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store." } }, UPDATE: { diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index f908aa1fc..975e6e7c8 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -66,7 +66,8 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { ) .optional() .describe(INTEGRATION.CREATE.metadata.secretAWSTag), - kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId) + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) }) .default({}) }), diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 168159847..4e1dbd7ae 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -517,20 +517,22 @@ const syncSecretsAWSParameterStore = async ({ }) ); - // Identify secrets to delete - await Promise.all( - Object.keys(awsParameterStoreSecretsObj).map(async (key) => { - if (!(key in secrets)) { - // case: - // -> delete secret - await ssm - .deleteParameter({ - Name: awsParameterStoreSecretsObj[key].Name as string - }) - .promise(); - } - }) - ); + if (!metadata.shouldDisableDelete) { + // Identify secrets to delete + await Promise.all( + Object.keys(awsParameterStoreSecretsObj).map(async (key) => { + if (!(key in secrets)) { + // case: + // -> delete secret + await ssm + .deleteParameter({ + Name: awsParameterStoreSecretsObj[key].Name as string + }) + .promise(); + } + }) + ); + } }; /** diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 56ea46350..1913dd31f 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -27,6 +27,7 @@ export type TCreateIntegrationDTO = { value: string; }[]; kmsKeyId?: string; + shouldDisableDelete?: boolean; }; } & Omit; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index b855dbf73..2d4c8a313 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -68,6 +68,7 @@ export const useCreateIntegration = () => { value: string; }[]; kmsKeyId?: string; + shouldDisableDelete?: boolean; }; }) => { const { diff --git a/frontend/src/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx index cc80d2f29..ee9bf30b9 100644 --- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx +++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx @@ -89,6 +89,7 @@ export default function AWSParameterStoreCreateIntegrationPage() { const [isLoading, setIsLoading] = useState(false); const [shouldTag, setShouldTag] = useState(false); + const [shouldDisableDelete, setShouldDisableDelete] = useState(false); const [tagKey, setTagKey] = useState(""); const [tagValue, setTagValue] = useState(""); const [kmsKeyId, setKmsKeyId] = useState(""); @@ -144,7 +145,8 @@ export default function AWSParameterStoreCreateIntegrationPage() { ] } : {}), - ...(kmsKeyId && { kmsKeyId }) + ...(kmsKeyId && { kmsKeyId }), + ...(shouldDisableDelete && { shouldDisableDelete }) } }); @@ -273,6 +275,15 @@ export default function AWSParameterStoreCreateIntegrationPage() { exit={{ opacity: 0, translateX: 30 }} >
+ setShouldDisableDelete(!shouldDisableDelete)} + isChecked={shouldDisableDelete} + > + Disable deleting secrets in AWS Parameter Store + +
+
setShouldTag(!shouldTag)} diff --git a/pg-migrator/src/models/integration/types.ts b/pg-migrator/src/models/integration/types.ts index 635182cd2..6bd0bb1a4 100644 --- a/pg-migrator/src/models/integration/types.ts +++ b/pg-migrator/src/models/integration/types.ts @@ -10,4 +10,5 @@ export type Metadata = { value: string; }[] kmsKeyId?: string; + shouldDisableDelete?: boolean; } \ No newline at end of file From 42471b22bb3a783842a7af73ef8e7ff1a5da6014 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 8 May 2024 21:52:56 -0700 Subject: [PATCH 227/244] Finish AWS Auth mermaid diagram --- .../platform/identities/aws-auth.mdx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index e4c6ff0de..cf76ffce7 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -5,6 +5,32 @@ description: "Learn how to authenticate with Infisical for EC2 instances, Lambda **AWS Auth** is an AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to access Infisical. +## Diagram + +The following sequence digram illustrates the AWS Auth workflow for authenticating AWS IAM principals with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical + participant AWS as AWS STS + + Note over Client,Client: Step 1: Sign GetCallerIdentityQuery + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send signed query details /api/v1/auth/aws-auth/login + + Note over Infis,AWS: Step 3: Query verification + Infis->>AWS: Forward signed GetCallerIdentity query + AWS-->>Infis: Return IAM user/role details + + Note over Infis: Step 4: Identity Property Validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 4: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + ## Concept At a high-level, Infisical authenticates an IAM principal by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed IAM principal ARN) at the `/api/v1/auth/aws-auth/login` endpoint. If successful, From 5a92520ca3353a159338daf3749c95bac4bec4b8 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 9 May 2024 00:53:42 -0400 Subject: [PATCH 228/244] Update build-staging-and-deploy-aws.yml --- .github/workflows/build-staging-and-deploy-aws.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml index 49728038e..4341d19f5 100644 --- a/.github/workflows/build-staging-and-deploy-aws.yml +++ b/.github/workflows/build-staging-and-deploy-aws.yml @@ -74,7 +74,7 @@ jobs: uses: pr-mpt/actions-commit-hash@v2 - name: Download task definition run: | - aws ecs describe-task-definition --task-definition infisical-prod-platform --query taskDefinition > task-definition.json + aws ecs describe-task-definition --task-definition infisical-core-platform --query taskDefinition > task-definition.json - name: Render Amazon ECS task definition id: render-web-container uses: aws-actions/amazon-ecs-render-task-definition@v1 From b4f1cce587ec2cd36b5c79ae0d939205cf2cfc0e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 8 May 2024 22:03:57 -0700 Subject: [PATCH 229/244] Add mermaid diagram for universal auth --- .../platform/identities/universal-auth.mdx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index cd40a9e64..bad96c0e3 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -5,6 +5,25 @@ description: "Learn how to authenticate to Infisical from any platform or enviro **Universal Auth** is a platform-agnostic authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) suitable to authenticate from any platform/environment. +## Diagram + +The following sequence digram illustrates the Universal Auth workflow for authenticating clients with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical + + Note over Client,Infis: Step 1: Login Operation + Client->>Infis: Send Client ID and Client Secret + + Note over Infis: Step 2: Client ID and Client Secret validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 3: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + ## Concept In this method, Infisical authenticates an identity by verifying the credentials issued for it at the `/api/v1/auth/universal-auth/login` endpoint. If successful, @@ -12,7 +31,7 @@ then Infisical returns a short-lived access token that can be used to make authe In Universal Auth, an identity is given a **Client ID** and one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for a short-lived access token to authenticate with the Infisical API. -## Workflow +## Guide In the following steps, we explore how to create and use identities for your workloads and applications to access the Infisical API using the Universal Auth authentication method. From 20f29c752dd07316bc763bd8876f614d590a37eb Mon Sep 17 00:00:00 2001 From: = Date: Thu, 9 May 2024 21:23:57 +0530 Subject: [PATCH 230/244] fix: added workspaceSlug support get secret by key --- backend/src/lib/api-docs/constants.ts | 3 ++- backend/src/server/routes/v3/secret-router.ts | 13 +++++++++---- backend/src/services/secret/secret-service.ts | 5 ++++- backend/src/services/secret/secret-types.ts | 4 +++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 10006b9ef..b8ad185a0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -276,7 +276,7 @@ export const RAW_SECRETS = { recursive: "Whether or not to fetch all secrets from the specified base path, and all of its subdirectories. Note, the max depth is 20 deep.", workspaceId: "The ID of the project to list secrets from.", - workspaceSlug: "The slug of the project to list secrets from. This parameter is only usable by machine identities.", + workspaceslug: "the slug of the project to list secrets from. this parameter is only usable by machine identities.", environment: "The slug of the environment to list secrets from.", secretPath: "The secret path to list secrets from.", includeImports: "Weather to include imported secrets or not." @@ -295,6 +295,7 @@ export const RAW_SECRETS = { GET: { secretName: "The name of the secret to get.", workspaceId: "The ID of the project to get the secret from.", + workspaceSlug: "The slug of the project to get the secret from.", environment: "The slug of the environment to get the secret from.", secretPath: "The path of the secret to get.", version: "The version of the secret to get.", diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index cae51f858..b16b4706d 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -163,7 +163,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { ], querystring: z.object({ workspaceId: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceId), - workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceSlug), + workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceslug), environment: z.string().trim().optional().describe(RAW_SECRETS.LIST.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.LIST.secretPath), expandSecretReferences: z @@ -293,6 +293,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }), querystring: z.object({ workspaceId: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceId), + workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceSlug), environment: z.string().trim().optional().describe(RAW_SECRETS.GET.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.GET.secretPath), version: z.coerce.number().optional().describe(RAW_SECRETS.GET.version), @@ -311,6 +312,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { workspaceSlug } = req.query; let { secretPath, environment, workspaceId } = req.query; if (req.auth.actor === ActorType.SERVICE) { const scope = ServiceTokenScopes.parse(req.auth.serviceToken.scopes); @@ -322,7 +324,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } } - if (!workspaceId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); + if (!environment) throw new BadRequestError({ message: "Missing environment" }); + if (!workspaceId && !workspaceSlug) + throw new BadRequestError({ message: "You must provide workspaceSlug or workspaceId" }); const secret = await server.services.secret.getSecretByNameRaw({ actorId: req.permission.id, @@ -331,6 +335,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, environment, projectId: workspaceId, + projectSlug: workspaceSlug, path: secretPath, secretName: req.params.secretName, type: req.query.type, @@ -339,7 +344,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); await server.services.auditLog.createAuditLog({ - projectId: req.query.workspaceId, + projectId: secret.workspace, ...req.auditLogInfo, event: { type: EventType.GET_SECRET, @@ -358,7 +363,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, - workspaceId, + workspaceId: secret.workspace, environment, secretPath: req.query.secretPath, channel: getUserAgentType(req.headers["user-agent"]), diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 3f647d8a9..feb8c01d5 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -972,7 +972,8 @@ export const secretServiceFactory = ({ path, actor, environment, - projectId, + projectId: workspaceId, + projectSlug, actorId, actorOrgId, actorAuthMethod, @@ -980,6 +981,8 @@ export const secretServiceFactory = ({ includeImports, version }: TGetASecretRawDTO) => { + const projectId = workspaceId || (await projectDAL.findProjectBySlug(projectSlug as string, actorOrgId)).id; + const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index df0af5b5d..9f2addc89 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -152,7 +152,9 @@ export type TGetASecretRawDTO = { type: "shared" | "personal"; includeImports?: boolean; version?: number; -} & TProjectPermission; + projectSlug?: string; + projectId?: string; +} & Omit; export type TCreateSecretRawDTO = TProjectPermission & { secretPath: string; From 1da4cf85f8b4be4fb94959f4486b4e27c4af3c8c Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 9 May 2024 11:59:47 -0400 Subject: [PATCH 231/244] rename schema file --- ...entity-aws-iam-auth.ts => 20240507210655_identity-aws-auth.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240507210655_identity-aws-iam-auth.ts => 20240507210655_identity-aws-auth.ts} (100%) diff --git a/backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts b/backend/src/db/migrations/20240507210655_identity-aws-auth.ts similarity index 100% rename from backend/src/db/migrations/20240507210655_identity-aws-iam-auth.ts rename to backend/src/db/migrations/20240507210655_identity-aws-auth.ts From 21c339d27a62bc43ee4529a573ca1c12f7a545e8 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 9 May 2024 21:27:34 +0530 Subject: [PATCH 232/244] fix: better error message on ua based login error --- backend/src/lib/api-docs/constants.ts | 2 +- backend/src/server/routes/v3/secret-router.ts | 2 +- backend/src/services/identity-ua/identity-ua-service.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index b8ad185a0..cd5d2f4b3 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -276,7 +276,7 @@ export const RAW_SECRETS = { recursive: "Whether or not to fetch all secrets from the specified base path, and all of its subdirectories. Note, the max depth is 20 deep.", workspaceId: "The ID of the project to list secrets from.", - workspaceslug: "the slug of the project to list secrets from. this parameter is only usable by machine identities.", + workspaceSlug: "the slug of the project to list secrets from. this parameter is only usable by machine identities.", environment: "The slug of the environment to list secrets from.", secretPath: "The secret path to list secrets from.", includeImports: "Weather to include imported secrets or not." diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index b16b4706d..e2d8ddb12 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -163,7 +163,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { ], querystring: z.object({ workspaceId: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceId), - workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceslug), + workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceSlug), environment: z.string().trim().optional().describe(RAW_SECRETS.LIST.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.LIST.secretPath), expandSecretReferences: z diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 54a074073..5e940871b 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -52,7 +52,7 @@ export const identityUaServiceFactory = ({ }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); - if (!identityUa) throw new UnauthorizedError(); + if (!identityUa) throw new UnauthorizedError({ message: "Invalid credentials" }); const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); @@ -68,7 +68,7 @@ export const identityUaServiceFactory = ({ const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) => bcrypt.compareSync(clientSecret, clientSecretHash) ); - if (!validClientSecretInfo) throw new UnauthorizedError(); + if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; if (Number(clientSecretTTL) > 0) { From 4adc2c49279bbdb71896761d467c21e2e2ad3564 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 9 May 2024 12:11:46 -0400 Subject: [PATCH 233/244] update api descriptions --- backend/src/lib/api-docs/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index cd5d2f4b3..e859d5c54 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -276,7 +276,8 @@ export const RAW_SECRETS = { recursive: "Whether or not to fetch all secrets from the specified base path, and all of its subdirectories. Note, the max depth is 20 deep.", workspaceId: "The ID of the project to list secrets from.", - workspaceSlug: "the slug of the project to list secrets from. this parameter is only usable by machine identities.", + workspaceSlug: + "The slug of the project to list secrets from. This parameter is only applicable by machine identities.", environment: "The slug of the environment to list secrets from.", secretPath: "The secret path to list secrets from.", includeImports: "Weather to include imported secrets or not." From 3952ad9a2eb05b1662707d141f7263ca39cb8e76 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 9 May 2024 09:51:53 -0700 Subject: [PATCH 234/244] Update isEmailVerified field upon invite signups --- backend/src/services/org/org-service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index d7ee1ce93..60ddc5230 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -546,6 +546,10 @@ export const orgServiceFactory = ({ code }); + await userDAL.updateById(user.id, { + isEmailVerified: true + }); + if (user.isAccepted) { // this means user has already completed signup process // isAccepted is set true when keys are exchanged From 7c77a4f0498256f7c7fe0055536f81cdd52ce4ce Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 9 May 2024 13:42:23 -0400 Subject: [PATCH 235/244] revert schema name --- ...int.ts => 20240405000045_org-memberships-unique-constraint.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20240330075120_org-memberships-unique-constraint.ts => 20240405000045_org-memberships-unique-constraint.ts} (100%) diff --git a/backend/src/db/migrations/20240330075120_org-memberships-unique-constraint.ts b/backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts similarity index 100% rename from backend/src/db/migrations/20240330075120_org-memberships-unique-constraint.ts rename to backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts From 79cacfa89cd96afb5ba829c0be49bd2f14feb7f4 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 9 May 2024 12:16:13 -0700 Subject: [PATCH 236/244] Delete PG migrator folder --- pg-migrator/.gitignore | 1 - pg-migrator/package-lock.json | 1487 ---------- pg-migrator/package.json | 33 - pg-migrator/src/@types/knex.d.ts | 451 --- pg-migrator/src/audit-log-migrator.ts | 1284 --------- pg-migrator/src/folder.ts | 36 - pg-migrator/src/index.ts | 2512 ----------------- .../src/migrations/20231128072457_user.ts | 37 - .../20231128092347_user-encryption-key.ts | 31 - .../migrations/20231129072939_auth-token.ts | 25 - .../20231130072734_auth-token-session.ts | 29 - .../migrations/20231201151432_backup-key.ts | 26 - .../migrations/20231204092737_organization.ts | 35 - .../20231204092747_org-membership.ts | 48 - .../20231205151331_incident-contact.ts | 25 - .../migrations/20231207055643_user-action.ts | 20 - .../migrations/20231207055701_super-admin.ts | 23 - .../src/migrations/20231207105059_api-key.ts | 26 - .../src/migrations/20231212110939_project.ts | 62 - .../20231212110946_project-membership.ts | 43 - .../20231218092441_secret-folder.ts | 42 - .../20231218092508_secret-import.ts | 30 - .../migrations/20231218092517_secret-tag.ts | 26 - .../src/migrations/20231218103423_secret.ts | 93 - .../20231220052508_secret-version.ts | 53 - .../migrations/20231222092113_project-bot.ts | 35 - .../migrations/20231222172455_integration.ts | 71 - .../20231225072545_service-token.ts | 32 - .../src/migrations/20231225072552_webhook.ts | 32 - .../src/migrations/20231228074856_identity.ts | 21 - .../20231228074908_identity-universal-auth.ts | 49 - .../20231228075011_identity-access-token.ts | 37 - .../20231228075023_identity-membership.ts | 44 - .../20240101054849_secret-approval-policy.ts | 45 - .../20240101104907_secret-approval-request.ts | 118 - .../20240102152111_secret-rotation.ts | 47 - .../20240104140641_secret-snapshot.ts | 61 - .../migrations/20240107153439_saml-config.ts | 33 - .../src/migrations/20240107163155_org-bot.ts | 35 - .../migrations/20240108134148_audit-log.ts | 29 - .../20240111051011_secret-scanning.ts | 79 - .../migrations/20240113103743_trusted-ip.ts | 26 - pg-migrator/src/models/apiKeyData.ts | 39 - pg-migrator/src/models/apiKeyDataV2.ts | 38 - pg-migrator/src/models/auditLog/auditLog.ts | 70 - pg-migrator/src/models/auditLog/enums.ts | 69 - pg-migrator/src/models/auditLog/index.ts | 3 - pg-migrator/src/models/auditLog/types.ts | 585 ---- pg-migrator/src/models/backupPrivateKey.ts | 67 - pg-migrator/src/models/bot.ts | 68 - pg-migrator/src/models/botKey.ts | 43 - pg-migrator/src/models/botOrg.ts | 81 - pg-migrator/src/models/folder.ts | 54 - pg-migrator/src/models/folderVersion.ts | 58 - .../src/models/gitAppInstallationSession.ts | 32 - .../models/gitAppOrganizationInstallation.ts | 29 - pg-migrator/src/models/gitRisks.ts | 150 - pg-migrator/src/models/identity.ts | 38 - pg-migrator/src/models/identityAccessToken.ts | 100 - pg-migrator/src/models/identityMembership.ts | 40 - .../src/models/identityMembershipOrg.ts | 38 - .../src/models/identityUniversalAuth.ts | 99 - .../identityUniversalAuthClientSecret.ts | 81 - pg-migrator/src/models/incidentContactOrg.ts | 29 - pg-migrator/src/models/index.ts | 49 - pg-migrator/src/models/integration/index.ts | 1 - .../src/models/integration/integration.ts | 155 - pg-migrator/src/models/integration/types.ts | 14 - .../src/models/integrationAuth/index.ts | 1 - .../models/integrationAuth/integrationAuth.ts | 144 - .../src/models/integrationAuth/types.ts | 5 - pg-migrator/src/models/key.ts | 43 - pg-migrator/src/models/loginSRPDetail.ts | 27 - pg-migrator/src/models/membership.ts | 58 - pg-migrator/src/models/membershipOrg.ts | 49 - pg-migrator/src/models/organization.ts | 29 - pg-migrator/src/models/role.ts | 55 - pg-migrator/src/models/secret.ts | 160 -- .../src/models/secretApprovalPolicy.ts | 51 - .../src/models/secretApprovalRequest.ts | 195 -- .../src/models/secretBlindIndexData.ts | 49 - pg-migrator/src/models/secretImports.ts | 51 - pg-migrator/src/models/secretRotation.ts | 106 - pg-migrator/src/models/secretSnapshot.ts | 54 - pg-migrator/src/models/secretVersion.ts | 123 - pg-migrator/src/models/serverConfig.ts | 25 - pg-migrator/src/models/serviceToken.ts | 60 - pg-migrator/src/models/serviceTokenData.ts | 93 - pg-migrator/src/models/ssoConfig.ts | 72 - pg-migrator/src/models/tag.ts | 53 - pg-migrator/src/models/token.ts | 30 - pg-migrator/src/models/tokenData.ts | 53 - pg-migrator/src/models/tokenVersion.ts | 45 - pg-migrator/src/models/trustedIp.ts | 54 - pg-migrator/src/models/user.ts | 143 - pg-migrator/src/models/userAction.ts | 26 - pg-migrator/src/models/webhooks.ts | 78 - pg-migrator/src/models/workspace.ts | 52 - pg-migrator/src/rollback.ts | 29 - pg-migrator/src/schemas/api-keys.ts | 23 - pg-migrator/src/schemas/audit-logs.ts | 28 - .../src/schemas/auth-token-sessions.ts | 24 - pg-migrator/src/schemas/auth-tokens.ts | 25 - pg-migrator/src/schemas/backup-private-key.ts | 26 - .../src/schemas/git-app-install-sessions.ts | 21 - pg-migrator/src/schemas/git-app-org.ts | 21 - pg-migrator/src/schemas/identities.ts | 20 - .../src/schemas/identity-access-tokens.ts | 32 - .../src/schemas/identity-org-memberships.ts | 22 - .../schemas/identity-project-memberships.ts | 22 - .../src/schemas/identity-ua-client-secrets.ts | 34 - .../src/schemas/identity-universal-auths.ts | 25 - pg-migrator/src/schemas/incident-contacts.ts | 20 - pg-migrator/src/schemas/index.ts | 56 - pg-migrator/src/schemas/integration-auths.ts | 37 - pg-migrator/src/schemas/integrations.ts | 35 - pg-migrator/src/schemas/models.ts | 116 - pg-migrator/src/schemas/org-bots.ts | 31 - pg-migrator/src/schemas/org-memberships.ts | 24 - pg-migrator/src/schemas/org-roles.ts | 23 - pg-migrator/src/schemas/organizations.ts | 23 - pg-migrator/src/schemas/project-bots.ts | 30 - .../src/schemas/project-environments.ts | 22 - pg-migrator/src/schemas/project-keys.ts | 23 - .../src/schemas/project-memberships.ts | 22 - pg-migrator/src/schemas/project-roles.ts | 23 - pg-migrator/src/schemas/projects.ts | 22 - pg-migrator/src/schemas/saml-configs.ts | 30 - .../secret-approval-policies-approvers.ts | 20 - .../src/schemas/secret-approval-policies.ts | 22 - .../secret-approval-request-secret-tags.ts | 20 - .../secret-approval-requests-reviewers.ts | 21 - .../secret-approval-requests-secrets.ts | 39 - .../src/schemas/secret-approval-requests.ts | 26 - .../src/schemas/secret-blind-indexes.ts | 24 - .../src/schemas/secret-folder-versions.ts | 22 - pg-migrator/src/schemas/secret-folders.ts | 22 - pg-migrator/src/schemas/secret-imports.ts | 23 - .../src/schemas/secret-rotation-outputs.ts | 19 - pg-migrator/src/schemas/secret-rotations.ts | 30 - .../src/schemas/secret-scanning-git-risks.ts | 46 - .../src/schemas/secret-snapshot-folders.ts | 21 - .../src/schemas/secret-snapshot-secrets.ts | 21 - pg-migrator/src/schemas/secret-snapshots.ts | 21 - .../src/schemas/secret-tag-junction.ts | 18 - pg-migrator/src/schemas/secret-tags.ts | 23 - .../schemas/secret-version-tag-junction.ts | 18 - pg-migrator/src/schemas/secret-versions.ts | 42 - pg-migrator/src/schemas/secrets.ts | 38 - pg-migrator/src/schemas/service-tokens.ts | 29 - pg-migrator/src/schemas/super-admin.ts | 20 - pg-migrator/src/schemas/trusted-ips.ts | 24 - pg-migrator/src/schemas/user-actions.ts | 20 - .../src/schemas/user-encryption-keys.ts | 29 - pg-migrator/src/schemas/users.ts | 27 - pg-migrator/src/schemas/webhooks.ts | 29 - pg-migrator/src/utils.ts | 53 - pg-migrator/tsconfig.json | 15 - 158 files changed, 12957 deletions(-) delete mode 100644 pg-migrator/.gitignore delete mode 100644 pg-migrator/package-lock.json delete mode 100644 pg-migrator/package.json delete mode 100644 pg-migrator/src/@types/knex.d.ts delete mode 100644 pg-migrator/src/audit-log-migrator.ts delete mode 100644 pg-migrator/src/folder.ts delete mode 100644 pg-migrator/src/index.ts delete mode 100644 pg-migrator/src/migrations/20231128072457_user.ts delete mode 100644 pg-migrator/src/migrations/20231128092347_user-encryption-key.ts delete mode 100644 pg-migrator/src/migrations/20231129072939_auth-token.ts delete mode 100644 pg-migrator/src/migrations/20231130072734_auth-token-session.ts delete mode 100644 pg-migrator/src/migrations/20231201151432_backup-key.ts delete mode 100644 pg-migrator/src/migrations/20231204092737_organization.ts delete mode 100644 pg-migrator/src/migrations/20231204092747_org-membership.ts delete mode 100644 pg-migrator/src/migrations/20231205151331_incident-contact.ts delete mode 100644 pg-migrator/src/migrations/20231207055643_user-action.ts delete mode 100644 pg-migrator/src/migrations/20231207055701_super-admin.ts delete mode 100644 pg-migrator/src/migrations/20231207105059_api-key.ts delete mode 100644 pg-migrator/src/migrations/20231212110939_project.ts delete mode 100644 pg-migrator/src/migrations/20231212110946_project-membership.ts delete mode 100644 pg-migrator/src/migrations/20231218092441_secret-folder.ts delete mode 100644 pg-migrator/src/migrations/20231218092508_secret-import.ts delete mode 100644 pg-migrator/src/migrations/20231218092517_secret-tag.ts delete mode 100644 pg-migrator/src/migrations/20231218103423_secret.ts delete mode 100644 pg-migrator/src/migrations/20231220052508_secret-version.ts delete mode 100644 pg-migrator/src/migrations/20231222092113_project-bot.ts delete mode 100644 pg-migrator/src/migrations/20231222172455_integration.ts delete mode 100644 pg-migrator/src/migrations/20231225072545_service-token.ts delete mode 100644 pg-migrator/src/migrations/20231225072552_webhook.ts delete mode 100644 pg-migrator/src/migrations/20231228074856_identity.ts delete mode 100644 pg-migrator/src/migrations/20231228074908_identity-universal-auth.ts delete mode 100644 pg-migrator/src/migrations/20231228075011_identity-access-token.ts delete mode 100644 pg-migrator/src/migrations/20231228075023_identity-membership.ts delete mode 100644 pg-migrator/src/migrations/20240101054849_secret-approval-policy.ts delete mode 100644 pg-migrator/src/migrations/20240101104907_secret-approval-request.ts delete mode 100644 pg-migrator/src/migrations/20240102152111_secret-rotation.ts delete mode 100644 pg-migrator/src/migrations/20240104140641_secret-snapshot.ts delete mode 100644 pg-migrator/src/migrations/20240107153439_saml-config.ts delete mode 100644 pg-migrator/src/migrations/20240107163155_org-bot.ts delete mode 100644 pg-migrator/src/migrations/20240108134148_audit-log.ts delete mode 100644 pg-migrator/src/migrations/20240111051011_secret-scanning.ts delete mode 100644 pg-migrator/src/migrations/20240113103743_trusted-ip.ts delete mode 100644 pg-migrator/src/models/apiKeyData.ts delete mode 100644 pg-migrator/src/models/apiKeyDataV2.ts delete mode 100644 pg-migrator/src/models/auditLog/auditLog.ts delete mode 100644 pg-migrator/src/models/auditLog/enums.ts delete mode 100644 pg-migrator/src/models/auditLog/index.ts delete mode 100644 pg-migrator/src/models/auditLog/types.ts delete mode 100644 pg-migrator/src/models/backupPrivateKey.ts delete mode 100644 pg-migrator/src/models/bot.ts delete mode 100644 pg-migrator/src/models/botKey.ts delete mode 100644 pg-migrator/src/models/botOrg.ts delete mode 100644 pg-migrator/src/models/folder.ts delete mode 100644 pg-migrator/src/models/folderVersion.ts delete mode 100644 pg-migrator/src/models/gitAppInstallationSession.ts delete mode 100644 pg-migrator/src/models/gitAppOrganizationInstallation.ts delete mode 100644 pg-migrator/src/models/gitRisks.ts delete mode 100644 pg-migrator/src/models/identity.ts delete mode 100644 pg-migrator/src/models/identityAccessToken.ts delete mode 100644 pg-migrator/src/models/identityMembership.ts delete mode 100644 pg-migrator/src/models/identityMembershipOrg.ts delete mode 100644 pg-migrator/src/models/identityUniversalAuth.ts delete mode 100644 pg-migrator/src/models/identityUniversalAuthClientSecret.ts delete mode 100644 pg-migrator/src/models/incidentContactOrg.ts delete mode 100644 pg-migrator/src/models/index.ts delete mode 100644 pg-migrator/src/models/integration/index.ts delete mode 100644 pg-migrator/src/models/integration/integration.ts delete mode 100644 pg-migrator/src/models/integration/types.ts delete mode 100644 pg-migrator/src/models/integrationAuth/index.ts delete mode 100644 pg-migrator/src/models/integrationAuth/integrationAuth.ts delete mode 100644 pg-migrator/src/models/integrationAuth/types.ts delete mode 100644 pg-migrator/src/models/key.ts delete mode 100644 pg-migrator/src/models/loginSRPDetail.ts delete mode 100644 pg-migrator/src/models/membership.ts delete mode 100644 pg-migrator/src/models/membershipOrg.ts delete mode 100644 pg-migrator/src/models/organization.ts delete mode 100644 pg-migrator/src/models/role.ts delete mode 100644 pg-migrator/src/models/secret.ts delete mode 100644 pg-migrator/src/models/secretApprovalPolicy.ts delete mode 100644 pg-migrator/src/models/secretApprovalRequest.ts delete mode 100644 pg-migrator/src/models/secretBlindIndexData.ts delete mode 100644 pg-migrator/src/models/secretImports.ts delete mode 100644 pg-migrator/src/models/secretRotation.ts delete mode 100644 pg-migrator/src/models/secretSnapshot.ts delete mode 100644 pg-migrator/src/models/secretVersion.ts delete mode 100644 pg-migrator/src/models/serverConfig.ts delete mode 100644 pg-migrator/src/models/serviceToken.ts delete mode 100644 pg-migrator/src/models/serviceTokenData.ts delete mode 100644 pg-migrator/src/models/ssoConfig.ts delete mode 100644 pg-migrator/src/models/tag.ts delete mode 100644 pg-migrator/src/models/token.ts delete mode 100644 pg-migrator/src/models/tokenData.ts delete mode 100644 pg-migrator/src/models/tokenVersion.ts delete mode 100644 pg-migrator/src/models/trustedIp.ts delete mode 100644 pg-migrator/src/models/user.ts delete mode 100644 pg-migrator/src/models/userAction.ts delete mode 100644 pg-migrator/src/models/webhooks.ts delete mode 100644 pg-migrator/src/models/workspace.ts delete mode 100644 pg-migrator/src/rollback.ts delete mode 100644 pg-migrator/src/schemas/api-keys.ts delete mode 100644 pg-migrator/src/schemas/audit-logs.ts delete mode 100644 pg-migrator/src/schemas/auth-token-sessions.ts delete mode 100644 pg-migrator/src/schemas/auth-tokens.ts delete mode 100644 pg-migrator/src/schemas/backup-private-key.ts delete mode 100644 pg-migrator/src/schemas/git-app-install-sessions.ts delete mode 100644 pg-migrator/src/schemas/git-app-org.ts delete mode 100644 pg-migrator/src/schemas/identities.ts delete mode 100644 pg-migrator/src/schemas/identity-access-tokens.ts delete mode 100644 pg-migrator/src/schemas/identity-org-memberships.ts delete mode 100644 pg-migrator/src/schemas/identity-project-memberships.ts delete mode 100644 pg-migrator/src/schemas/identity-ua-client-secrets.ts delete mode 100644 pg-migrator/src/schemas/identity-universal-auths.ts delete mode 100644 pg-migrator/src/schemas/incident-contacts.ts delete mode 100644 pg-migrator/src/schemas/index.ts delete mode 100644 pg-migrator/src/schemas/integration-auths.ts delete mode 100644 pg-migrator/src/schemas/integrations.ts delete mode 100644 pg-migrator/src/schemas/models.ts delete mode 100644 pg-migrator/src/schemas/org-bots.ts delete mode 100644 pg-migrator/src/schemas/org-memberships.ts delete mode 100644 pg-migrator/src/schemas/org-roles.ts delete mode 100644 pg-migrator/src/schemas/organizations.ts delete mode 100644 pg-migrator/src/schemas/project-bots.ts delete mode 100644 pg-migrator/src/schemas/project-environments.ts delete mode 100644 pg-migrator/src/schemas/project-keys.ts delete mode 100644 pg-migrator/src/schemas/project-memberships.ts delete mode 100644 pg-migrator/src/schemas/project-roles.ts delete mode 100644 pg-migrator/src/schemas/projects.ts delete mode 100644 pg-migrator/src/schemas/saml-configs.ts delete mode 100644 pg-migrator/src/schemas/secret-approval-policies-approvers.ts delete mode 100644 pg-migrator/src/schemas/secret-approval-policies.ts delete mode 100644 pg-migrator/src/schemas/secret-approval-request-secret-tags.ts delete mode 100644 pg-migrator/src/schemas/secret-approval-requests-reviewers.ts delete mode 100644 pg-migrator/src/schemas/secret-approval-requests-secrets.ts delete mode 100644 pg-migrator/src/schemas/secret-approval-requests.ts delete mode 100644 pg-migrator/src/schemas/secret-blind-indexes.ts delete mode 100644 pg-migrator/src/schemas/secret-folder-versions.ts delete mode 100644 pg-migrator/src/schemas/secret-folders.ts delete mode 100644 pg-migrator/src/schemas/secret-imports.ts delete mode 100644 pg-migrator/src/schemas/secret-rotation-outputs.ts delete mode 100644 pg-migrator/src/schemas/secret-rotations.ts delete mode 100644 pg-migrator/src/schemas/secret-scanning-git-risks.ts delete mode 100644 pg-migrator/src/schemas/secret-snapshot-folders.ts delete mode 100644 pg-migrator/src/schemas/secret-snapshot-secrets.ts delete mode 100644 pg-migrator/src/schemas/secret-snapshots.ts delete mode 100644 pg-migrator/src/schemas/secret-tag-junction.ts delete mode 100644 pg-migrator/src/schemas/secret-tags.ts delete mode 100644 pg-migrator/src/schemas/secret-version-tag-junction.ts delete mode 100644 pg-migrator/src/schemas/secret-versions.ts delete mode 100644 pg-migrator/src/schemas/secrets.ts delete mode 100644 pg-migrator/src/schemas/service-tokens.ts delete mode 100644 pg-migrator/src/schemas/super-admin.ts delete mode 100644 pg-migrator/src/schemas/trusted-ips.ts delete mode 100644 pg-migrator/src/schemas/user-actions.ts delete mode 100644 pg-migrator/src/schemas/user-encryption-keys.ts delete mode 100644 pg-migrator/src/schemas/users.ts delete mode 100644 pg-migrator/src/schemas/webhooks.ts delete mode 100644 pg-migrator/src/utils.ts delete mode 100644 pg-migrator/tsconfig.json diff --git a/pg-migrator/.gitignore b/pg-migrator/.gitignore deleted file mode 100644 index 65eef93d6..000000000 --- a/pg-migrator/.gitignore +++ /dev/null @@ -1 +0,0 @@ -db diff --git a/pg-migrator/package-lock.json b/pg-migrator/package-lock.json deleted file mode 100644 index 5db0d9368..000000000 --- a/pg-migrator/package-lock.json +++ /dev/null @@ -1,1487 +0,0 @@ -{ - "name": "pg-migrator", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pg-migrator", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@casl/ability": "^6.5.0", - "@sindresorhus/slugify": "^2.2.1", - "dotenv": "^16.3.1", - "knex": "^3.1.0", - "level": "^8.0.0", - "mongoose": "^8.0.4", - "nanoid": "^5.0.4", - "pg": "^8.11.3", - "prompt-sync": "^4.2.0", - "uuid": "^9.0.1", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/node": "^20.11.0", - "@types/prompt-sync": "^4.2.3", - "@types/uuid": "^9.0.7", - "tsx": "^4.7.0", - "typescript": "^5.3.3" - } - }, - "node_modules/@casl/ability": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", - "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", - "dependencies": { - "@ucast/mongo2js": "^1.3.0" - }, - "funding": { - "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", - "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.3.tgz", - "integrity": "sha512-SyCxhJfmK6MoLNV5SbDpNdUy9SDv5H7y9/9rl3KpnwgTHWuNNMc87zWqbcIZXNWY+aUjxLGLEcvHoLagG4tWCg==", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, - "node_modules/@sindresorhus/slugify": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", - "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", - "dependencies": { - "@sindresorhus/transliterate": "^1.0.0", - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sindresorhus/transliterate": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", - "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", - "dependencies": { - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@types/node": { - "version": "20.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", - "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/prompt-sync": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@types/prompt-sync/-/prompt-sync-4.2.3.tgz", - "integrity": "sha512-Ox77gCSx0YyeakGt/qfOZUSFNSSi+sh3ABoGOiCwiO2KODx492BJnUm9oIXS+AHJtqp12iM4RduY6viTJ9bYwA==", - "dev": true - }, - "node_modules/@types/uuid": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", - "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", - "dev": true - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" - }, - "node_modules/@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "dependencies": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, - "node_modules/@ucast/core": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", - "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" - }, - "node_modules/@ucast/js": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", - "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", - "dependencies": { - "@ucast/core": "^1.0.0" - } - }, - "node_modules/@ucast/mongo": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", - "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", - "dependencies": { - "@ucast/core": "^1.4.1" - } - }, - "node_modules/@ucast/mongo2js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", - "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", - "dependencies": { - "@ucast/core": "^1.6.1", - "@ucast/js": "^3.0.0", - "@ucast/mongo": "^2.4.0" - } - }, - "node_modules/abstract-level": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.3.tgz", - "integrity": "sha512-t6jv+xHy+VYwc4xqZMn2Pa9DjcdzvzZmQGRjTFc8spIbRGHgBrEKbPq+rYXc7CCo0lxgYvSgKVg9qZAhpVQSjA==", - "dependencies": { - "buffer": "^6.0.3", - "catering": "^2.1.0", - "is-buffer": "^2.0.5", - "level-supports": "^4.0.0", - "level-transcoder": "^1.0.1", - "module-error": "^1.0.1", - "queue-microtask": "^1.2.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "engines": { - "node": ">=6" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/browser-level": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browser-level/-/browser-level-1.0.1.tgz", - "integrity": "sha512-XECYKJ+Dbzw0lbydyQuJzwNXtOpbMSq737qxJN11sIRTErOMShvDpbzTlgju7orJKvx4epULolZAuJGLzCmWRQ==", - "dependencies": { - "abstract-level": "^1.0.2", - "catering": "^2.1.1", - "module-error": "^1.0.2", - "run-parallel-limit": "^1.1.0" - } - }, - "node_modules/bson": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", - "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/buffer-writer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", - "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/catering": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/catering/-/catering-2.1.1.tgz", - "integrity": "sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w==", - "engines": { - "node": ">=6" - } - }, - "node_modules/classic-level": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-1.3.0.tgz", - "integrity": "sha512-iwFAJQYtqRTRM0F6L8h4JCt00ZSGdOyqh7yVrhhjrOpFhmBjNlRUey64MCiyo6UmQHMJ+No3c81nujPv+n9yrg==", - "hasInstallScript": true, - "dependencies": { - "abstract-level": "^1.0.2", - "catering": "^2.1.0", - "module-error": "^1.0.1", - "napi-macros": "^2.2.2", - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/colorette": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", - "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==" - }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "engines": { - "node": ">=14" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", - "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.11", - "@esbuild/android-arm": "0.19.11", - "@esbuild/android-arm64": "0.19.11", - "@esbuild/android-x64": "0.19.11", - "@esbuild/darwin-arm64": "0.19.11", - "@esbuild/darwin-x64": "0.19.11", - "@esbuild/freebsd-arm64": "0.19.11", - "@esbuild/freebsd-x64": "0.19.11", - "@esbuild/linux-arm": "0.19.11", - "@esbuild/linux-arm64": "0.19.11", - "@esbuild/linux-ia32": "0.19.11", - "@esbuild/linux-loong64": "0.19.11", - "@esbuild/linux-mips64el": "0.19.11", - "@esbuild/linux-ppc64": "0.19.11", - "@esbuild/linux-riscv64": "0.19.11", - "@esbuild/linux-s390x": "0.19.11", - "@esbuild/linux-x64": "0.19.11", - "@esbuild/netbsd-x64": "0.19.11", - "@esbuild/openbsd-x64": "0.19.11", - "@esbuild/sunos-x64": "0.19.11", - "@esbuild/win32-arm64": "0.19.11", - "@esbuild/win32-ia32": "0.19.11", - "@esbuild/win32-x64": "0.19.11" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", - "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", - "dev": true, - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/getopts": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", - "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==" - }, - "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/kareem": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", - "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/knex": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz", - "integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==", - "dependencies": { - "colorette": "2.0.19", - "commander": "^10.0.0", - "debug": "4.3.4", - "escalade": "^3.1.1", - "esm": "^3.2.25", - "get-package-type": "^0.1.0", - "getopts": "2.3.0", - "interpret": "^2.2.0", - "lodash": "^4.17.21", - "pg-connection-string": "2.6.2", - "rechoir": "^0.8.0", - "resolve-from": "^5.0.0", - "tarn": "^3.0.2", - "tildify": "2.0.0" - }, - "bin": { - "knex": "bin/cli.js" - }, - "engines": { - "node": ">=16" - }, - "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, - "mysql": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-native": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "tedious": { - "optional": true - } - } - }, - "node_modules/level": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/level/-/level-8.0.0.tgz", - "integrity": "sha512-ypf0jjAk2BWI33yzEaaotpq7fkOPALKAgDBxggO6Q9HGX2MRXn0wbP1Jn/tJv1gtL867+YOjOB49WaUF3UoJNQ==", - "dependencies": { - "browser-level": "^1.0.1", - "classic-level": "^1.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/level" - } - }, - "node_modules/level-supports": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-4.0.1.tgz", - "integrity": "sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/level-transcoder": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz", - "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==", - "dependencies": { - "buffer": "^6.0.3", - "module-error": "^1.0.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" - }, - "node_modules/module-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", - "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/mongodb": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.2.0.tgz", - "integrity": "sha512-d7OSuGjGWDZ5usZPqfvb36laQ9CPhnWkAGHT61x5P95p/8nMVeH8asloMwW6GcYFeB0Vj4CB/1wOTDG2RA9BFA==", - "dependencies": { - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^6.2.0", - "mongodb-connection-string-url": "^2.6.0" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", - "dependencies": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" - } - }, - "node_modules/mongoose": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.0.4.tgz", - "integrity": "sha512-wN9qvdevX3+922VnLT7CpaZRT3jmVCBOK2QMHMGeScQxDRnFMPpkuI9StEPpZo/3x8t+kbzH7F8RMPsyNwyM4w==", - "dependencies": { - "bson": "^6.2.0", - "kareem": "2.5.1", - "mongodb": "6.2.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "16.0.1" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mongoose/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", - "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/nanoid": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.4.tgz", - "integrity": "sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, - "node_modules/napi-macros": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", - "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==" - }, - "node_modules/node-gyp-build": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", - "integrity": "sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/packet-reader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", - "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/pg": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", - "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", - "dependencies": { - "buffer-writer": "2.0.0", - "packet-reader": "1.0.0", - "pg-connection-string": "^2.6.2", - "pg-pool": "^3.6.1", - "pg-protocol": "^1.6.0", - "pg-types": "^2.1.0", - "pgpass": "1.x" - }, - "engines": { - "node": ">= 8.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.1.1" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", - "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", - "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", - "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", - "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/prompt-sync": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/prompt-sync/-/prompt-sync-4.2.0.tgz", - "integrity": "sha512-BuEzzc5zptP5LsgV5MZETjDaKSWfchl5U9Luiu8SKp7iZWD5tZalOxvNcZRwv+d2phNFr8xlbxmFNcRKfJOzJw==", - "dependencies": { - "strip-ansi": "^5.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/run-parallel-limit": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz", - "integrity": "sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/sift": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" - }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tarn": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", - "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/tildify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", - "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/tsx": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.0.tgz", - "integrity": "sha512-I+t79RYPlEYlHn9a+KzwrvEwhJg35h/1zHsLC2JXvhC2mdynMv6Zxzvhv5EMV6VF5qJlLlkSnMVvdZV3PSIGcg==", - "dev": true, - "dependencies": { - "esbuild": "~0.19.10", - "get-tsconfig": "^4.7.2" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/pg-migrator/package.json b/pg-migrator/package.json deleted file mode 100644 index 4ea100d8a..000000000 --- a/pg-migrator/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "pg-migrator", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "migration": "tsx src/index.ts", - "rollback": "tsx src/rollback.ts", - "migrate:audit-log": "tsx src/audit-log-migrator.ts" - }, - "author": "", - "license": "ISC", - "devDependencies": { - "@types/node": "^20.11.0", - "@types/prompt-sync": "^4.2.3", - "@types/uuid": "^9.0.7", - "tsx": "^4.7.0", - "typescript": "^5.3.3" - }, - "dependencies": { - "@casl/ability": "^6.5.0", - "@sindresorhus/slugify": "^2.2.1", - "dotenv": "^16.3.1", - "knex": "^3.1.0", - "level": "^8.0.0", - "mongoose": "^8.0.4", - "nanoid": "^5.0.4", - "pg": "^8.11.3", - "prompt-sync": "^4.2.0", - "uuid": "^9.0.1", - "zod": "^3.22.4" - } -} diff --git a/pg-migrator/src/@types/knex.d.ts b/pg-migrator/src/@types/knex.d.ts deleted file mode 100644 index 059a14d16..000000000 --- a/pg-migrator/src/@types/knex.d.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { Knex } from "knex"; - -import { - TableName, - TApiKeys, - TApiKeysInsert, - TApiKeysUpdate, - TAuditLogs, - TAuditLogsInsert, - TAuditLogsUpdate, - TAuthTokens, - TAuthTokenSessions, - TAuthTokenSessionsInsert, - TAuthTokenSessionsUpdate, - TAuthTokensInsert, - TAuthTokensUpdate, - TBackupPrivateKey, - TBackupPrivateKeyInsert, - TBackupPrivateKeyUpdate, - TGitAppInstallSessions, - TGitAppInstallSessionsInsert, - TGitAppInstallSessionsUpdate, - TGitAppOrg, - TGitAppOrgInsert, - TGitAppOrgUpdate, - TIdentities, - TIdentitiesInsert, - TIdentitiesUpdate, - TIdentityAccessTokens, - TIdentityAccessTokensInsert, - TIdentityAccessTokensUpdate, - TIdentityOrgMemberships, - TIdentityOrgMembershipsInsert, - TIdentityOrgMembershipsUpdate, - TIdentityProjectMemberships, - TIdentityProjectMembershipsInsert, - TIdentityProjectMembershipsUpdate, - TIdentityUaClientSecrets, - TIdentityUaClientSecretsInsert, - TIdentityUaClientSecretsUpdate, - TIdentityUniversalAuths, - TIdentityUniversalAuthsInsert, - TIdentityUniversalAuthsUpdate, - TIncidentContacts, - TIncidentContactsInsert, - TIncidentContactsUpdate, - TIntegrationAuths, - TIntegrationAuthsInsert, - TIntegrationAuthsUpdate, - TIntegrations, - TIntegrationsInsert, - TIntegrationsUpdate, - TOrganizations, - TOrganizationsInsert, - TOrganizationsUpdate, - TOrgBots, - TOrgBotsInsert, - TOrgBotsUpdate, - TOrgMemberships, - TOrgMembershipsInsert, - TOrgMembershipsUpdate, - TOrgRoles, - TOrgRolesInsert, - TOrgRolesUpdate, - TProjectBots, - TProjectBotsInsert, - TProjectBotsUpdate, - TProjectEnvironments, - TProjectEnvironmentsInsert, - TProjectEnvironmentsUpdate, - TProjectKeys, - TProjectKeysInsert, - TProjectKeysUpdate, - TProjectMemberships, - TProjectMembershipsInsert, - TProjectMembershipsUpdate, - TProjectRoles, - TProjectRolesInsert, - TProjectRolesUpdate, - TProjects, - TProjectsInsert, - TProjectsUpdate, - TSamlConfigs, - TSamlConfigsInsert, - TSamlConfigsUpdate, - TSecretApprovalPolicies, - TSecretApprovalPoliciesApprovers, - TSecretApprovalPoliciesApproversInsert, - TSecretApprovalPoliciesApproversUpdate, - TSecretApprovalPoliciesInsert, - TSecretApprovalPoliciesUpdate, - TSecretApprovalRequests, - TSecretApprovalRequestSecretTags, - TSecretApprovalRequestSecretTagsInsert, - TSecretApprovalRequestSecretTagsUpdate, - TSecretApprovalRequestsInsert, - TSecretApprovalRequestsReviewers, - TSecretApprovalRequestsReviewersInsert, - TSecretApprovalRequestsReviewersUpdate, - TSecretApprovalRequestsSecrets, - TSecretApprovalRequestsSecretsInsert, - TSecretApprovalRequestsSecretsUpdate, - TSecretApprovalRequestsUpdate, - TSecretBlindIndexes, - TSecretBlindIndexesInsert, - TSecretBlindIndexesUpdate, - TSecretFolders, - TSecretFoldersInsert, - TSecretFoldersUpdate, - TSecretFolderVersions, - TSecretFolderVersionsInsert, - TSecretFolderVersionsUpdate, - TSecretImports, - TSecretImportsInsert, - TSecretImportsUpdate, - TSecretRotationOutputs, - TSecretRotationOutputsInsert, - TSecretRotationOutputsUpdate, - TSecretRotations, - TSecretRotationsInsert, - TSecretRotationsUpdate, - TSecrets, - TSecretScanningGitRisks, - TSecretScanningGitRisksInsert, - TSecretScanningGitRisksUpdate, - TSecretsInsert, - TSecretSnapshotFolders, - TSecretSnapshotFoldersInsert, - TSecretSnapshotFoldersUpdate, - TSecretSnapshots, - TSecretSnapshotSecrets, - TSecretSnapshotSecretsInsert, - TSecretSnapshotSecretsUpdate, - TSecretSnapshotsInsert, - TSecretSnapshotsUpdate, - TSecretsUpdate, - TSecretTagJunction, - TSecretTagJunctionInsert, - TSecretTagJunctionUpdate, - TSecretTags, - TSecretTagsInsert, - TSecretTagsUpdate, - TSecretVersions, - TSecretVersionsInsert, - TSecretVersionsUpdate, - TSecretVersionTagJunction, - TSecretVersionTagJunctionInsert, - TSecretVersionTagJunctionUpdate, - TServiceTokens, - TServiceTokensInsert, - TServiceTokensUpdate, - TSuperAdmin, - TSuperAdminInsert, - TSuperAdminUpdate, - TTrustedIps, - TTrustedIpsInsert, - TTrustedIpsUpdate, - TUserActions, - TUserActionsInsert, - TUserActionsUpdate, - TUserEncryptionKeys, - TUserEncryptionKeysInsert, - TUserEncryptionKeysUpdate, - TUsers, - TUsersInsert, - TUsersUpdate, - TWebhooks, - TWebhooksInsert, - TWebhooksUpdate, -} from "../schemas"; - -declare module "knex/types/tables" { - interface Tables { - [TableName.Users]: Knex.CompositeTableType< - TUsers, - TUsersInsert, - TUsersUpdate - >; - [TableName.UserEncryptionKey]: Knex.CompositeTableType< - TUserEncryptionKeys, - TUserEncryptionKeysInsert, - TUserEncryptionKeysUpdate - >; - [TableName.AuthTokens]: Knex.CompositeTableType< - TAuthTokens, - TAuthTokensInsert, - TAuthTokensUpdate - >; - [TableName.AuthTokenSession]: Knex.CompositeTableType< - TAuthTokenSessions, - TAuthTokenSessionsInsert, - TAuthTokenSessionsUpdate - >; - [TableName.BackupPrivateKey]: Knex.CompositeTableType< - TBackupPrivateKey, - TBackupPrivateKeyInsert, - TBackupPrivateKeyUpdate - >; - [TableName.Organization]: Knex.CompositeTableType< - TOrganizations, - TOrganizationsInsert, - TOrganizationsUpdate - >; - [TableName.OrgMembership]: Knex.CompositeTableType< - TOrgMemberships, - TOrgMembershipsInsert, - TOrgMembershipsUpdate - >; - [TableName.OrgRoles]: Knex.CompositeTableType< - TOrgRoles, - TOrgRolesInsert, - TOrgRolesUpdate - >; - [TableName.IncidentContact]: Knex.CompositeTableType< - TIncidentContacts, - TIncidentContactsInsert, - TIncidentContactsUpdate - >; - [TableName.UserAction]: Knex.CompositeTableType< - TUserActions, - TUserActionsInsert, - TUserActionsUpdate - >; - [TableName.SuperAdmin]: Knex.CompositeTableType< - TSuperAdmin, - TSuperAdminInsert, - TSuperAdminUpdate - >; - [TableName.ApiKey]: Knex.CompositeTableType< - TApiKeys, - TApiKeysInsert, - TApiKeysUpdate - >; - [TableName.Project]: Knex.CompositeTableType< - TProjects, - TProjectsInsert, - TProjectsUpdate - >; - [TableName.ProjectMembership]: Knex.CompositeTableType< - TProjectMemberships, - TProjectMembershipsInsert, - TProjectMembershipsUpdate - >; - [TableName.Environment]: Knex.CompositeTableType< - TProjectEnvironments, - TProjectEnvironmentsInsert, - TProjectEnvironmentsUpdate - >; - [TableName.ProjectBot]: Knex.CompositeTableType< - TProjectBots, - TProjectBotsInsert, - TProjectBotsUpdate - >; - [TableName.ProjectRoles]: Knex.CompositeTableType< - TProjectRoles, - TProjectRolesInsert, - TProjectRolesUpdate - >; - [TableName.ProjectKeys]: Knex.CompositeTableType< - TProjectKeys, - TProjectKeysInsert, - TProjectKeysUpdate - >; - [TableName.Secret]: Knex.CompositeTableType< - TSecrets, - TSecretsInsert, - TSecretsUpdate - >; - [TableName.SecretBlindIndex]: Knex.CompositeTableType< - TSecretBlindIndexes, - TSecretBlindIndexesInsert, - TSecretBlindIndexesUpdate - >; - [TableName.SecretVersion]: Knex.CompositeTableType< - TSecretVersions, - TSecretVersionsInsert, - TSecretVersionsUpdate - >; - [TableName.SecretFolder]: Knex.CompositeTableType< - TSecretFolders, - TSecretFoldersInsert, - TSecretFoldersUpdate - >; - [TableName.SecretFolderVersion]: Knex.CompositeTableType< - TSecretFolderVersions, - TSecretFolderVersionsInsert, - TSecretFolderVersionsUpdate - >; - [TableName.SecretTag]: Knex.CompositeTableType< - TSecretTags, - TSecretTagsInsert, - TSecretTagsUpdate - >; - [TableName.SecretImport]: Knex.CompositeTableType< - TSecretImports, - TSecretImportsInsert, - TSecretImportsUpdate - >; - [TableName.Integration]: Knex.CompositeTableType< - TIntegrations, - TIntegrationsInsert, - TIntegrationsUpdate - >; - [TableName.Webhook]: Knex.CompositeTableType< - TWebhooks, - TWebhooksInsert, - TWebhooksUpdate - >; - [TableName.ServiceToken]: Knex.CompositeTableType< - TServiceTokens, - TServiceTokensInsert, - TServiceTokensUpdate - >; - [TableName.IntegrationAuth]: Knex.CompositeTableType< - TIntegrationAuths, - TIntegrationAuthsInsert, - TIntegrationAuthsUpdate - >; - [TableName.Identity]: Knex.CompositeTableType< - TIdentities, - TIdentitiesInsert, - TIdentitiesUpdate - >; - [TableName.IdentityUniversalAuth]: Knex.CompositeTableType< - TIdentityUniversalAuths, - TIdentityUniversalAuthsInsert, - TIdentityUniversalAuthsUpdate - >; - [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< - TIdentityUaClientSecrets, - TIdentityUaClientSecretsInsert, - TIdentityUaClientSecretsUpdate - >; - [TableName.IdentityAccessToken]: Knex.CompositeTableType< - TIdentityAccessTokens, - TIdentityAccessTokensInsert, - TIdentityAccessTokensUpdate - >; - [TableName.IdentityOrgMembership]: Knex.CompositeTableType< - TIdentityOrgMemberships, - TIdentityOrgMembershipsInsert, - TIdentityOrgMembershipsUpdate - >; - [TableName.IdentityProjectMembership]: Knex.CompositeTableType< - TIdentityProjectMemberships, - TIdentityProjectMembershipsInsert, - TIdentityProjectMembershipsUpdate - >; - [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< - TSecretApprovalPolicies, - TSecretApprovalPoliciesInsert, - TSecretApprovalPoliciesUpdate - >; - [TableName.SecretApprovalPolicyApprover]: Knex.CompositeTableType< - TSecretApprovalPoliciesApprovers, - TSecretApprovalPoliciesApproversInsert, - TSecretApprovalPoliciesApproversUpdate - >; - [TableName.SecretApprovalRequest]: Knex.CompositeTableType< - TSecretApprovalRequests, - TSecretApprovalRequestsInsert, - TSecretApprovalRequestsUpdate - >; - [TableName.SecretApprovalRequestReviewer]: Knex.CompositeTableType< - TSecretApprovalRequestsReviewers, - TSecretApprovalRequestsReviewersInsert, - TSecretApprovalRequestsReviewersUpdate - >; - [TableName.SecretApprovalRequestSecret]: Knex.CompositeTableType< - TSecretApprovalRequestsSecrets, - TSecretApprovalRequestsSecretsInsert, - TSecretApprovalRequestsSecretsUpdate - >; - [TableName.SecretApprovalRequestSecretTag]: Knex.CompositeTableType< - TSecretApprovalRequestSecretTags, - TSecretApprovalRequestSecretTagsInsert, - TSecretApprovalRequestSecretTagsUpdate - >; - [TableName.SecretRotation]: Knex.CompositeTableType< - TSecretRotations, - TSecretRotationsInsert, - TSecretRotationsUpdate - >; - [TableName.SecretRotationOutput]: Knex.CompositeTableType< - TSecretRotationOutputs, - TSecretRotationOutputsInsert, - TSecretRotationOutputsUpdate - >; - [TableName.Snapshot]: Knex.CompositeTableType< - TSecretSnapshots, - TSecretSnapshotsInsert, - TSecretSnapshotsUpdate - >; - [TableName.SnapshotSecret]: Knex.CompositeTableType< - TSecretSnapshotSecrets, - TSecretSnapshotSecretsInsert, - TSecretSnapshotSecretsUpdate - >; - [TableName.SnapshotFolder]: Knex.CompositeTableType< - TSecretSnapshotFolders, - TSecretSnapshotFoldersInsert, - TSecretSnapshotFoldersUpdate - >; - [TableName.SamlConfig]: Knex.CompositeTableType< - TSamlConfigs, - TSamlConfigsInsert, - TSamlConfigsUpdate - >; - [TableName.OrgBot]: Knex.CompositeTableType< - TOrgBots, - TOrgBotsInsert, - TOrgBotsUpdate - >; - [TableName.AuditLog]: Knex.CompositeTableType< - TAuditLogs, - TAuditLogsInsert, - TAuditLogsUpdate - >; - [TableName.GitAppInstallSession]: Knex.CompositeTableType< - TGitAppInstallSessions, - TGitAppInstallSessionsInsert, - TGitAppInstallSessionsUpdate - >; - [TableName.GitAppOrg]: Knex.CompositeTableType< - TGitAppOrg, - TGitAppOrgInsert, - TGitAppOrgUpdate - >; - [TableName.SecretScanningGitRisk]: Knex.CompositeTableType< - TSecretScanningGitRisks, - TSecretScanningGitRisksInsert, - TSecretScanningGitRisksUpdate - >; - [TableName.TrustedIps]: Knex.CompositeTableType< - TTrustedIps, - TTrustedIpsInsert, - TTrustedIpsUpdate - >; - // Junction tables - [TableName.JnSecretTag]: Knex.CompositeTableType< - TSecretTagJunction, - TSecretTagJunctionInsert, - TSecretTagJunctionUpdate - >; - [TableName.SecretVersionTag]: Knex.CompositeTableType< - TSecretVersionTagJunction, - TSecretVersionTagJunctionInsert, - TSecretVersionTagJunctionUpdate - >; - } -} diff --git a/pg-migrator/src/audit-log-migrator.ts b/pg-migrator/src/audit-log-migrator.ts deleted file mode 100644 index becb60427..000000000 --- a/pg-migrator/src/audit-log-migrator.ts +++ /dev/null @@ -1,1284 +0,0 @@ -import { Level } from "level"; -import { TableName } from "./schemas"; -import dotenv from "dotenv"; -import PromptSync from "prompt-sync"; -import mongoose from "mongoose"; -import knex from "knex"; -import path from "path"; -import { v4 as uuidV4 } from "uuid"; -import { getFolderKv, migrateCollection, truncateAndSlugify } from "."; -import { ActorType, AuditLog } from "./models"; - -export type TCreateAuditLogDTO = { - event: Event; - actor: UserActor | IdentityActor | ServiceActor; - orgId?: string; - projectId?: string; -} & BaseAuthData; - -interface BaseAuthData { - ipAddress?: string; - userAgent?: string; - userAgentType?: UserAgentType; -} - -export enum UserAgentType { - WEB = "web", - CLI = "cli", - K8_OPERATOR = "k8-operator", - TERRAFORM = "terraform", - OTHER = "other", - PYTHON_SDK = "InfisicalPythonSDK", - NODE_SDK = "InfisicalNodeSDK", -} - -export enum EventType { - GET_SECRETS = "get-secrets", - GET_SECRET = "get-secret", - REVEAL_SECRET = "reveal-secret", - CREATE_SECRET = "create-secret", - CREATE_SECRETS = "create-secrets", - UPDATE_SECRET = "update-secret", - UPDATE_SECRETS = "update-secrets", - DELETE_SECRET = "delete-secret", - DELETE_SECRETS = "delete-secrets", - GET_WORKSPACE_KEY = "get-workspace-key", - AUTHORIZE_INTEGRATION = "authorize-integration", - UNAUTHORIZE_INTEGRATION = "unauthorize-integration", - CREATE_INTEGRATION = "create-integration", - DELETE_INTEGRATION = "delete-integration", - ADD_TRUSTED_IP = "add-trusted-ip", - UPDATE_TRUSTED_IP = "update-trusted-ip", - DELETE_TRUSTED_IP = "delete-trusted-ip", - CREATE_SERVICE_TOKEN = "create-service-token", // v2 - DELETE_SERVICE_TOKEN = "delete-service-token", // v2 - CREATE_IDENTITY = "create-identity", - UPDATE_IDENTITY = "update-identity", - DELETE_IDENTITY = "delete-identity", - LOGIN_IDENTITY_UNIVERSAL_AUTH = "login-identity-universal-auth", - ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", - UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", - GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", - CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", - REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", - GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", - CREATE_ENVIRONMENT = "create-environment", - UPDATE_ENVIRONMENT = "update-environment", - DELETE_ENVIRONMENT = "delete-environment", - ADD_WORKSPACE_MEMBER = "add-workspace-member", - ADD_BATCH_WORKSPACE_MEMBER = "add-workspace-members", - REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", - CREATE_FOLDER = "create-folder", - UPDATE_FOLDER = "update-folder", - DELETE_FOLDER = "delete-folder", - CREATE_WEBHOOK = "create-webhook", - UPDATE_WEBHOOK_STATUS = "update-webhook-status", - DELETE_WEBHOOK = "delete-webhook", - GET_SECRET_IMPORTS = "get-secret-imports", - CREATE_SECRET_IMPORT = "create-secret-import", - UPDATE_SECRET_IMPORT = "update-secret-import", - DELETE_SECRET_IMPORT = "delete-secret-import", - UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions", - SECRET_APPROVAL_MERGED = "secret-approval-merged", - SECRET_APPROVAL_REQUEST = "secret-approval-request", - SECRET_APPROVAL_CLOSED = "secret-approval-closed", - SECRET_APPROVAL_REOPENED = "secret-approval-reopened", -} - -interface UserActorMetadata { - userId: string; - email: string; -} - -interface ServiceActorMetadata { - serviceId: string; - name: string; -} - -interface IdentityActorMetadata { - identityId: string; - name: string; -} - -export interface UserActor { - type: ActorType.USER; - metadata: UserActorMetadata; -} - -export interface ServiceActor { - type: ActorType.SERVICE; - metadata: ServiceActorMetadata; -} - -export interface IdentityActor { - type: ActorType.IDENTITY; - metadata: IdentityActorMetadata; -} - -export type Actor = UserActor | ServiceActor | IdentityActor; - -interface GetSecretsEvent { - type: EventType.GET_SECRETS; - metadata: { - environment: string; - secretPath: string; - numberOfSecrets: number; - }; -} - -interface GetSecretEvent { - type: EventType.GET_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface CreateSecretEvent { - type: EventType.CREATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface CreateSecretBatchEvent { - type: EventType.CREATE_SECRETS; - metadata: { - environment: string; - secretPath: string; - secrets: Array<{ - secretId: string; - secretKey: string; - secretVersion: number; - }>; - }; -} - -interface UpdateSecretEvent { - type: EventType.UPDATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface UpdateSecretBatchEvent { - type: EventType.UPDATE_SECRETS; - metadata: { - environment: string; - secretPath: string; - secrets: Array<{ - secretId: string; - secretKey: string; - secretVersion: number; - }>; - }; -} - -interface DeleteSecretEvent { - type: EventType.DELETE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface DeleteSecretBatchEvent { - type: EventType.DELETE_SECRETS; - metadata: { - environment: string; - secretPath: string; - secrets: Array<{ - secretId: string; - secretKey: string; - secretVersion: number; - }>; - }; -} - -interface GetWorkspaceKeyEvent { - type: EventType.GET_WORKSPACE_KEY; - metadata: { - keyId: string; - }; -} - -interface AuthorizeIntegrationEvent { - type: EventType.AUTHORIZE_INTEGRATION; - metadata: { - integration: string; - }; -} - -interface UnauthorizeIntegrationEvent { - type: EventType.UNAUTHORIZE_INTEGRATION; - metadata: { - integration: string; - }; -} - -interface CreateIntegrationEvent { - type: EventType.CREATE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - }; -} - -interface DeleteIntegrationEvent { - type: EventType.DELETE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - }; -} - -interface AddTrustedIPEvent { - type: EventType.ADD_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - }; -} - -interface UpdateTrustedIPEvent { - type: EventType.UPDATE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - }; -} - -interface DeleteTrustedIPEvent { - type: EventType.DELETE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - }; -} - -interface CreateServiceTokenEvent { - type: EventType.CREATE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - }; -} - -interface DeleteServiceTokenEvent { - type: EventType.DELETE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - }; -} - -interface CreateIdentityEvent { - // note: currently not logging org-role - type: EventType.CREATE_IDENTITY; - metadata: { - identityId: string; - name: string; - }; -} - -interface UpdateIdentityEvent { - type: EventType.UPDATE_IDENTITY; - metadata: { - identityId: string; - name?: string; - }; -} - -interface DeleteIdentityEvent { - type: EventType.DELETE_IDENTITY; - metadata: { - identityId: string; - }; -} - -interface LoginIdentityUniversalAuthEvent { - type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH; - metadata: { - identityId: string; - identityUniversalAuthId: string; - clientSecretId: string; - identityAccessTokenId: string; - }; -} -export enum IPType { - IPV4 = "ipv4", - IPV6 = "ipv6", -} - -export interface TIdentityTrustedIp { - ipAddress: string; - type: IPType; - prefix: number; -} - -interface AddIdentityUniversalAuthEvent { - type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH; - metadata: { - identityId: string; - clientSecretTrustedIps: Array; - accessTokenTTL: number; - accessTokenMaxTTL: number; - accessTokenNumUsesLimit: number; - accessTokenTrustedIps: Array; - }; -} - -interface UpdateIdentityUniversalAuthEvent { - type: EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH; - metadata: { - identityId: string; - clientSecretTrustedIps?: Array; - accessTokenTTL?: number; - accessTokenMaxTTL?: number; - accessTokenNumUsesLimit?: number; - accessTokenTrustedIps?: Array; - }; -} - -interface GetIdentityUniversalAuthEvent { - type: EventType.GET_IDENTITY_UNIVERSAL_AUTH; - metadata: { - identityId: string; - }; -} - -interface CreateIdentityUniversalAuthClientSecretEvent { - type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; - metadata: { - identityId: string; - clientSecretId: string; - }; -} - -interface GetIdentityUniversalAuthClientSecretsEvent { - type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS; - metadata: { - identityId: string; - }; -} - -interface RevokeIdentityUniversalAuthClientSecretEvent { - type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; - metadata: { - identityId: string; - clientSecretId: string; - }; -} - -interface CreateEnvironmentEvent { - type: EventType.CREATE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - }; -} - -interface UpdateEnvironmentEvent { - type: EventType.UPDATE_ENVIRONMENT; - metadata: { - oldName: string; - newName: string; - oldSlug: string; - newSlug: string; - oldPos: number; - newPos: number; - }; -} - -interface DeleteEnvironmentEvent { - type: EventType.DELETE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - }; -} - -interface AddWorkspaceMemberEvent { - type: EventType.ADD_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - }; -} - -interface AddBatchWorkspaceMemberEvent { - type: EventType.ADD_BATCH_WORKSPACE_MEMBER; - metadata: Array<{ - userId: string; - email: string; - }>; -} - -interface RemoveWorkspaceMemberEvent { - type: EventType.REMOVE_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - }; -} - -interface CreateFolderEvent { - type: EventType.CREATE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - }; -} - -interface UpdateFolderEvent { - type: EventType.UPDATE_FOLDER; - metadata: { - environment: string; - folderId: string; - oldFolderName: string; - newFolderName: string; - folderPath: string; - }; -} - -interface DeleteFolderEvent { - type: EventType.DELETE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - }; -} - -interface CreateWebhookEvent { - type: EventType.CREATE_WEBHOOK; - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - }; -} - -interface UpdateWebhookStatusEvent { - type: EventType.UPDATE_WEBHOOK_STATUS; - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - }; -} - -interface DeleteWebhookEvent { - type: EventType.DELETE_WEBHOOK; - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - }; -} - -interface GetSecretImportsEvent { - type: EventType.GET_SECRET_IMPORTS; - metadata: { - environment: string; - folderId: string; - numberOfImports: number; - }; -} - -interface CreateSecretImportEvent { - type: EventType.CREATE_SECRET_IMPORT; - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - }; -} - -interface UpdateSecretImportEvent { - type: EventType.UPDATE_SECRET_IMPORT; - metadata: { - secretImportId: string; - folderId: string; - importToEnvironment: string; - importToSecretPath: string; - position: number; - orderBefore?: { - environment: string; - secretPath: string; - }[]; - orderAfter?: { - environment: string; - secretPath: string; - }[]; - }; -} - -interface DeleteSecretImportEvent { - type: EventType.DELETE_SECRET_IMPORT; - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - }; -} - -interface UpdateUserRole { - type: EventType.UPDATE_USER_WORKSPACE_ROLE; - metadata: { - userId: string; - email: string; - oldRole: string; - newRole: string; - }; -} - -interface UpdateUserDeniedPermissions { - type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS; - metadata: { - userId: string; - email: string; - deniedPermissions: { - environmentSlug: string; - ability: string; - }[]; - }; -} -interface SecretApprovalMerge { - type: EventType.SECRET_APPROVAL_MERGED; - metadata: { - mergedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -interface SecretApprovalClosed { - type: EventType.SECRET_APPROVAL_CLOSED; - metadata: { - closedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -interface SecretApprovalReopened { - type: EventType.SECRET_APPROVAL_REOPENED; - metadata: { - reopenedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -interface SecretApprovalRequest { - type: EventType.SECRET_APPROVAL_REQUEST; - metadata: { - committedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -export type Event = - | GetSecretsEvent - | GetSecretEvent - | CreateSecretEvent - | CreateSecretBatchEvent - | UpdateSecretEvent - | UpdateSecretBatchEvent - | DeleteSecretEvent - | DeleteSecretBatchEvent - | GetWorkspaceKeyEvent - | AuthorizeIntegrationEvent - | UnauthorizeIntegrationEvent - | CreateIntegrationEvent - | DeleteIntegrationEvent - | AddTrustedIPEvent - | UpdateTrustedIPEvent - | DeleteTrustedIPEvent - | CreateServiceTokenEvent - | DeleteServiceTokenEvent - | CreateIdentityEvent - | UpdateIdentityEvent - | DeleteIdentityEvent - | LoginIdentityUniversalAuthEvent - | AddIdentityUniversalAuthEvent - | UpdateIdentityUniversalAuthEvent - | GetIdentityUniversalAuthEvent - | CreateIdentityUniversalAuthClientSecretEvent - | GetIdentityUniversalAuthClientSecretsEvent - | RevokeIdentityUniversalAuthClientSecretEvent - | CreateEnvironmentEvent - | UpdateEnvironmentEvent - | DeleteEnvironmentEvent - | AddWorkspaceMemberEvent - | AddBatchWorkspaceMemberEvent - | RemoveWorkspaceMemberEvent - | CreateFolderEvent - | UpdateFolderEvent - | DeleteFolderEvent - | CreateWebhookEvent - | UpdateWebhookStatusEvent - | DeleteWebhookEvent - | GetSecretImportsEvent - | CreateSecretImportEvent - | UpdateSecretImportEvent - | DeleteSecretImportEvent - | UpdateUserRole - | UpdateUserDeniedPermissions - | SecretApprovalMerge - | SecretApprovalClosed - | SecretApprovalRequest; - -const kdb = new Level("./db", { valueEncoding: "json" }); - -const projectKv = kdb.sublevel(TableName.Project); -const secKv = kdb.sublevel(TableName.Secret); -const userKv = kdb.sublevel(TableName.Users); -const orgKv = kdb.sublevel(TableName.Organization); -const identityKv = kdb.sublevel(TableName.Identity); -const secretApprovalRequestKv = kdb.sublevel(TableName.SecretApprovalRequest); -const projectMembKv = kdb.sublevel(TableName.ProjectMembership); -const projectKeyKv = kdb.sublevel(TableName.ProjectKeys); -const integrationKv = kdb.sublevel(TableName.Integration); -const trustedIpKv = kdb.sublevel(TableName.TrustedIps); -const identityUaKv = kdb.sublevel(TableName.IdentityUniversalAuth); -const identityUaClientSecKv = kdb.sublevel(TableName.IdentityUaClientSecret); -const identityAccessTokenKv = kdb.sublevel(TableName.IdentityAccessToken); -const webhookKv = kdb.sublevel(TableName.Webhook); - -// add these to the main migrator -// this will be empty -const secretImportKv = kdb.sublevel(TableName.SecretImport); - -const getKeyWithFallack = (sublevel: typeof projectKeyKv, key: string) => - sublevel.get(key).catch(() => key); - -const main = async () => { - try { - dotenv.config(); - const prompt = PromptSync({ sigint: true }); - - let mongodb_url = process.env.MONGO_DB_URL; - if (!mongodb_url) { - mongodb_url = prompt("Type the mongodb url: "); - } - console.log("Checking mongoose connection..."); - await mongoose.connect(mongodb_url); - console.log("Connected successfully to mongo"); - - let postgres_url = process.env.POSTGRES_DB_URL; - if (!postgres_url) { - postgres_url = prompt("Type the postgres url: "); - } - console.log("Checking postgres connection..."); - const db = knex({ - client: "pg", - connection: postgres_url, - migrations: { - directory: path.join(__dirname, "../../backend/src/db/migrations"), - extension: "ts", - tableName: "infisical_migrations", - }, - }); - console.log("Connected successfully to postgres"); - await db.raw("select 1+1 as result"); - - if (process.env.START_FRESH === "true") { - console.log("Starting rolling back to latest, comment this out later"); - await db.migrate.rollback({}, true); - await kdb.clear(); - console.log("Rolling back completed"); - - console.log("Executing migration"); - await db.migrate.latest(); - console.log("Completed migration"); - } - - await migrateCollection({ - db, - mongooseCollection: AuditLog, - postgresTableName: TableName.AuditLog, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - const orgId = await orgKv - .get(doc.organization.toString()) - .catch(() => null); - if (!orgId) return; - - const projectId = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectId) return; - - const eventFields = { - id, - actor: doc.actor.type, - actorMetadata: doc.actor.metadata, - orgId, - eventType: doc.event.type, - expiresAt: doc.expiresAt, - ipAddress: doc.ipAddress, - projectId, - userAgent: doc.userAgent, - userAgentType: doc.userAgentType, - createdAt: (doc as any).createdAt, - updatedAt: (doc as any).updatedAt, - }; - - switch (doc.event.type) { - case EventType.GET_SECRET: { - const event = doc.event.metadata; - if (!event.environment) return; - - const secretId = await secKv.get(event.secretId).catch(() => null); - if (!secretId) return; - const pgEvent = { ...event, secretId }; - return { ...eventFields, eventMetadata: pgEvent }; - } - case EventType.GET_SECRETS: { - const event = doc.event.metadata; - return { ...eventFields, eventMetadata: event }; - } - case EventType.CREATE_SECRET: { - const event = doc.event.metadata; - const secretId = await secKv.get(event.secretId).catch(() => null); - if (!secretId) return; - const pgEvent = { ...event, secretId }; - return { ...eventFields, eventMetadata: pgEvent }; - } - case EventType.CREATE_SECRETS: { - const event = doc.event.metadata; - const secrets = await Promise.all( - event.secrets.flatMap(async (el) => { - const secretId = await secKv.get(el.secretId).catch(() => null); - if (!secretId) return []; - return { ...el, secretId }; - }), - ); - return { ...eventFields, eventMetadata: { ...event, secrets } }; - } - case EventType.UPDATE_SECRET: { - const event = doc.event.metadata; - const secretId = await secKv.get(event.secretId).catch(() => null); - if (!secretId) return; - const pgEvent = { ...event, secretId }; - return { ...eventFields, eventMetadata: pgEvent }; - } - case EventType.UPDATE_SECRETS: { - const event = doc.event.metadata; - const secrets = await Promise.all( - event.secrets.flatMap(async (el) => { - const secretId = await secKv.get(el.secretId).catch(() => null); - if (!secretId) return []; - return { ...el, secretId }; - }), - ); - return { ...eventFields, eventMetadata: { ...event, secrets } }; - } - case EventType.DELETE_SECRET: { - const event = doc.event.metadata; - const secretId = await secKv.get(event.secretId).catch(() => null); - if (!secretId) return; - const pgEvent = { ...event, secretId }; - return { ...eventFields, eventMetadata: pgEvent }; - } - case EventType.DELETE_SECRETS: { - const event = doc.event.metadata; - const secrets = await Promise.all( - event.secrets.flatMap(async (el) => { - const secretId = await secKv.get(el.secretId).catch(() => null); - if (!secretId) return []; - return { ...el, secretId }; - }), - ); - return { ...eventFields, eventMetadata: { ...event, secrets } }; - } - case EventType.GET_WORKSPACE_KEY: { - const event = doc.event.metadata; - const keyId = await projectKeyKv.get(event.keyId).catch(() => null); - if (!keyId) return; - return { ...eventFields, eventMetadata: { keyId } }; - } - case EventType.AUTHORIZE_INTEGRATION: { - const event = doc.event.metadata; - return { ...eventFields, eventMetadata: event }; - } - case EventType.UNAUTHORIZE_INTEGRATION: { - const event = doc.event.metadata; - return { ...eventFields, eventMetadata: event }; - } - case EventType.CREATE_INTEGRATION: { - const event = doc.event.metadata; - const integrationId = await integrationKv - .get(event.integrationId) - .catch(() => event.integrationId); - return { - ...eventFields, - eventMetadata: { ...event, integrationId }, - }; - } - case EventType.DELETE_INTEGRATION: { - const event = doc.event.metadata; - const integrationId = await integrationKv - .get(event.integrationId) - .catch(() => event.integrationId); - return { - ...eventFields, - eventMetadata: { ...event, integrationId }, - }; - } - case EventType.ADD_TRUSTED_IP: { - const event = doc.event.metadata; - const trustedIpId = await trustedIpKv - .get(event.trustedIpId) - .catch(() => event.trustedIpId); - return { - ...eventFields, - eventMetadata: { ...event, trustedIpId }, - }; - } - case EventType.UPDATE_TRUSTED_IP: { - const event = doc.event.metadata; - const trustedIpId = await trustedIpKv - .get(event.trustedIpId) - .catch(() => event.trustedIpId); - return { - ...eventFields, - eventMetadata: { ...event, trustedIpId }, - }; - } - case EventType.DELETE_TRUSTED_IP: { - const event = doc.event.metadata; - const trustedIpId = await trustedIpKv - .get(event.trustedIpId) - .catch(() => event.trustedIpId); - if (!trustedIpId) return; - return { - ...eventFields, - eventMetadata: { ...event, trustedIpId }, - }; - } - case EventType.CREATE_SERVICE_TOKEN: { - const event = doc.event.metadata; - return { - ...eventFields, - eventMetadata: event, - }; - } - case EventType.DELETE_SERVICE_TOKEN: { - const event = doc.event.metadata; - return { - ...eventFields, - eventMetadata: event, - }; - } - case EventType.CREATE_IDENTITY: { - const event = doc.event.metadata; - const trustedIpId = await identityKv - .get(event.identityId) - .catch(() => event.identityId); - return { - ...eventFields, - eventMetadata: { ...event, trustedIpId }, - }; - } - case EventType.UPDATE_IDENTITY: { - const event = doc.event.metadata; - const trustedIpId = await identityKv - .get(event.identityId) - .catch(() => event.identityId); - return { - ...eventFields, - eventMetadata: { ...event, trustedIpId }, - }; - } - case EventType.DELETE_IDENTITY: { - const event = doc.event.metadata; - const trustedIpId = await identityKv - .get(event.identityId) - .catch(() => event.identityId); - return { - ...eventFields, - eventMetadata: { ...event, trustedIpId }, - }; - } - case EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH: { - const event = doc.event.metadata; - const identityId = await getKeyWithFallack( - identityKv, - event.identityId, - ); - const identityAccessTokenId = await getKeyWithFallack( - identityAccessTokenKv, - event.identityAccessTokenId, - ); - const identityUniversalAuthId = await getKeyWithFallack( - identityUaKv, - event.identityUniversalAuthId, - ); - const clientSecretId = await getKeyWithFallack( - identityUaClientSecKv, - event.clientSecretId, - ); - return { - ...eventFields, - eventMetadata: { - identityId, - identityAccessTokenId, - identityUniversalAuthId, - clientSecretId, - }, - }; - } - case EventType.ADD_IDENTITY_UNIVERSAL_AUTH: { - const event = doc.event.metadata; - const identityId = await getKeyWithFallack( - identityKv, - event.identityId, - ); - return { - ...eventFields, - eventMetadata: { - ...event, - identityId, - }, - }; - } - case EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH: { - const event = doc.event.metadata; - const identityId = await getKeyWithFallack( - identityKv, - event.identityId, - ); - return { - ...eventFields, - eventMetadata: { - ...event, - identityId, - }, - }; - } - case EventType.GET_IDENTITY_UNIVERSAL_AUTH: { - const event = doc.event.metadata; - const identityId = await getKeyWithFallack( - identityKv, - event.identityId, - ); - return { - ...eventFields, - eventMetadata: { - identityId, - }, - }; - } - case EventType.CREATE_ENVIRONMENT: { - const event = doc.event.metadata; - return { - ...eventFields, - eventMetadata: event, - }; - } - case EventType.UPDATE_ENVIRONMENT: { - const event = doc.event.metadata; - return { - ...eventFields, - eventMetadata: event, - }; - } - case EventType.DELETE_ENVIRONMENT: { - const event = doc.event.metadata; - return { - ...eventFields, - eventMetadata: event, - }; - } - case EventType.ADD_WORKSPACE_MEMBER: { - const event = doc.event.metadata; - const userId = await getKeyWithFallack(userKv, event.userId); - return { ...eventFields, eventMetadata: { ...event, userId } }; - } - case EventType.ADD_BATCH_WORKSPACE_MEMBER: { - const event = doc.event.metadata; - const pgEvent = await Promise.all( - event.flatMap(async ({ userId, email }) => ({ - userId: await getKeyWithFallack(userKv, userId), - email, - })), - ); - return { ...eventFields, eventMetadata: pgEvent }; - } - case EventType.REMOVE_WORKSPACE_MEMBER: { - const event = doc.event.metadata; - const userId = await getKeyWithFallack(userKv, event.userId); - return { ...eventFields, eventMetadata: { ...event, userId } }; - } - case EventType.CREATE_FOLDER: { - const event = doc.event.metadata; - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(event.environment), - ); - const folderId = await getKeyWithFallack( - folderKv as any, - event.folderId, - ); - return { ...eventFields, eventMetadata: { ...event, folderId } }; - } - case EventType.UPDATE_FOLDER: { - const event = doc.event.metadata; - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(event.environment), - ); - const folderId = await getKeyWithFallack( - folderKv as any, - event.folderId, - ); - return { ...eventFields, eventMetadata: { ...event, folderId } }; - } - case EventType.DELETE_FOLDER: { - const event = doc.event.metadata; - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(event.environment), - ); - const folderId = await getKeyWithFallack( - folderKv as any, - event.folderId, - ); - return { ...eventFields, eventMetadata: { ...event, folderId } }; - } - case EventType.CREATE_WEBHOOK: { - const event = doc.event.metadata; - const webhookId = await getKeyWithFallack( - webhookKv, - event.webhookId, - ); - return { ...eventFields, eventMetadata: { ...event, webhookId } }; - } - case EventType.UPDATE_WEBHOOK_STATUS: { - const event = doc.event.metadata; - const webhookId = await getKeyWithFallack( - webhookKv, - event.webhookId, - ); - return { ...eventFields, eventMetadata: { ...event, webhookId } }; - } - case EventType.DELETE_WEBHOOK: { - const event = doc.event.metadata; - const webhookId = await getKeyWithFallack( - webhookKv, - event.webhookId, - ); - return { ...eventFields, eventMetadata: { ...event, webhookId } }; - } - case EventType.CREATE_SECRET_IMPORT: { - const event = doc.event.metadata; - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(event.importToEnvironment), - ); - const folderId = await getKeyWithFallack( - folderKv as any, - event.folderId, - ); - const secretImportId = await getKeyWithFallack( - secretImportKv, - event.secretImportId, - ); - return { - ...eventFields, - eventMetadata: { ...event, folderId, secretImportId }, - }; - } - case EventType.UPDATE_SECRET_IMPORT: { - const event = doc.event.metadata; - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(event.importToEnvironment), - ); - const folderId = await getKeyWithFallack( - folderKv as any, - event.folderId, - ); - const secretImportId = await getKeyWithFallack( - secretImportKv, - event.secretImportId, - ); - return { - ...eventFields, - eventMetadata: { ...event, folderId, secretImportId }, - }; - } - case EventType.DELETE_SECRET_IMPORT: { - const event = doc.event.metadata; - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(event.importToEnvironment), - ); - const folderId = await getKeyWithFallack( - folderKv as any, - event.folderId, - ); - const secretImportId = await getKeyWithFallack( - secretImportKv, - event.secretImportId, - ); - return { - ...eventFields, - eventMetadata: { ...event, folderId, secretImportId }, - }; - } - case EventType.UPDATE_USER_WORKSPACE_ROLE: { - const event = doc.event.metadata; - const userId = await getKeyWithFallack(userKv, event.userId); - return { - ...eventFields, - eventMetadata: { ...event, userId }, - }; - } - case EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS: { - const event = doc.event.metadata; - const userId = await getKeyWithFallack(userKv, event.userId); - return { - ...eventFields, - eventMetadata: { ...event, userId }, - }; - } - case EventType.SECRET_APPROVAL_MERGED: { - const event = doc.event.metadata; - const secretApprovalRequestId = await getKeyWithFallack( - secretApprovalRequestKv, - event.secretApprovalRequestId, - ); - const mergedBy = await getKeyWithFallack( - projectMembKv, - event.mergedBy, - ); - return { - ...eventFields, - eventMetadata: { ...event, secretApprovalRequestId, mergedBy }, - }; - } - case EventType.SECRET_APPROVAL_REQUEST: { - const event = doc.event.metadata; - const secretApprovalRequestId = await getKeyWithFallack( - secretApprovalRequestKv, - event.secretApprovalRequestId, - ); - const committedBy = await getKeyWithFallack( - projectMembKv, - event.committedBy, - ); - return { - ...eventFields, - eventMetadata: { ...event, secretApprovalRequestId, committedBy }, - }; - } - case EventType.SECRET_APPROVAL_CLOSED: { - const event = doc.event.metadata; - const secretApprovalRequestId = await getKeyWithFallack( - secretApprovalRequestKv, - event.secretApprovalRequestId, - ); - const closedBy = await getKeyWithFallack( - projectMembKv, - event.closedBy, - ); - return { - ...eventFields, - eventMetadata: { ...event, secretApprovalRequestId, closedBy }, - }; - } - case EventType.SECRET_APPROVAL_REOPENED: { - const event = doc.event.metadata; - const secretApprovalRequestId = await getKeyWithFallack( - secretApprovalRequestKv, - event.secretApprovalRequestId, - ); - const reopenedBy = await getKeyWithFallack( - projectMembKv, - event.reopenedBy, - ); - return { - ...eventFields, - eventMetadata: { ...event, secretApprovalRequestId, reopenedBy }, - }; - } - default: { - throw new Error("Unknown field"); - } - } - }, - }); - } catch (error) { - console.error(error); - } - process.exit(0); -}; - -main(); diff --git a/pg-migrator/src/folder.ts b/pg-migrator/src/folder.ts deleted file mode 100644 index ac03d9923..000000000 --- a/pg-migrator/src/folder.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TFolderSchema } from "./models"; - -export const folderBfsTraversal = async ( - root: TFolderSchema, - callback: ( - data: TFolderSchema & { parentId: string | null }, - ) => void | Promise, -) => { - const queue = [root]; - while (queue.length) { - const folder = queue.pop() as TFolderSchema & { parentId: null }; - callback(folder); - queue.push( - ...folder.children.map((el) => ({ - ...el, - parentId: folder.id, - })), - ); - } -}; - -export const flattenFolders = (folders: TFolderSchema) => { - const flattened: { - id: string; - parentId: string | null; - name: string; - version: number; - }[] = []; - - if(!folders) return [] - - folderBfsTraversal(folders, ({ name, version, parentId, id }) => { - flattened.push({ name, version, parentId, id }); - }); - return flattened; -}; diff --git a/pg-migrator/src/index.ts b/pg-migrator/src/index.ts deleted file mode 100644 index ada5931c6..000000000 --- a/pg-migrator/src/index.ts +++ /dev/null @@ -1,2512 +0,0 @@ -import promptSync from "prompt-sync"; -import mongoose, { Model } from "mongoose"; -import dotenv from "dotenv"; -import knex, { Knex } from "knex"; -import path from "path"; -import { Level } from "level"; -import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; - -import { - APIKeyData, - BackupPrivateKey, - Bot, - BotKey, - BotOrg, - CommitType, - Folder, - FolderVersion, - GitAppInstallationSession, - GitAppOrganizationInstallation, - GitRisks, - Identity, - IdentityAccessToken, - IdentityMembership, - IdentityMembershipOrg, - IdentityUniversalAuth, - IdentityUniversalAuthClientSecret, - IncidentContactOrg, - Integration, - IntegrationAuth, - Key, - Membership, - MembershipOrg, - Organization, - Role, - SSOConfig, - Secret, - SecretApprovalPolicy, - SecretApprovalRequest, - SecretBlindIndexData, - SecretImport, - SecretSnapshot, - SecretVersion, - ServiceTokenData, - Tag, - TrustedIP, - User, - UserAction, - Webhook, - Workspace, -} from "./models"; -import { TableName } from "./schemas"; -import { v4 as uuidV4 } from "uuid"; -import { Tables } from "knex/types/tables"; -import { ServerConfig } from "./models/serverConfig"; -import { flattenFolders } from "./folder"; -import { SecretRotation } from "./models/secretRotation"; - -import { customAlphabet } from "nanoid"; - -const SLUG_ALPHABETS = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; -export const alphaNumericNanoId = customAlphabet(SLUG_ALPHABETS, 10); - -enum SecretEncryptionAlgo { - AES_256_GCM = "aes-256-gcm", -} - -const ENV_SLUG_LENGTH = 500; - -enum SecretKeyEncoding { - UTF8 = "utf8", - BASE64 = "base64", - HEX = "hex", -} - -const kdb = new Level("./db", { valueEncoding: "json" }); -const getFolderVersionKey = (folderId: string, version: number) => - `${folderId}:${version}`; - -const projectKv = kdb.sublevel(TableName.Project); -const envPKv = kdb.sublevel(TableName.Environment); - -export const getEnvId = (workspace: string, environment: string) => { - const envKv = envPKv.sublevel(workspace); - return envKv.get(environment); -}; -export const getFolderKv = (workspace: string, environment: string) => { - const envKv = envPKv.sublevel(workspace); - return envKv.sublevel(environment); -}; - -const checkIfFolderIsDangling = async ( - projectId: string, - env_slug: string, - folderId: string, -) => { - const kv = getFolderKv(projectId, truncateAndSlugify(env_slug)); - const result = await kv.get(`${folderId}:dead`).catch(() => null); - return Boolean(result); -}; - -const migrationCheckPointsKv = kdb.sublevel("CHECK-POINTS"); - -export const truncateAndSlugify = (slug: string): string => { - return slugify(slug.slice(0, ENV_SLUG_LENGTH)); -}; - -/** - * Sorts an array of items into groups. The return value is a map where the keys are - * the group ids the given getGroupId function produced and the value is an array of - * each item in that group. - */ -export const groupBy = ( - array: readonly T[], - getGroupId: (item: T) => Key, -): Record => - array.reduce( - (acc, item) => { - const groupId = getGroupId(item); - if (!acc[groupId]) acc[groupId] = []; - acc[groupId].push(item); - return acc; - }, - {} as Record, - ); - -export const migrateCollection = async < - T extends {}, - K extends keyof Tables, - R extends (keyof Tables[K]["base"])[] = [], ->({ - db, - postPgProcessing, - preProcessing, - mongooseCollection, - postgresTableName, - returnKeys, - filter, -}: { - db: Knex; - returnKeys: R; - postPgProcessing?: ( - preData: T[], - data: Pick[], - ) => void | Promise; - preProcessing: ( - data: T, - ) => - | Tables[K]["base"] - | Tables[K]["base"][] - | Promise - | undefined; - postgresTableName: K; - mongooseCollection: Model; - filter?: Record; -}) => { - // check ones that have already been migrated - const migrationCheckPointsKvRes = await migrationCheckPointsKv - .get(`${postgresTableName}-${mongooseCollection.modelName}`) - .catch(() => null); - - if (migrationCheckPointsKvRes) { - console.log( - `Skipping Postgres table '${postgresTableName}' because of check point`, - ); - return; - } - - const mongooseDoc: T[] = []; - const pgDoc: Tables[K]["base"][] = []; // pre processed data ready to be inserted into PSQL - - console.log( - "Starting migration of ", - mongooseCollection.modelName, - " Postgres table name:", - postgresTableName, - ); - const totalMongoCount = - postgresTableName === TableName.AuditLog - ? await mongooseCollection.estimatedDocumentCount() // this is because audit log is too big - : await mongooseCollection.countDocuments(); - - console.log("Total documents", totalMongoCount); - console.log("Total batches", Math.ceil(totalMongoCount / 1000)); - let batch = 1; - - for await (const doc of mongooseCollection - .find(filter || {}) - .cursor({ batchSize: 100 })) { - mongooseDoc.push(doc); - const preProcessedData = await preProcessing( - doc.toObject({ virtuals: true }), - ); - if (preProcessedData) { - if (Array.isArray(preProcessedData)) { - pgDoc.push( - ...(preProcessedData.filter(Boolean) as Tables[K]["base"][]), - ); - } else { - pgDoc.push(preProcessedData); - } - } - if (mongooseDoc.length >= 1000) { - console.log("Batch No.:", batch); - if (!pgDoc.length) - console.log("Skipping this batch due to empty pre processor", batch); - if (pgDoc.length) { - const newUserIds = await db.transaction(async (tx) => { - return (await tx - .batchInsert(postgresTableName, pgDoc as any) - .returning(returnKeys as any)) as Pick< - Tables[K]["base"], - R[number] - >[]; - }); - await postPgProcessing?.(mongooseDoc, newUserIds); - } - batch += 1; - mongooseDoc.splice(0, mongooseDoc.length); - pgDoc.splice(0, pgDoc.length); - } - } - if (mongooseDoc.length) { - console.log("Batch No.:", batch); - if (!pgDoc.length) - console.log("Skipping this batch due to empty pre processor", batch); - if (pgDoc.length) { - const newUserIds = await db.transaction(async (tx) => { - return (await tx - .batchInsert(postgresTableName, pgDoc as any) - .returning(returnKeys as any)) as Pick< - Tables[K]["base"], - R[number] - >[]; - }); - await postPgProcessing?.(mongooseDoc, newUserIds); - } - batch += 1; - mongooseDoc.splice(0, mongooseDoc.length); - pgDoc.splice(0, pgDoc.length); - } - - migrationCheckPointsKv.put( - `${postgresTableName}-${mongooseCollection.modelName}`, - "done", - ); - - console.log( - "Finished migration of ", - mongooseCollection.modelName, - " Postgres table name:", - postgresTableName, - ); -}; - -const main = async () => { - try { - dotenv.config(); - - // process.env.MONGO_DB_URL = "mongodb://root:example@localhost:27017/test?authSource=admin" - - // process.env.POSTGRES_DB_URL = "postgres://infisical:infisical@localhost/infisical?sslmode=disable" - - process.env.START_FRESH = "true"; - const prompt = promptSync({ sigint: true }); - - let mongodb_url = process.env.MONGO_DB_URL; - if (!mongodb_url) { - mongodb_url = prompt("Type the mongodb url: "); - } - console.log("Checking mongoose connection..."); - await mongoose.connect(mongodb_url); - console.log("Connected successfully to mongo"); - - let postgres_url = process.env.POSTGRES_DB_URL; - if (!postgres_url) { - postgres_url = prompt("Type the postgres url: "); - } - - console.log("Checking postgres connection..."); - const db = knex({ - client: "pg", - connection: postgres_url, - migrations: { - directory: path.join(__dirname, "../../backend/src/db/migrations"), - extension: "ts", - tableName: "infisical_migrations", - }, - }); - console.log("Connected successfully to postgres"); - await db.raw("select 1+1 as result"); - - if (process.env.START_FRESH === "true") { - await migrationCheckPointsKv.clear(); - - console.log("Starting rolling back to latest, comment this out later"); - await db.migrate.rollback({}, true); - await kdb.clear(); - console.log("Rolling back completed"); - - console.log("Executing migration"); - await db.migrate.latest(); - console.log("Completed migration"); - } - - const userKv = kdb.sublevel(TableName.Users); - await migrateCollection({ - db, - mongooseCollection: User, - postgresTableName: TableName.Users, - returnKeys: ["id", "email"], - preProcessing: async (doc) => { - if (["64058e0ea5c55c6a8203fed7", "64155f5d75c91bf4e176eb85", "6434ff80b82e04f17008aa13"].includes(doc._id.toString())) { - console.log("Skipping duplicate user") - return - } - - const id = uuidV4(); - await userKv.put(doc.id.toString(), id); - - return { - id, - firstName: doc.firstName, - email: doc.email, - devices: JSON.stringify(doc.devices), - lastName: doc.lastName, - isAccepted: Boolean(doc.publicKey), - superAdmin: doc.superAdmin, - authMethods: doc.authMethods, - isMfaEnabled: doc.isMfaEnabled, - createdAt: new Date(doc.createdAt), - updatedAt: new Date(doc.updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: User, - postgresTableName: TableName.UserEncryptionKey, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - if (!doc.publicKey || !doc.encryptedPrivateKey || !doc.iv) return; - const userId = await userKv.get(doc.id.toString()).catch(() => null); - if (!userId) return; - - return { - id, - iv: doc.iv, - tag: doc.tag as string, - salt: doc.salt as string, - verifier: doc.verifier as string, - publicKey: doc.publicKey, - userId, - protectedKey: doc.protectedKey as string, - protectedKeyIV: doc.protectedKeyIV as string, - protectedKeyTag: doc.protectedKeyTag as string, - encryptedPrivateKey: doc.encryptedPrivateKey as string, - encryptionVersion: doc.encryptionVersion as number, - // for change password - clientPublicKey: null, - serverPrivateKey: null, - }; - }, - }); - // skipping user auth token and token sessions - // lets reset and ask users to login - - await migrateCollection({ - db, - mongooseCollection: BackupPrivateKey, - postgresTableName: TableName.BackupPrivateKey, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const userId = await userKv.get(doc.user.toString()).catch(() => null); - if (!userId) return; - - return { - id, - encryptedPrivateKey: doc.encryptedPrivateKey, - userId, - verifier: doc.verifier, - salt: doc.salt, - tag: doc.tag, - iv: doc.iv, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8, - createdAt: new Date(doc.createdAt), - updatedAt: new Date(doc.updatedAt), - }; - }, - }); - - const orgKv = kdb.sublevel(TableName.Organization); - await migrateCollection({ - db, - mongooseCollection: Organization, - postgresTableName: TableName.Organization, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - await orgKv.put(doc._id.toString(), id); - return { - id, - name: doc.name, - slug: slugify(`${doc.name}-${alphaNumericNanoId(4)}`), - customerId: doc.customerId, - createdAt: new Date(doc.createdAt), - updatedAt: new Date(doc.updatedAt), - }; - }, - }); - - const orgRoleKv = kdb.sublevel(TableName.OrgRoles); - await migrateCollection({ - db, - filter: { isOrgRole: true }, - mongooseCollection: Role, - postgresTableName: TableName.OrgRoles, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - await orgRoleKv.put(doc._id.toString(), id); - return { - id, - name: doc.name, - orgId, - description: doc.description, - slug: truncateAndSlugify(doc.slug), - permissions: doc.permissions - ? JSON.stringify(packRules(doc.permissions as any)) - : null, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: MembershipOrg, - postgresTableName: TableName.OrgMembership, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - const userId = doc?.user - ? await userKv.get(doc.user.toString()).catch(() => null) - : null; - if (!userId) return; - - const roleId = doc.customRole - ? await orgRoleKv.get(doc.customRole.toString()) - : null; - - return { - id, - role: doc.role, - orgId, - roleId, - userId, - status: doc.status, - inviteEmail: doc.inviteEmail, - createdAt: new Date(doc.createdAt), - updatedAt: new Date(doc.updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: IncidentContactOrg, - postgresTableName: TableName.IncidentContact, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - return { - id, - email: doc.email, - orgId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: UserAction, - postgresTableName: TableName.UserAction, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const userId = await userKv.get(doc.user.toString()).catch(() => null); - if (!userId) return; - - return { - id, - userId, - action: doc.action, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: ServerConfig, - postgresTableName: TableName.SuperAdmin, - returnKeys: ["id"], - preProcessing: async (doc) => { - return { - id: "00000000-0000-0000-0000-000000000000", - allowSignUp: doc.allowSignUp, - initialized: doc.initialized, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: APIKeyData, - postgresTableName: TableName.ApiKey, - returnKeys: ["id"], - preProcessing: async (doc) => { - const userId = await userKv.get(doc.user.toString()).catch(() => null); - if (!userId) return; - - // expired tokens can be removed - if (new Date(doc.expiresAt) < new Date()) return; - return { - id: doc._id.toString(), - userId, - name: doc.name, - lastUsed: doc.lastUsed, - secretHash: doc.secretHash, - expiresAt: doc.expiresAt, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: Workspace, - postgresTableName: TableName.Project, - returnKeys: ["id"], - preProcessing: async (doc) => { - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - await projectKv.put(doc._id.toString(), doc._id.toString()); - - // expired tokens can be removed - // cannot use this uuid for the org id - return { - id: doc._id.toString(), - name: doc.name.slice(0, 60), - slug: slugify(`${doc.name.slice(0, 60)}-${alphaNumericNanoId(4)}`), - orgId, - autoCapitalization: doc.autoCapitalization, - createdAt: new Date(), - updatedAt: new Date(), - }; - }, - }); - - console.log( - "Migrating environments from Mongo Project -> Pg Environment Table", - ); - const envPKv = kdb.sublevel(TableName.Environment); - - const getEnvId = async (workspace: string, environment: string) => { - const envKv = envPKv.sublevel(workspace); - return envKv.get(environment); - }; - const getFolderKv = (workspace: string, environment: string) => { - const envKv = envPKv.sublevel(workspace); - return envKv.sublevel(environment); - }; - - await migrateCollection({ - db, - mongooseCollection: Workspace, - postgresTableName: TableName.Environment, - returnKeys: ["id"], - preProcessing: async (doc) => { - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - const projectKvRes = await projectKv - .get(doc._id.toString()) - .catch(() => null); - if (!projectKvRes) return; - - // to we scope environments into each project then map each slug with respective id - const envKv = envPKv.sublevel(doc._id.toString()); - - // expired tokens can be removed - // cannot use this uuid for the org id - return Promise.all( - doc.environments.map(async (env, index) => { - const id = uuidV4(); - await envKv.put(truncateAndSlugify(env.slug), id); - return { - id, - name: env.name, - slug: truncateAndSlugify(env.slug), - position: index + 1, - projectId: doc._id.toString(), - createdAt: new Date(), - updatedAt: new Date(), - }; - }), - ); - }, - }); - - console.log("Creating root folders for all environments"); - await migrateCollection({ - db, - mongooseCollection: Workspace, - postgresTableName: TableName.SecretFolder, - returnKeys: ["id"], - preProcessing: async (doc) => { - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - let results = []; - for (const env of doc.environments) { - const id = uuidV4(); - - // case: we forgot to clean up folders that belong to deleted env slugs - const isEnvFound = await getEnvId( - doc._id.toString(), - truncateAndSlugify(env.slug), - ).catch(() => null); - - if (!isEnvFound) continue; - - const envId = await getEnvId( - doc._id.toString(), - truncateAndSlugify(env.slug), - ); - - const folderKv = getFolderKv( - doc._id.toString(), - truncateAndSlugify(env.slug), - ); - - await folderKv.put("root", id); - results.push({ - id, - name: "root", - envId, - version: 1, - createdAt: new Date(), - updatedAt: new Date(), - }); - } - - return results; - }, - }); - - const projectKeyKv = kdb.sublevel(TableName.ProjectKeys); - await migrateCollection({ - db, - mongooseCollection: Key, - postgresTableName: TableName.ProjectKeys, - returnKeys: ["id"], - preProcessing: async (doc) => { - // expired tokens can be removed - // cannot use this uuid for the org id - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const id = uuidV4(); - await projectKeyKv.put(doc._id.toString(), id); - - const senderId = await userKv - .get(doc.sender.toString()) - .catch(() => null); - if (!senderId) return; - - const receiverId = await userKv - .get(doc.receiver.toString()) - .catch(() => null); - if (!receiverId) return; - - return { - id, - projectId: doc.workspace.toString(), - senderId, - nonce: doc.nonce, - encryptedKey: doc.encryptedKey, - receiverId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - console.log("Migrating roles from Mongo Project -> Pg Project Role Table"); - const projectRoleKv = kdb.sublevel(TableName.ProjectRoles); - await migrateCollection({ - db, - filter: { isOrgRole: false }, - mongooseCollection: Role, - postgresTableName: TableName.ProjectRoles, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - await projectRoleKv.put(doc._id.toString(), id); - return { - id, - name: doc.name, - projectId: doc.workspace.toString(), - description: doc.description, - slug: truncateAndSlugify(doc.slug), - permissions: doc.permissions - ? JSON.stringify(packRules(doc.permissions as any)) - : null, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const projectMembKv = kdb.sublevel(TableName.ProjectMembership); - await migrateCollection({ - db, - mongooseCollection: Membership, - postgresTableName: TableName.ProjectMembership, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const userId = await userKv.get(doc.user.toString()).catch(() => null); - if (!userId) return; - - const roleId = doc.customRole - ? await projectRoleKv.get(doc.customRole.toString()) - : null; - await projectMembKv.put(doc._id.toString(), id); - - return { - id, - role: doc.role, - roleId, - projectId: doc.workspace.toString(), - userId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: Folder, - postgresTableName: TableName.SecretFolder, - returnKeys: ["id"], - preProcessing: async (doc) => { - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - // case: we forgot to clean up folders that belong to deleted env slugs - const isEnvFound = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ).catch(() => null); - - if (!isEnvFound) return; - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - const folders = flattenFolders(doc.nodes); - if (!folders) return; - - const pgFolder = []; - for (const folder of folders) { - if (folder.id !== "root") { - const { name, version } = folder; - const id = uuidV4(); - await folderKv.put(folder.id, id); - const parentId = folder?.parentId - ? await folderKv.get(folder?.parentId).catch((e) => { - console.log("parent folder not found==>", folder); - throw e; - }) - : null; - - pgFolder.push({ - name, - version, - id, - parentId, - envId, - createdAt: (doc as any).createdAt, - updatedAt: (doc as any).updatedAt, - }); - } - } - - return pgFolder; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: FolderVersion, - postgresTableName: TableName.SecretFolderVersion, - returnKeys: ["id"], - preProcessing: async (doc) => { - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - // looping through env, we can come across envs that do not exist in the present state. - // This is because some folder snap shots were taken when that env slug exists but the same env slug was later deleted - let envId: string; - try { - const isEnvFound = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ).catch(() => null); - if (!isEnvFound) return; - - envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - } catch (e) { - return; - } - - const rootFolders = (doc?.nodes?.children || []).map( - ({ name, version, id }) => ({ - name, - version, - id, - }), - ); - - const pgFolder = await Promise.all( - rootFolders.map(async (folder) => { - const { name, version } = folder; - const id = uuidV4(); - - await folderKv.put(getFolderVersionKey(folder.id, version), id); - - // we are looking for each folder in folder versions and some we might not see - // because folderKv is ony keeps track of folders that are present in dashboard. So those we don't find, we'll add to same kv but add prefix `dead`. - // This way, we can handle dead ones we encounter in the future - const folderId = await folderKv.get(folder.id).catch(async () => { - const newFolderId = uuidV4(); - await folderKv.put(`${folder.id}:dead`, id); // we are adding dead folders that are not present in dashboard - return newFolderId; - }); - - return { - name, - version, - id, - folderId, - envId, - createdAt: (doc as any).createdAt, - updatedAt: (doc as any).updatedAt, - }; - }), - ); - - return pgFolder; - }, - }); - - const secretImportKv = kdb.sublevel(TableName.SecretImport); - await migrateCollection({ - db, - mongooseCollection: SecretImport, - postgresTableName: TableName.SecretImport, - returnKeys: ["id"], - preProcessing: async (doc) => { - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const envKv = envPKv.sublevel(doc.workspace.toString()); - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - if ( - await checkIfFolderIsDangling( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - doc.folderId, - ) - ) { - return; - } - - // case: when import is created for a given folder and later the folder is deleted AND the import with that folder ref is not deleted THEN this folder won't exist?? :( - const folderId = await folderKv.get(doc.folderId).catch(() => null); - - if (!folderId) return; - - return Promise.all( - doc.imports - .map(async ({ environment, secretPath }, index) => { - const id = uuidV4(); - // case: when import is created but later the env slug is deleted HOWEVER the import with that env slug was not deleted :( - const importEnv = await envKv - .get(truncateAndSlugify(environment)) - .catch(() => null); - if (!importEnv) return; - - return { - id, - folderId, - position: index + 1, - version: 1, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - importEnv, - importPath: secretPath, - }; - }) - .filter(Boolean), - ); - }, - }); - - const tagKv = kdb.sublevel(TableName.SecretTag); - await migrateCollection({ - db, - mongooseCollection: Tag, - postgresTableName: TableName.SecretTag, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - await tagKv.put(doc._id.toString(), id); - - // skip tags that have slugs that are empty - if (doc.slug.length < 1) { - return; - } - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const createdBy = await userKv - .get(doc.user.toString()) - .catch(() => null); - if (!createdBy) return; - - return { - id, - name: doc.name, - slug: truncateAndSlugify(doc.slug), - color: doc.tagColor, - projectId: doc.workspace.toString(), - createdBy: createdBy || null, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: SecretBlindIndexData, - postgresTableName: TableName.SecretBlindIndex, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - return { - id, - projectId: doc.workspace.toString(), - saltIV: doc.saltIV, - saltTag: doc.saltTag, - algorithm: doc.algorithm, - keyEncoding: doc.keyEncoding, - encryptedSaltCipherText: doc.encryptedSaltCiphertext, - createdAt: new Date(), - updatedAt: new Date(), - }; - }, - }); - - const secKv = kdb.sublevel(TableName.Secret); - await migrateCollection({ - db, - mongooseCollection: Secret, - postgresTableName: TableName.Secret, - returnKeys: ["id"], - preProcessing: async (doc) => { - // when env slug is empty - if (doc.environment.length < 1) { - return; - } - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - // case: we forgot to clean up secrets that belong to deleted env slugs - const isEnvFound = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ).catch(() => null); - if (!isEnvFound) return; - - if ( - await checkIfFolderIsDangling( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - doc.folder as string, - ) - ) { - return; - } - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - // Case: if folder id doesn't exist and the root of the folder also doesn;t exist, THEN put the secret at the ROOT - // case: after deleting a folder, we don't clean up the secrets that link to that folder - const folderId = await folderKv - .get(doc.folder || "root") - .catch(() => null); - - // case: when environments are renamed, there used to be a TIMEE when the related folder's slugs weren't updated with it... :( - if (!folderId) return; - - // issue with personal - const userId = doc.user - ? await userKv.get(doc.user.toString()).catch(() => null) - : null; - - if (doc.type === "personal" && !userId) return; - - const id = uuidV4(); - await secKv.put(doc._id.toString(), id); - - return { - id, - keyEncoding: doc.keyEncoding, - algorithm: doc.algorithm, - folderId, - type: doc.type, - version: doc.version, - secretReminderRepeatDays: doc.secretReminderRepeatDays, - userId, - metadata: doc.metadata, - secretKeyIV: doc.secretKeyIV, - secretKeyTag: doc.secretKeyTag, - secretKeyCiphertext: doc.secretKeyCiphertext, - secretValueIV: doc.secretValueIV, - secretValueTag: doc.secretValueTag, - secretValueCiphertext: doc.secretValueCiphertext, - secretBlindIndex: doc.secretBlindIndex, - secretCommentIV: doc.secretCommentIV, - secretCommentTag: doc.secretCommentTag, - secretCommentCiphertext: doc.secretCommentCiphertext, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - console.log( - "Migrating secret tags from Mongo Secret.tags -> Pg SecretTag table", - ); - await migrateCollection({ - db, - mongooseCollection: Secret, - postgresTableName: TableName.JnSecretTag, - returnKeys: ["id"], - preProcessing: async (doc) => { - return Promise.all( - (doc.tags || [])?.map(async (tagId) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - // case: we forgot to clean up secrets that belong to deleted env slugs - const isEnvFound = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ).catch(() => null); - if (!isEnvFound) return; - - if ( - await checkIfFolderIsDangling( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - doc.folder as string, - ) - ) { - return; - } - - // const userId = doc.user - // ? await userKv.get(doc.user.toString()).catch(() => null) - // : null; - // if (!userId) return; - - const secretId = await secKv.get(doc._id.toString()).catch((e) => { - throw e; - }); - const secretTagId = await tagKv.get(tagId).catch((e) => { - throw e; - }); - return { - id, - [`${TableName.Secret}Id`]: secretId, - [`${TableName.SecretTag}Id`]: secretTagId, - // createdAt: new Date((doc as any).createdAt), - // updatedAt: new Date((doc as any).updatedAt), - }; - }), - ); - }, - }); - - const secVerKv = kdb.sublevel(TableName.SecretVersion); - await migrateCollection({ - db, - mongooseCollection: SecretVersion, - postgresTableName: TableName.SecretVersion, - returnKeys: ["id"], - preProcessing: async (doc) => { - // when env slug is empty - if (doc.environment.length < 1) { - return; - } - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - // case: we forgot to clean up secrets that belong to deleted env slugs - const isEnvFound = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ).catch(() => null); - if (!isEnvFound) return; - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - if ( - await checkIfFolderIsDangling( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - doc.folder as string, - ) - ) { - return; - } - - // Case: if folder id doesn't exist and the root of the folder also doesn;t exist, THEN put the secret at the ROOT - const folderId = await folderKv - .get(doc.folder || "root") - .catch(async () => { - console.log( - "secret location unknown, moving secret to root of env_slug/project", - ); - return await folderKv.get("root"); - }); - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - const userId = doc.user - ? await userKv.get(doc.user.toString()).catch(() => null) - : null; - if (!userId && doc.type === "personal") return; - - const id = uuidV4(); - await secVerKv.put(doc._id.toString(), id); - const secretId = await secKv - .get(doc.secret.toString()) - .catch(async (e) => { - const newId = uuidV4(); - await secKv.put(`${doc.secret.toString()}:dead`, newId); - return newId; - }); - - // comment and reminder are not saved in secret version of mongo - return { - id, - keyEncoding: doc.keyEncoding, - algorithm: doc.algorithm, - folderId, - envId, - type: doc.type, - version: doc.version, - userId, - secretId, - secretKeyIV: doc.secretKeyIV, - secretKeyTag: doc.secretKeyTag, - secretKeyCiphertext: doc.secretKeyCiphertext, - secretValueIV: doc.secretValueIV, - secretValueTag: doc.secretValueTag, - secretValueCiphertext: doc.secretValueCiphertext, - secretBlindIndex: doc.secretBlindIndex, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const projectBotKv = kdb.sublevel(TableName.ProjectBot); - await migrateCollection({ - db, - mongooseCollection: Bot, - postgresTableName: TableName.ProjectBot, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - // if we try to process two bots for the same workspace, then skip - if ( - await projectBotKv.get(doc.workspace.toString()).catch(() => null) - ) { - return; - } - - await projectBotKv.put(doc.workspace.toString(), id); - - // case: skip bots that are inactive (skipped specifically because 6388653a200193a667c7e3f3 has two records, one active one not) - let bot = await Bot.findOne({ - workspace: doc.workspace, - $or: [{ isActive: true }, { isActive: false }], - }) - .sort({ isActive: -1 }) - .lean(); - - // case: when no bots are found for this project, skip - if (!bot) return; - - const botKey = await BotKey.findOne({ bot: bot?._id }); - - const senderId = botKey?.sender - ? await userKv.get(botKey.sender.toString()).catch(() => null) - : null; - - if (!senderId) return; - - return { - id, - algorithm: bot.algorithm, - keyEncoding: bot.keyEncoding, - projectId: bot.workspace.toString(), - name: bot.name, - iv: bot.iv, - tag: bot.tag, - senderId, - isActive: bot.isActive, - publicKey: bot.publicKey, - encryptedProjectKey: botKey?.encryptedKey || null, - encryptedProjectKeyNonce: botKey?.nonce || null, - encryptedPrivateKey: bot.encryptedPrivateKey, - createdAt: new Date((bot as any).createdAt), - updatedAt: new Date((bot as any).updatedAt), - }; - }, - }); - - const integrationAuthKv = kdb.sublevel(TableName.IntegrationAuth); - await migrateCollection({ - db, - mongooseCollection: IntegrationAuth, - postgresTableName: TableName.IntegrationAuth, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - await integrationAuthKv.put(doc._id.toString(), id); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - return { - id, - projectId: doc.workspace.toString(), - keyEncoding: doc.keyEncoding || SecretKeyEncoding.UTF8, - algorithm: doc.algorithm || SecretEncryptionAlgo.AES_256_GCM, - metadata: doc.metadata, - url: doc.url, - teamId: doc.teamId, - accessIV: doc.accessIV, - accessTag: doc.accessTag, - accessCiphertext: doc.accessCiphertext, - accountId: doc.accountId, - namespace: doc.namespace, - refreshIV: doc.refreshIV, - refreshTag: doc.refreshTag, - refreshCiphertext: doc.refreshCiphertext, - integration: doc.integration, - accessIdIV: doc.accessIdIV, - accessIdTag: doc.accessIdTag, - accessIdCiphertext: doc.accessIdCiphertext, - accessExpiresAt: doc.accessExpiresAt, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const integrationKv = kdb.sublevel(TableName.Integration); - await migrateCollection({ - db, - mongooseCollection: Integration, - postgresTableName: TableName.Integration, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - const integrationAuthId = await integrationAuthKv.get( - doc.integrationAuth.toString(), - ); - await integrationKv.put(doc._id.toString(), id); - - return { - id, - integration: doc.integration, - url: doc.url, - metadata: doc.metadata, - isActive: doc.isActive, - secretPath: doc.secretPath, - integrationAuthId, - app: doc.app, - envId, - path: doc.path, - appId: doc.appId, - owner: doc.owner, - scope: doc.scope, - region: doc.region, - targetService: doc.targetService, - targetServiceId: doc.targetServiceId, - targetEnvironment: doc.targetEnvironment, - targetEnvironmentId: doc.targetEnvironmentId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: ServiceTokenData, - postgresTableName: TableName.ServiceToken, - returnKeys: ["id"], - preProcessing: async (doc) => { - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const userId = await userKv.get(doc.user.toString()).catch(() => null); - if (!userId) return; - - return { - id: doc._id.toString(), - projectId: doc.workspace.toString(), - name: doc.name, - createdBy: userId, - iv: doc.iv, - tag: doc.tag, - scopes: JSON.stringify(doc.scopes), - lastUsed: doc.lastUsed, - secretHash: doc.secretHash, - expiresAt: doc.expiresAt, - permissions: doc.permissions, - encryptedKey: doc.encryptedKey, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const webhookKv = kdb.sublevel(TableName.Webhook); - await migrateCollection({ - db, - mongooseCollection: Webhook, - postgresTableName: TableName.Webhook, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - await webhookKv.put(doc._id.toString(), id); - return { - id, - iv: doc.iv, - envId, - secretPath: doc.secretPath, - url: doc.url, - algorithm: doc.algorithm || SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: doc.keyEncoding || SecretKeyEncoding.UTF8, - tag: doc.tag, - isDisabled: doc.isDisabled, - lastStatus: doc.lastStatus, - encryptedSecretKey: doc.encryptedSecretKey, - lastRunErrorMessage: doc.lastRunErrorMessage, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const identityKv = kdb.sublevel(TableName.Identity); - await migrateCollection({ - db, - mongooseCollection: Identity, - postgresTableName: TableName.Identity, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - await identityKv.put(doc._id.toString(), id); - return { - id, - name: doc.name, - authMethod: doc.authMethod, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const identityUaKv = kdb.sublevel(TableName.IdentityUniversalAuth); - await migrateCollection({ - db, - mongooseCollection: IdentityUniversalAuth, - postgresTableName: TableName.IdentityUniversalAuth, - returnKeys: ["id"], - preProcessing: async (doc) => { - // dangling identity - if (!await identityKv.get(doc.identity.toString()).catch(() => null)) { - return - } - - const id = uuidV4(); - const identityId = await identityKv.get(doc.identity.toString()); - await identityUaKv.put(doc._id.toString(), id); - return { - id, - identityId, - clientId: doc.clientId, - accessTokenTTL: doc.accessTokenTTL, - accessTokenMaxTTL: doc.accessTokenMaxTTL, - accessTokenTrustedIps: JSON.stringify( - doc.accessTokenTrustedIps || [], - ), - accessTokenNumUsesLimit: doc.accessTokenNumUsesLimit, - clientSecretTrustedIps: JSON.stringify( - doc.clientSecretTrustedIps || [], - ), - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const identityUaClientSecKv = kdb.sublevel( - TableName.IdentityUaClientSecret, - ); - await migrateCollection({ - db, - mongooseCollection: IdentityUniversalAuthClientSecret, - postgresTableName: TableName.IdentityUaClientSecret, - returnKeys: ["id"], - preProcessing: async (doc) => { - // dangling identity - if (!await identityKv.get(doc.identity.toString()).catch(() => null)) { - return - } - - const identityUAId = await identityUaKv.get( - doc.identityUniversalAuth.toString(), - ); - await identityUaClientSecKv.put(doc._id.toString(), doc._id.toString()); - return { - id: doc._id.toString(), - identityUAId, - description: doc.description, - clientSecretTTL: doc.clientSecretTTL, - clientSecretHash: doc.clientSecretHash, - clientSecretPrefix: doc.clientSecretPrefix, - clientSecretNumUses: doc.clientSecretNumUses, - isClientSecretRevoked: doc.isClientSecretRevoked, - clientSecretLastUsedAt: doc.clientSecretLastUsedAt, - clientSecretNumUsesLimit: doc.clientSecretNumUsesLimit, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const identityAccessTokenKv = kdb.sublevel(TableName.IdentityAccessToken); - await migrateCollection({ - db, - mongooseCollection: IdentityAccessToken, - postgresTableName: TableName.IdentityAccessToken, - returnKeys: ["id"], - preProcessing: async (doc) => { - // dangling identity - if (!await identityKv.get(doc.identity.toString()).catch(() => null)) { - return - } - - await identityAccessTokenKv.put(doc._id.toString(), doc._id.toString()); - const identityUAClientSecretId = doc?.identityUniversalAuthClientSecret - ? await identityUaClientSecKv.get( - doc.identityUniversalAuthClientSecret.toString(), - ) - : null; - const identityId = await identityKv.get(doc.identity.toString()); - return { - id: doc._id.toString(), - accessTokenNumUsesLimit: doc.accessTokenNumUsesLimit, - accessTokenMaxTTL: doc.accessTokenMaxTTL, - accessTokenTTL: doc.accessTokenTTL, - identityId, - accessTokenNumUses: doc.accessTokenNumUses, - isAccessTokenRevoked: doc.isAccessTokenRevoked, - accessTokenLastUsedAt: doc.accessTokenLastUsedAt, - accessTokenLastRenewedAt: doc.accessTokenLastRenewedAt, - identityUAClientSecretId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: IdentityMembershipOrg, - postgresTableName: TableName.IdentityOrgMembership, - returnKeys: ["id"], - preProcessing: async (doc) => { - // dangling identity - if (!await identityKv.get(doc.identity.toString()).catch(() => null)) { - return - } - - const id = uuidV4(); - - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - const identityId = await identityKv.get(doc.identity.toString()); - const roleId = doc.customRole - ? await orgRoleKv.get(doc.customRole.toString()) - : null; - - return { - id, - role: doc.role, - orgId, - identityId, - roleId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: IdentityMembership, - postgresTableName: TableName.IdentityProjectMembership, - returnKeys: ["id"], - preProcessing: async (doc) => { - // dangling identity - if (!await identityKv.get(doc.identity.toString()).catch(() => null)) { - return - } - - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const identityId = await identityKv.get(doc.identity.toString()); - const roleId = doc.customRole - ? await projectRoleKv.get(doc.customRole.toString()) - : null; - - return { - id, - role: doc.role, - identityId, - projectId: doc.workspace.toString(), - roleId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const sapKv = kdb.sublevel(TableName.SecretApprovalPolicy); - await migrateCollection({ - db, - mongooseCollection: SecretApprovalPolicy, - postgresTableName: TableName.SecretApprovalPolicy, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - await sapKv.put(doc._id.toString(), id); - - return { - id, - name: doc.name, - envId, - approvals: doc.approvals, - secretPath: doc.secretPath, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - console.log( - "Migration secret approval policy approvers -> Pg sap approvers table", - ); - await migrateCollection({ - db, - mongooseCollection: SecretApprovalPolicy, - postgresTableName: TableName.SecretApprovalPolicyApprover, - returnKeys: ["id"], - preProcessing: async (doc) => { - const policyId = await sapKv.get(doc._id.toString()); - - return Promise.all( - doc.approvers.map(async (membId) => { - const id = uuidV4(); - const approverId = await projectMembKv.get(membId.toString()); - return { - id, - policyId, - approverId, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }), - ); - }, - }); - - const secRotationKv = kdb.sublevel(TableName.SecretRotation); - await migrateCollection({ - db, - mongooseCollection: SecretRotation, - postgresTableName: TableName.SecretRotation, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - await secRotationKv.put(doc._id.toString(), id); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - return { - id, - envId, - keyEncoding: doc.keyEncoding, - algorithm: doc.algorithm, - secretPath: doc.secretPath, - status: doc.status, - interval: doc.interval, - provider: doc.provider, - encryptedData: doc.encryptedData, - encryptedDataIV: doc.encryptedDataIV, - encryptedDataTag: doc.encryptedDataTag, - lastRotatedAt: doc.lastRotatedAt ? new Date(doc.lastRotatedAt) : null, - statusMessage: doc.statusMessage, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - console.log( - "Migration secret rotation outputs from mongo to postgres secret rotation output table", - ); - await migrateCollection({ - db, - mongooseCollection: SecretRotation, - postgresTableName: TableName.SecretRotationOutput, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - const rotationId = await secRotationKv.get(doc._id.toString()); - - return Promise.all( - doc.outputs.map(async ({ key, secret }) => { - const secretId = await secKv - .get(secret.toString()) - .catch(() => null); - if (!secretId) return; - - return { - id, - key, - secretId, - rotationId, - }; - }), - ); - }, - }); - - const ssoConfigKv = kdb.sublevel(TableName.SamlConfig); - await migrateCollection({ - db, - mongooseCollection: SSOConfig, - postgresTableName: TableName.SamlConfig, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - // case: when a org has two SSO configs, one with encryptedEntryPoint defined should be taken. Others skipped - if (!doc.encryptedEntryPoint) { - return; - } - - await ssoConfigKv.put(orgId.toString(), "true"); - - return { - id, - isActive: doc.isActive, - orgId, - certIV: doc.certIV, - certTag: doc.certTag, - issuerIV: doc.issuerIV, - issuerTag: doc.issuerTag, - authProvider: doc.authProvider, - entryPointIV: doc.entryPointIV, - entryPointTag: doc.entryPointTag, - encryptedEntryPoint: doc.encryptedEntryPoint, - encryptedCert: doc.encryptedCert, - encryptedIssuer: doc.encryptedIssuer, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const botOrgsProcessed = kdb.sublevel(TableName.OrgBot); - await migrateCollection({ - db, - mongooseCollection: BotOrg, - postgresTableName: TableName.OrgBot, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - // case: race condition where there are multiple org bots, we only take one - const botOrgsProcessedRes = await botOrgsProcessed - .get(orgId) - .catch(() => null); - if (botOrgsProcessedRes) { - return; - } - - const ssoConfigRes = await ssoConfigKv.get(orgId).catch(() => null); - if (!ssoConfigRes) { - return; - } - - await botOrgsProcessed.put(orgId, "true"); - - return { - id, - orgId, - name: doc.name, - encryptedPrivateKey: doc.encryptedPrivateKey, - publicKey: doc.publicKey, - privateKeyIV: doc.privateKeyIV, - privateKeyTag: doc.privateKeyTag, - symmetricKeyIV: doc.symmetricKeyIV, - symmetricKeyTag: doc.symmetricKeyTag, - privateKeyAlgorithm: doc.privateKeyAlgorithm, - encryptedSymmetricKey: doc.encryptedSymmetricKey, - privateKeyKeyEncoding: doc.privateKeyKeyEncoding, - symmetricKeyAlgorithm: doc.symmetricKeyAlgorithm, - symmetricKeyKeyEncoding: doc.symmetricKeyKeyEncoding, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: GitAppInstallationSession, - postgresTableName: TableName.GitAppInstallSession, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const orgId = doc?.organization - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - const userId = await userKv.get(doc.user.toString()).catch(() => null); - if (!userId) return; - - return { - id, - orgId, - userId, - sessionId: doc.sessionId, - createdAt: (doc as any)?.createdAt - ? new Date((doc as any).createdAt) - : new Date(), - updatedAt: (doc as any)?.updatedAt - ? new Date((doc as any).updatedAt) - : new Date(), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: GitAppOrganizationInstallation, - postgresTableName: TableName.GitAppOrg, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const orgId = doc?.organizationId.toString() - ? await orgKv.get(doc.organizationId.toString()).catch(() => null) - : null; - if (!orgId) return; - - const userId = await userKv.get(doc.user.toString()).catch(() => null); - if (!userId) return; - - return { - id, - orgId, - userId, - installationId: doc.installationId, - createdAt: (doc as any)?.createdAt - ? new Date((doc as any).createdAt) - : new Date(), - updatedAt: (doc as any)?.updatedAt - ? new Date((doc as any).updatedAt) - : new Date(), - }; - }, - }); - - await migrateCollection({ - db, - mongooseCollection: GitRisks, - postgresTableName: TableName.SecretScanningGitRisk, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const orgId = doc?.organization.toString() - ? await orgKv.get(doc.organization.toString()).catch(() => null) - : null; - if (!orgId) return; - - return { - id, - orgId, - installationId: doc.installationId, - status: doc.status, - tags: doc.tags, - date: doc.date, - file: doc.file, - email: doc.email, - author: doc.author, - commit: doc.commit, - ruleID: doc.ruleID, - endLine: doc.endLine, - entropy: doc.entropy, - message: doc.message, - endColumn: doc.endColumn, - riskOwner: doc.riskOwner, - startLine: doc.startLine, - isResolved: doc.isResolved, - pusherName: doc.pusher?.name, - description: doc.description, - fingerprint: doc.fingerprint, - fingerPrintWithoutCommitId: doc.fingerPrintWithoutCommitId, - pusherEmail: doc.pusher?.email, - startColumn: doc.startColumn, - symlinkFile: doc.symlinkFile, - repositoryId: doc.repositoryId, - repositoryLink: doc.repositoryLink, - isFalsePositive: doc.isFalsePositive, - repositoryFullName: doc.repositoryFullName, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const trustedIpKv = kdb.sublevel(TableName.TrustedIps); - await migrateCollection({ - db, - mongooseCollection: TrustedIP, - postgresTableName: TableName.TrustedIps, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - await trustedIpKv.put(doc._id.toString(), id); - - return { - id, - projectId: doc.workspace.toString(), - type: doc.type, - prefix: doc.prefix, - comment: doc.comment, - ipAddress: doc.ipAddress, - isActive: doc.isActive, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - const snapKv = kdb.sublevel(TableName.Snapshot); - await migrateCollection({ - db, - mongooseCollection: SecretSnapshot, - postgresTableName: TableName.Snapshot, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - await snapKv.put(doc._id.toString(), id); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const envKv = envPKv.sublevel(doc.workspace.toString()); - - // case: env was deleted but still links snapshot, so we don't need it - if ( - !(await envKv - .get(truncateAndSlugify(doc.environment)) - .catch(() => null)) - ) { - return; - } - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - if ( - await checkIfFolderIsDangling( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - doc.folderId, - ) - ) { - return; - } - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - // const folderKv = getFolderKv(doc.workspace.toString(), truncateAndSlugify(doc.environment)); - const folderId = await folderKv.get(doc.folderId).catch(async () => { - // this folder may not exist now in tree then create a new id and assign it - const newId = uuidV4(); - await folderKv.put(doc.folderId, newId); - return newId; - }); - - return { - id, - envId, - folderId, - createdAt: (doc as any).createdAt, - updatedAt: (doc as any).updatedAt, - }; - }, - }); - - console.log("Migrating secret snapshot secrets"); - await migrateCollection({ - db, - mongooseCollection: SecretSnapshot, - postgresTableName: TableName.SnapshotSecret, - returnKeys: ["id"], - preProcessing: async (doc) => { - const snapshotId = await snapKv.get(doc._id.toString()); - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - const envKv = envPKv.sublevel(doc.workspace.toString()); - - // case: env was deleted but still links snapshot, so we don't need it - if ( - !(await envKv - .get(truncateAndSlugify(doc.environment)) - .catch(() => null)) - ) { - return; - } - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - return Promise.all( - doc.secretVersions - .map(async (secVer) => { - const id = uuidV4(); - - // case: for secret versions that have been discarded, skip creating a snapshot for it - const secretVersionId = await secVerKv - .get(secVer.toString()) - .catch(() => null); - if (!secretVersionId) return; - - return { - id, - envId, - snapshotId, - secretVersionId, - createdAt: (doc as any).createdAt, - updatedAt: (doc as any).updatedAt, - }; - }) - .filter(Boolean), - ); - }, - }); - - console.log("Migrating secret snapshot folders"); - await migrateCollection({ - db, - mongooseCollection: SecretSnapshot, - postgresTableName: TableName.SnapshotFolder, - returnKeys: ["id"], - preProcessing: async (doc) => { - const snapshotId = await snapKv.get(doc._id.toString()); - - if (!doc.folderVersion) return; - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - // case: we forgot to clean up secrets that belong to deleted env slugs - const isEnvFound = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ).catch(() => null); - if (!isEnvFound) return; - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - if ( - await checkIfFolderIsDangling( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - doc.folderId, - ) - ) { - return; - } - - const envId = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - const folderVersion = await FolderVersion.findById(doc.folderVersion); - - if (!folderVersion) return; - - return Promise.all( - folderVersion.nodes.children.map(async (folderVer) => { - const id = uuidV4(); - const folderVersionId = await folderKv.get( - getFolderVersionKey(folderVer.id, folderVer.version), - ); - return { - id, - envId, - snapshotId, - folderVersionId, - createdAt: (doc as any).createdAt, - updatedAt: (doc as any).updatedAt, - }; - }), - ); - }, - }); - - const sarKv = kdb.sublevel(TableName.SecretApprovalRequest); - await migrateCollection({ - db, - mongooseCollection: SecretApprovalRequest, - postgresTableName: TableName.SecretApprovalRequest, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - - // case: when the policy has been deleted, the request should also be deleted - const policyId = await sapKv - .get(doc.policy.toString()) - .catch(() => null); - if (!policyId) return; - - const projectKvRes = await projectKv - .get(doc.workspace.toString()) - .catch(() => null); - if (!projectKvRes) return; - - // case: we forgot to clean up secrets that belong to deleted env slugs - const isEnvFound = await getEnvId( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ).catch(() => null); - if (!isEnvFound) return; - - const folderKv = getFolderKv( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - ); - - if ( - await checkIfFolderIsDangling( - doc.workspace.toString(), - truncateAndSlugify(doc.environment), - doc.folderId, - ) - ) { - return; - } - - // Case: if folder id doesn't exist and the root of the folder also doesn't exist, THEN put the secret at the ROOT - const folderId = await folderKv - .get(doc.folderId || "root") - .catch(async () => { - return await folderKv.get("root"); - }); - - // case: when the committer has been removed from Infisical, we should delete all of their requests (past and preset). - const committerId = await projectMembKv - .get(doc.committer.toString()) - .catch(() => null); - if (!committerId) return; - - await sarKv.put(doc._id.toString(), id); - - const statusChangeBy = doc.statusChangeBy - ? await projectMembKv - .get(doc.statusChangeBy.toString()) - .catch(() => null) - : null; - return { - id, - policyId, - hasMerged: doc.hasMerged, - status: doc.status, - conflicts: JSON.stringify(doc.conflicts), - slug: truncateAndSlugify(doc.slug), - folderId, - committerId, - statusChangeBy, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }, - }); - - console.log( - "Migrating Mongo Secret approval request reviewers -> Pg Secret Approval Request reviewers table", - ); - await migrateCollection({ - db, - mongooseCollection: SecretApprovalRequest, - postgresTableName: TableName.SecretApprovalRequestReviewer, - returnKeys: ["id"], - preProcessing: async (doc) => { - const id = uuidV4(); - const requestId = await sarKv.get(doc._id.toString()).catch(() => null); - if (!requestId) return; - - return Promise.all( - doc.reviewers.map(async ({ status, member }) => { - return { - id, - status, - requestId, - member: await projectMembKv.get(member.toString()), - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - }), - ); - }, - }); - - console.log( - "Migrating Mongo Secret approval request secrets -> Pg Secret Approval Request secrets table", - ); - await migrateCollection({ - db, - mongooseCollection: SecretApprovalRequest, - postgresTableName: TableName.SecretApprovalRequestSecret, - returnKeys: ["id"], - preProcessing: async (doc) => { - const requestId = await sarKv.get(doc._id.toString()).catch(() => null); - if (!requestId) return; - - return Promise.all( - doc.commits.map(async (commit) => { - const id = uuidV4(); - - if (commit.op === CommitType.CREATE) { - if (!commit.newVersion.secretBlindIndex) return; - - return { - id, - op: CommitType.CREATE, - requestId, - secretBlindIndex: commit?.newVersion?.secretBlindIndex as string, - keyEncoding: commit.newVersion.keyEncoding as string, - algorithm: commit.newVersion.algorithm as string, - version: commit.newVersion.version, - secretKeyIV: commit.newVersion.secretKeyIV, - secretKeyTag: commit.newVersion.secretKeyTag, - secretKeyCiphertext: commit.newVersion.secretKeyCiphertext, - secretValueIV: commit.newVersion.secretValueIV, - secretValueTag: commit.newVersion.secretValueTag, - secretValueCiphertext: commit.newVersion.secretValueCiphertext, - secretCommentIV: commit.newVersion.secretCommentIV, - secretCommentTag: commit.newVersion.secretCommentTag, - secretCommentCiphertext: - commit.newVersion.secretCommentCiphertext, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - } - if (commit.op === CommitType.UPDATE) { - const id = uuidV4(); - - const secretId = await secKv - .get(commit.secret.toString()) - .catch(() => null); - if (!secretId) return; - - const secret = await Secret.findById(commit.secret); - if (!secret) return; - - if (!secret?.secretBlindIndex) return; - - const secretVersion = await secVerKv.get(commit.secretVersion._id.toString()) - - return { - id, - op: CommitType.UPDATE, - requestId, - secretId, - secretBlindIndex: - commit.newVersion.secretBlindIndex || secret.secretBlindIndex, - keyEncoding: - (commit.newVersion.keyEncoding as string) || - secret.keyEncoding, - algorithm: - (commit.newVersion.algorithm as string) || secret.algorithm, - version: commit.newVersion.version || secret.version, - secretKeyIV: - commit.newVersion.secretKeyIV || secret.secretKeyIV, - secretKeyTag: - commit.newVersion.secretKeyTag || secret.secretKeyTag, - secretKeyCiphertext: - commit.newVersion.secretKeyCiphertext || - secret.secretKeyCiphertext, - secretValueIV: - commit.newVersion.secretValueIV || secret.secretValueIV, - secretValueTag: - commit.newVersion.secretValueTag || secret.secretValueTag, - secretValueCiphertext: - commit.newVersion.secretValueCiphertext || - secret.secretValueCiphertext, - secretCommentIV: - commit.newVersion.secretCommentIV || secret.secretCommentIV, - secretCommentTag: - commit.newVersion.secretCommentTag || secret.secretCommentTag, - secretCommentCiphertext: - commit.newVersion.secretCommentCiphertext || - secret.secretCommentCiphertext, - secretVersion, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - } - if (commit.op === CommitType.DELETE) { - const id = uuidV4(); - - const secretId = await secKv - .get(commit.secret.toString()) - .catch(() => null); - if (!secretId) return; - - const secret = await Secret.findById(commit.secret); - if (!secret) return; - - if (!secret?.secretBlindIndex) return; - - const secretVersion = await secVerKv.get(commit.secretVersion._id.toString()) - - return { - id, - op: CommitType.DELETE, - requestId, - secretId, - secretBlindIndex: secret.secretBlindIndex, - keyEncoding: secret.keyEncoding, - algorithm: secret.algorithm, - version: secret.version, - secretKeyIV: secret.secretKeyIV, - secretKeyTag: secret.secretKeyTag, - secretKeyCiphertext: secret.secretKeyCiphertext, - secretValueIV: secret.secretValueIV, - secretValueTag: secret.secretValueTag, - secretValueCiphertext: secret.secretValueCiphertext, - secretCommentIV: secret.secretCommentIV, - secretCommentTag: secret.secretCommentTag, - secretCommentCiphertext: secret.secretCommentCiphertext, - secretVersion, - createdAt: new Date((doc as any).createdAt), - updatedAt: new Date((doc as any).updatedAt), - }; - } - }), - ); - }, - }); - - console.log("MIGRATION SCRIPT COMPLETED SUCCESSFULLY"); - process.exit(1); - } catch (error) { - console.error(error); - process.exit(1); - } -}; - -main(); diff --git a/pg-migrator/src/migrations/20231128072457_user.ts b/pg-migrator/src/migrations/20231128072457_user.ts deleted file mode 100644 index f0572824e..000000000 --- a/pg-migrator/src/migrations/20231128072457_user.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { - createOnUpdateTrigger, - createUpdateAtTriggerFunction, - dropOnUpdateTrigger, - dropUpdatedAtTriggerFunction -} from "../utils"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.Users); - if (!isTablePresent) { - await knex.schema.createTable(TableName.Users, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("email").notNullable(); - t.specificType("authMethods", "text[]"); - t.boolean("superAdmin").defaultTo(false); - t.string("firstName"); - t.string("lastName"); - t.boolean("isAccepted").defaultTo(false); - t.boolean("isMfaEnabled").defaultTo(false); - t.specificType("mfaMethods", "text[]"); - t.jsonb("devices"); - t.timestamps(true, true, true); - }); - } - // this is a one time function - await createUpdateAtTriggerFunction(knex); - await createOnUpdateTrigger(knex, TableName.Users); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.Users); - await dropOnUpdateTrigger(knex, TableName.Users); - await dropUpdatedAtTriggerFunction(knex); -} diff --git a/pg-migrator/src/migrations/20231128092347_user-encryption-key.ts b/pg-migrator/src/migrations/20231128092347_user-encryption-key.ts deleted file mode 100644 index 2ac292e88..000000000 --- a/pg-migrator/src/migrations/20231128092347_user-encryption-key.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.UserEncryptionKey); - if (!isTablePresent) { - await knex.schema.createTable(TableName.UserEncryptionKey, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.text("clientPublicKey"); - t.text("serverPrivateKey"); - t.integer("encryptionVersion").defaultTo(2); - t.text("protectedKey"); - t.text("protectedKeyIV"); - t.text("protectedKeyTag"); - t.text("publicKey").notNullable(); - t.text("encryptedPrivateKey").notNullable(); - t.text("iv").notNullable(); - t.text("tag").notNullable(); - t.text("salt").notNullable(); - t.text("verifier").notNullable(); - // one to one relationship - t.uuid("userId").notNullable().unique(); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - }); - } -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.UserEncryptionKey); -} diff --git a/pg-migrator/src/migrations/20231129072939_auth-token.ts b/pg-migrator/src/migrations/20231129072939_auth-token.ts deleted file mode 100644 index ed23ebe9d..000000000 --- a/pg-migrator/src/migrations/20231129072939_auth-token.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.AuthTokens); - if (!isTablePresent) { - await knex.schema.createTable(TableName.AuthTokens, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("type").notNullable(); - t.string("phoneNumber"); - t.string("tokenHash").notNullable(); - t.integer("triesLeft"); - t.datetime("expiresAt").notNullable(); - // does not need update trigger we will do it manually - t.timestamps(true, true, true); - t.uuid("userId"); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - }); - } -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.AuthTokens); -} diff --git a/pg-migrator/src/migrations/20231130072734_auth-token-session.ts b/pg-migrator/src/migrations/20231130072734_auth-token-session.ts deleted file mode 100644 index 9d5b98910..000000000 --- a/pg-migrator/src/migrations/20231130072734_auth-token-session.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.AuthTokenSession); - if (!isTablePresent) { - await knex.schema.createTable(TableName.AuthTokenSession, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("ip").notNullable(); - t.string("userAgent"); - t.integer("refreshVersion").notNullable().defaultTo(1); - t.integer("accessVersion").notNullable().defaultTo(1); - t.datetime("lastUsed").notNullable(); - // does not need update trigger we will do it manually - t.timestamps(true, true, true); - t.uuid("userId").notNullable(); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - }); - } - // this is a one time function - await createOnUpdateTrigger(knex, TableName.AuthTokenSession); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.AuthTokenSession); - await dropOnUpdateTrigger(knex, TableName.AuthTokenSession); -} diff --git a/pg-migrator/src/migrations/20231201151432_backup-key.ts b/pg-migrator/src/migrations/20231201151432_backup-key.ts deleted file mode 100644 index 0c5dd2286..000000000 --- a/pg-migrator/src/migrations/20231201151432_backup-key.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; - -export async function up(knex: Knex): Promise { - const doesTableExist = await knex.schema.hasTable(TableName.BackupPrivateKey); - if (!doesTableExist) { - await knex.schema.createTable(TableName.BackupPrivateKey, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.text("encryptedPrivateKey").notNullable(); - t.text("iv").notNullable(); - t.text("tag").notNullable(); - t.string("algorithm").notNullable(); - t.string("keyEncoding").notNullable(); - t.text("salt").notNullable(); - t.text("verifier").notNullable(); - t.timestamps(true, true, true); - t.uuid("userId").notNullable().unique(); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - }); - } -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.BackupPrivateKey); -} diff --git a/pg-migrator/src/migrations/20231204092737_organization.ts b/pg-migrator/src/migrations/20231204092737_organization.ts deleted file mode 100644 index 8928b086a..000000000 --- a/pg-migrator/src/migrations/20231204092737_organization.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.Organization); - if (!isTablePresent) { - await knex.schema.createTable(TableName.Organization, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("customerId"); - t.string("slug").notNullable(); - // does not need update trigger we will do it manually - t.unique("slug"); - t.timestamps(true, true, true); - }); - await knex.schema.alterTable(TableName.AuthTokens, (t) => { - t.uuid("orgId"); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - }); - } - // this is a one time function - await createOnUpdateTrigger(knex, TableName.Organization); -} - -export async function down(knex: Knex): Promise { - if (await knex.schema.hasColumn(TableName.AuthTokens, "orgId")) { - await knex.schema.alterTable(TableName.AuthTokens, (t) => { - t.dropColumn("orgId"); - }); - } - await knex.schema.dropTableIfExists(TableName.Organization); - await dropOnUpdateTrigger(knex, TableName.Organization); -} diff --git a/pg-migrator/src/migrations/20231204092747_org-membership.ts b/pg-migrator/src/migrations/20231204092747_org-membership.ts deleted file mode 100644 index 2b166d3e5..000000000 --- a/pg-migrator/src/migrations/20231204092747_org-membership.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { OrgMembershipStatus } from "../schemas/models"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - const isOrgRolePresent = await knex.schema.hasTable(TableName.OrgRoles); - if (!isOrgRolePresent) { - await knex.schema.createTable(TableName.OrgRoles, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("description"); - t.string("slug").notNullable(); - t.jsonb("permissions").notNullable(); - // does not need update trigger we will do it manually - t.timestamps(true, true, true); - t.uuid("orgId").notNullable(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - }); - } - - const isOrgTablePresent = await knex.schema.hasTable(TableName.OrgMembership); - if (!isOrgTablePresent) { - await knex.schema.createTable(TableName.OrgMembership, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("role").notNullable(); - t.string("status").notNullable().defaultTo(OrgMembershipStatus.Invited); - t.string("inviteEmail"); - // does not need update trigger we will do it manually - t.timestamps(true, true, true); - t.uuid("userId"); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - t.uuid("orgId").notNullable(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.uuid("roleId"); - t.foreign("roleId").references("id").inTable(TableName.OrgRoles); - }); - } - // this is a one time function - await createOnUpdateTrigger(knex, TableName.OrgMembership); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.OrgMembership); - await knex.schema.dropTableIfExists(TableName.OrgRoles); - await dropOnUpdateTrigger(knex, TableName.OrgMembership); -} diff --git a/pg-migrator/src/migrations/20231205151331_incident-contact.ts b/pg-migrator/src/migrations/20231205151331_incident-contact.ts deleted file mode 100644 index a0c1a3c27..000000000 --- a/pg-migrator/src/migrations/20231205151331_incident-contact.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.IncidentContact); - if (!isTablePresent) { - await knex.schema.createTable(TableName.IncidentContact, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("email").notNullable(); - // does not need update trigger we will do it manually - t.timestamps(true, true, true); - t.uuid("orgId").notNullable(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - }); - } - // this is a one time function - await createOnUpdateTrigger(knex, TableName.IncidentContact); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.IncidentContact); - await dropOnUpdateTrigger(knex, TableName.IncidentContact); -} diff --git a/pg-migrator/src/migrations/20231207055643_user-action.ts b/pg-migrator/src/migrations/20231207055643_user-action.ts deleted file mode 100644 index c3b54839c..000000000 --- a/pg-migrator/src/migrations/20231207055643_user-action.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.UserAction); - if (!isTablePresent) { - await knex.schema.createTable(TableName.UserAction, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("action").notNullable(); - t.timestamps(true, true, true); - t.uuid("userId").notNullable(); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - }); - } -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.UserAction); -} diff --git a/pg-migrator/src/migrations/20231207055701_super-admin.ts b/pg-migrator/src/migrations/20231207055701_super-admin.ts deleted file mode 100644 index 6d5f7f32d..000000000 --- a/pg-migrator/src/migrations/20231207055701_super-admin.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); - if (!isTablePresent) { - await knex.schema.createTable(TableName.SuperAdmin, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.boolean("initialized").defaultTo(false); - t.boolean("allowSignUp").defaultTo(true); - t.timestamps(true, true, true); - }); - } - // this is a one time function - await createOnUpdateTrigger(knex, TableName.SuperAdmin); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SuperAdmin); - await dropOnUpdateTrigger(knex, TableName.SuperAdmin); -} diff --git a/pg-migrator/src/migrations/20231207105059_api-key.ts b/pg-migrator/src/migrations/20231207105059_api-key.ts deleted file mode 100644 index 4758973da..000000000 --- a/pg-migrator/src/migrations/20231207105059_api-key.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.ApiKey); - if (!isTablePresent) { - await knex.schema.createTable(TableName.ApiKey, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.datetime("lastUsed"); - t.datetime("expiresAt"); - t.string("secretHash").notNullable(); - t.timestamps(true, true, true); - t.uuid("userId").notNullable(); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - }); - } - await createOnUpdateTrigger(knex, TableName.ApiKey); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.ApiKey); - await dropOnUpdateTrigger(knex, TableName.ApiKey); -} diff --git a/pg-migrator/src/migrations/20231212110939_project.ts b/pg-migrator/src/migrations/20231212110939_project.ts deleted file mode 100644 index b2764dc1f..000000000 --- a/pg-migrator/src/migrations/20231212110939_project.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.Project))) { - await knex.schema.createTable(TableName.Project, (t) => { - t.string("id", 36).primary().defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("slug").notNullable(); - t.boolean("autoCapitalization").defaultTo(true); - t.uuid("orgId").notNullable(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.timestamps(true, true, true); - t.unique(["orgId", "slug"]); - }); - } - await createOnUpdateTrigger(knex, TableName.Project); - // environments - if (!(await knex.schema.hasTable(TableName.Environment))) { - await knex.schema.createTable(TableName.Environment, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("slug").notNullable(); - t.integer("position").notNullable(); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - // this will ensure ever env has its position - t.unique(["projectId", "position"], { - indexName: "env_pos_composite_uniqe", - deferrable: "deferred" - }); - t.timestamps(true, true, true); - }); - } - // project key - if (!(await knex.schema.hasTable(TableName.ProjectKeys))) { - await knex.schema.createTable(TableName.ProjectKeys, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.text("encryptedKey").notNullable(); - t.text("nonce").notNullable(); - t.uuid("receiverId").notNullable(); - t.foreign("receiverId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - t.uuid("senderId"); - // if sender is deleted just don't do anything to this record - t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("SET NULL"); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.ProjectKeys); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.Environment); - await knex.schema.dropTableIfExists(TableName.ProjectKeys); - await knex.schema.dropTableIfExists(TableName.Project); - await dropOnUpdateTrigger(knex, TableName.ProjectKeys); - await dropOnUpdateTrigger(knex, TableName.Project); -} diff --git a/pg-migrator/src/migrations/20231212110946_project-membership.ts b/pg-migrator/src/migrations/20231212110946_project-membership.ts deleted file mode 100644 index ad7cdb8a5..000000000 --- a/pg-migrator/src/migrations/20231212110946_project-membership.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.ProjectRoles))) { - await knex.schema.createTable(TableName.ProjectRoles, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("description"); - t.string("slug").notNullable(); - t.jsonb("permissions").notNullable(); - // does not need update trigger we will do it manually - t.timestamps(true, true, true); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - }); - } - - if (!(await knex.schema.hasTable(TableName.ProjectMembership))) { - await knex.schema.createTable(TableName.ProjectMembership, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("role").notNullable(); - // does not need update trigger we will do it manually - t.timestamps(true, true, true); - t.uuid("userId").notNullable(); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - // until role is changed/removed the role should not deleted - t.uuid("roleId"); - t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); - }); - } - await createOnUpdateTrigger(knex, TableName.ProjectMembership); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.ProjectMembership); - await knex.schema.dropTableIfExists(TableName.ProjectRoles); - await dropOnUpdateTrigger(knex, TableName.ProjectMembership); -} diff --git a/pg-migrator/src/migrations/20231218092441_secret-folder.ts b/pg-migrator/src/migrations/20231218092441_secret-folder.ts deleted file mode 100644 index ba67e295b..000000000 --- a/pg-migrator/src/migrations/20231218092441_secret-folder.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretFolder))) { - await knex.schema.createTable(TableName.SecretFolder, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.integer("version").defaultTo(1); - t.timestamps(true, true, true); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - t.uuid("parentId"); - t.foreign("parentId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretFolder); - - if (!(await knex.schema.hasTable(TableName.SecretFolderVersion))) { - await knex.schema.createTable(TableName.SecretFolderVersion, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.integer("version").defaultTo(1); - t.timestamps(true, true, true); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - t.uuid("folderId").notNullable(); - // t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("SET NULL"); - }); - } - - await createOnUpdateTrigger(knex, TableName.SecretFolderVersion); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretFolderVersion); - await knex.schema.dropTableIfExists(TableName.SecretFolder); - await dropOnUpdateTrigger(knex, TableName.SecretFolder); - await dropOnUpdateTrigger(knex, TableName.SecretFolderVersion); -} diff --git a/pg-migrator/src/migrations/20231218092508_secret-import.ts b/pg-migrator/src/migrations/20231218092508_secret-import.ts deleted file mode 100644 index 12aa80c74..000000000 --- a/pg-migrator/src/migrations/20231218092508_secret-import.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretImport))) { - await knex.schema.createTable(TableName.SecretImport, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.integer("version").defaultTo(1); - t.string("importPath").notNullable(); - t.uuid("importEnv").notNullable(); - t.foreign("importEnv").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - t.integer("position").notNullable(); - t.timestamps(true, true, true); - t.uuid("folderId").notNullable(); - t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); - t.unique(["folderId", "position"], { - indexName: "import_pos_composite_uniqe", - deferrable: "deferred" - }); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretImport); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretImport); - await dropOnUpdateTrigger(knex, TableName.SecretImport); -} diff --git a/pg-migrator/src/migrations/20231218092517_secret-tag.ts b/pg-migrator/src/migrations/20231218092517_secret-tag.ts deleted file mode 100644 index 051f39d76..000000000 --- a/pg-migrator/src/migrations/20231218092517_secret-tag.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretTag))) { - await knex.schema.createTable(TableName.SecretTag, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("slug").notNullable(); - t.string("color"); - t.timestamps(true, true, true); - t.uuid("createdBy"); - t.foreign("createdBy").references("id").inTable(TableName.Users).onDelete("SET NULL"); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretTag); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretTag); - await dropOnUpdateTrigger(knex, TableName.SecretTag); -} diff --git a/pg-migrator/src/migrations/20231218103423_secret.ts b/pg-migrator/src/migrations/20231218103423_secret.ts deleted file mode 100644 index c955ee50f..000000000 --- a/pg-migrator/src/migrations/20231218103423_secret.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Knex } from "knex"; - -import { - SecretEncryptionAlgo, - SecretKeyEncoding, - SecretType, - TableName, -} from "../schemas"; -import { - createJunctionTable, - createOnUpdateTrigger, - dropOnUpdateTrigger, -} from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretBlindIndex))) { - await knex.schema.createTable(TableName.SecretBlindIndex, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.text("encryptedSaltCipherText").notNullable(); - t.text("saltIV").notNullable(); - t.text("saltTag").notNullable(); - t.string("algorithm") - .notNullable() - .defaultTo(SecretEncryptionAlgo.AES_256_GCM); - t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); - t.string("projectId").notNullable().unique(); - t.foreign("projectId") - .references("id") - .inTable(TableName.Project) - .onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretBlindIndex); - - if (!(await knex.schema.hasTable(TableName.Secret))) { - await knex.schema.createTable(TableName.Secret, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.integer("version").defaultTo(1).notNullable(); - t.string("type").notNullable().defaultTo(SecretType.Shared); - // t.text("secretKeyHash").notNullable(); - // t.text("secretValueHash"); - // t.text("secretCommentHash"); - // this is required but for backward compatiability we are making it nullable - t.text("secretBlindIndex"); - t.text("secretKeyCiphertext").notNullable(); - t.text("secretKeyIV").notNullable(); - t.text("secretKeyTag").notNullable(); - t.text("secretValueCiphertext").notNullable(); - t.text("secretValueIV").notNullable(); // symmetric encryption - t.text("secretValueTag").notNullable(); - t.text("secretCommentCiphertext"); - t.text("secretCommentIV"); - t.text("secretCommentTag"); - t.string("secretReminderNote"); - t.integer("secretReminderRepeatDays"); - t.boolean("skipMultilineEncoding").defaultTo(false); - t.string("algorithm") - .notNullable() - .defaultTo(SecretEncryptionAlgo.AES_256_GCM); - t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); - t.jsonb("metadata"); - t.uuid("userId"); - t.foreign("userId") - .references("id") - .inTable(TableName.Users) - .onDelete("CASCADE"); - t.uuid("folderId").notNullable(); - t.foreign("folderId") - .references("id") - .inTable(TableName.SecretFolder) - .onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.Secret); - // many to many relation between tags - await createJunctionTable( - knex, - TableName.JnSecretTag, - TableName.Secret, - TableName.SecretTag, - ); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretBlindIndex); - await dropOnUpdateTrigger(knex, TableName.SecretBlindIndex); - - await knex.schema.dropTableIfExists(TableName.JnSecretTag); - await knex.schema.dropTableIfExists(TableName.Secret); - await dropOnUpdateTrigger(knex, TableName.Secret); -} diff --git a/pg-migrator/src/migrations/20231220052508_secret-version.ts b/pg-migrator/src/migrations/20231220052508_secret-version.ts deleted file mode 100644 index 0bcee295b..000000000 --- a/pg-migrator/src/migrations/20231220052508_secret-version.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Knex } from "knex"; - -import { SecretEncryptionAlgo, SecretKeyEncoding, SecretType, TableName } from "../schemas"; -import { createJunctionTable, createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretVersion))) { - await knex.schema.createTable(TableName.SecretVersion, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.integer("version").defaultTo(1).notNullable(); - t.string("type").notNullable().defaultTo(SecretType.Shared); - t.text("secretBlindIndex"); - t.text("secretKeyCiphertext").notNullable(); - t.text("secretKeyIV").notNullable(); - t.text("secretKeyTag").notNullable(); - t.text("secretValueCiphertext").notNullable(); - t.text("secretValueIV").notNullable(); // symmetric encryption - t.text("secretValueTag").notNullable(); - t.text("secretCommentCiphertext"); - t.text("secretCommentIV"); - t.text("secretCommentTag"); - t.string("secretReminderNote"); - t.integer("secretReminderRepeatDays"); - t.boolean("skipMultilineEncoding").defaultTo(false); - t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM); - t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); - t.jsonb("metadata"); - // to avoid orphan rows - t.uuid("envId"); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - t.uuid("secretId").notNullable(); - t.uuid("folderId").notNullable(); - // t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("SET NULL"); - t.uuid("userId"); - t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretVersion); - // many to many relation between tags - await createJunctionTable( - knex, - TableName.SecretVersionTag, - TableName.SecretVersion, - TableName.SecretTag - ); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretVersionTag); - await knex.schema.dropTableIfExists(TableName.SecretVersion); - await dropOnUpdateTrigger(knex, TableName.SecretVersion); -} diff --git a/pg-migrator/src/migrations/20231222092113_project-bot.ts b/pg-migrator/src/migrations/20231222092113_project-bot.ts deleted file mode 100644 index 98c67c8b6..000000000 --- a/pg-migrator/src/migrations/20231222092113_project-bot.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.ProjectBot))) { - await knex.schema.createTable(TableName.ProjectBot, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.boolean("isActive").defaultTo(false).notNullable(); - t.text("encryptedPrivateKey").notNullable(); - t.text("publicKey").notNullable(); - t.text("iv").notNullable(); - t.text("tag").notNullable(); - t.string("algorithm").notNullable(); - t.string("keyEncoding").notNullable(); - t.text("encryptedProjectKey"); - t.text("encryptedProjectKeyNonce"); - // one to one relationship - t.string("projectId").notNullable().unique(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - t.uuid("senderId"); - t.foreign("senderId").references("id").inTable(TableName.Users).onDelete("SET NULL"); - t.timestamps(true, true, true); - }); - } - - await createOnUpdateTrigger(knex, TableName.ProjectBot); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.ProjectBot); - await dropOnUpdateTrigger(knex, TableName.ProjectBot); -} diff --git a/pg-migrator/src/migrations/20231222172455_integration.ts b/pg-migrator/src/migrations/20231222172455_integration.ts deleted file mode 100644 index 4ef34cab1..000000000 --- a/pg-migrator/src/migrations/20231222172455_integration.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.IntegrationAuth))) { - await knex.schema.createTable(TableName.IntegrationAuth, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("integration").notNullable(); - t.string("teamId"); // vercel-specific - t.string("url"); // for self hosted - t.string("namespace"); // hashicorp specific - t.string("accountId"); // netlify - t.text("refreshCiphertext"); - t.string("refreshIV"); - t.string("refreshTag"); - t.string("accessIdCiphertext"); - t.string("accessIdIV"); - t.string("accessIdTag"); - t.text("accessCiphertext"); - t.string("accessIV"); - t.string("accessTag"); - t.datetime("accessExpiresAt"); - t.jsonb("metadata"); - t.string("algorithm").notNullable(); - t.string("keyEncoding").notNullable(); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.IntegrationAuth); - - if (!(await knex.schema.hasTable(TableName.Integration))) { - await knex.schema.createTable(TableName.Integration, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.boolean("isActive").notNullable(); - t.string("url"); // self hosted - t.string("app"); // name of app in provider - t.string("appId"); - t.string("targetEnvironment"); - t.string("targetEnvironmentId"); - t.string("targetService"); // railway - qovery specific - t.string("targetServiceId"); - t.string("owner"); // github specific - t.string("path"); // aws parameter store / vercel preview branch - t.string("region"); // aws - t.string("scope"); // qovery specific scope - t.string("integration").notNullable(); - t.jsonb("metadata"); - t.uuid("integrationAuthId").notNullable(); - t.foreign("integrationAuthId") - .references("id") - .inTable(TableName.IntegrationAuth) - .onDelete("CASCADE"); - t.uuid("envId").notNullable(); - t.string("secretPath").defaultTo("/").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.Integration); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.Integration); - await knex.schema.dropTableIfExists(TableName.IntegrationAuth); - await dropOnUpdateTrigger(knex, TableName.IntegrationAuth); - await dropOnUpdateTrigger(knex, TableName.Integration); -} diff --git a/pg-migrator/src/migrations/20231225072545_service-token.ts b/pg-migrator/src/migrations/20231225072545_service-token.ts deleted file mode 100644 index 0dd20af82..000000000 --- a/pg-migrator/src/migrations/20231225072545_service-token.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.ServiceToken))) { - await knex.schema.createTable(TableName.ServiceToken, (t) => { - t.string("id", 36).primary().defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.jsonb("scopes").notNullable(); - t.specificType("permissions", "text[]").notNullable(); - t.datetime("lastUsed"); - t.datetime("expiresAt"); - t.text("secretHash").notNullable(); - t.text("encryptedKey"); - t.text("iv"); - t.text("tag"); - t.timestamps(true, true, true); - // user is old one - t.string("createdBy").notNullable(); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - }); - } - await createOnUpdateTrigger(knex, TableName.ServiceToken); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.ServiceToken); - await dropOnUpdateTrigger(knex, TableName.ServiceToken); -} diff --git a/pg-migrator/src/migrations/20231225072552_webhook.ts b/pg-migrator/src/migrations/20231225072552_webhook.ts deleted file mode 100644 index f12f489e8..000000000 --- a/pg-migrator/src/migrations/20231225072552_webhook.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.Webhook))) { - await knex.schema.createTable(TableName.Webhook, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("secretPath").notNullable().defaultTo("/"); - t.string("url").notNullable(); - t.string("lastStatus"); - t.text("lastRunErrorMessage"); - t.boolean("isDisabled").defaultTo(false).notNullable(); - // webhook signature - t.text("encryptedSecretKey"); - t.text("iv"); - t.text("tag"); - t.string("algorithm"); - t.string("keyEncoding"); - t.timestamps(true, true, true); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - }); - } - await createOnUpdateTrigger(knex, TableName.Webhook); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.Webhook); - await dropOnUpdateTrigger(knex, TableName.Webhook); -} diff --git a/pg-migrator/src/migrations/20231228074856_identity.ts b/pg-migrator/src/migrations/20231228074856_identity.ts deleted file mode 100644 index 56f5c6752..000000000 --- a/pg-migrator/src/migrations/20231228074856_identity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.Identity))) { - await knex.schema.createTable(TableName.Identity, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("authMethod"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.Identity); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.Identity); - await dropOnUpdateTrigger(knex, TableName.Identity); -} diff --git a/pg-migrator/src/migrations/20231228074908_identity-universal-auth.ts b/pg-migrator/src/migrations/20231228074908_identity-universal-auth.ts deleted file mode 100644 index 3d648a797..000000000 --- a/pg-migrator/src/migrations/20231228074908_identity-universal-auth.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.IdentityUniversalAuth))) { - await knex.schema.createTable(TableName.IdentityUniversalAuth, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("clientId").notNullable(); - t.bigint("accessTokenTTL").defaultTo(7200).notNullable(); - t.bigint("accessTokenMaxTTL").defaultTo(7200).notNullable(); - t.bigint("accessTokenNumUsesLimit").defaultTo(0).notNullable(); - t.jsonb("clientSecretTrustedIps").notNullable(); - t.jsonb("accessTokenTrustedIps").notNullable(); - t.timestamps(true, true, true); - t.uuid("identityId").notNullable().unique(); - t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); - }); - } - if (!(await knex.schema.hasTable(TableName.IdentityUaClientSecret))) { - await knex.schema.createTable(TableName.IdentityUaClientSecret, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("description").notNullable(); - t.string("clientSecretPrefix").notNullable(); - t.string("clientSecretHash").notNullable(); - t.datetime("clientSecretLastUsedAt"); - t.bigint("clientSecretNumUses").defaultTo(0).notNullable(); - t.bigint("clientSecretNumUsesLimit").defaultTo(0).notNullable(); - t.bigint("clientSecretTTL").defaultTo(0).notNullable(); - t.boolean("isClientSecretRevoked").defaultTo(false).notNullable(); - t.timestamps(true, true, true); - t.uuid("identityUAId").notNullable(); - t.foreign("identityUAId") - .references("id") - .inTable(TableName.IdentityUniversalAuth) - .onDelete("CASCADE"); - }); - } - await createOnUpdateTrigger(knex, TableName.IdentityUniversalAuth); - await createOnUpdateTrigger(knex, TableName.IdentityUaClientSecret); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.IdentityUaClientSecret); - await knex.schema.dropTableIfExists(TableName.IdentityUniversalAuth); - await dropOnUpdateTrigger(knex, TableName.IdentityUaClientSecret); - await dropOnUpdateTrigger(knex, TableName.IdentityUniversalAuth); -} diff --git a/pg-migrator/src/migrations/20231228075011_identity-access-token.ts b/pg-migrator/src/migrations/20231228075011_identity-access-token.ts deleted file mode 100644 index fccbe2730..000000000 --- a/pg-migrator/src/migrations/20231228075011_identity-access-token.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.IdentityAccessToken))) { - await knex.schema.createTable(TableName.IdentityAccessToken, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.integer("accessTokenTTL").defaultTo(2592000).notNullable(); // 30 days second - t.integer("accessTokenMaxTTL").defaultTo(2592000).notNullable(); - t.integer("accessTokenNumUses").defaultTo(0).notNullable(); - t.integer("accessTokenNumUsesLimit").defaultTo(0).notNullable(); - t.datetime("accessTokenLastUsedAt"); - t.datetime("accessTokenLastRenewedAt"); - t.boolean("isAccessTokenRevoked").defaultTo(false).notNullable(); - t.uuid("identityUAClientSecretId"); - t.foreign("identityUAClientSecretId") - .references("id") - .inTable(TableName.IdentityUaClientSecret) - .onDelete("CASCADE"); - t.uuid("identityId").notNullable(); - t.foreign("identityId") - .references("id") - .inTable(TableName.Identity) - .onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - - await createOnUpdateTrigger(knex, TableName.IdentityAccessToken); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.IdentityAccessToken); - await dropOnUpdateTrigger(knex, TableName.IdentityAccessToken); -} diff --git a/pg-migrator/src/migrations/20231228075023_identity-membership.ts b/pg-migrator/src/migrations/20231228075023_identity-membership.ts deleted file mode 100644 index 288c11be3..000000000 --- a/pg-migrator/src/migrations/20231228075023_identity-membership.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.IdentityOrgMembership))) { - await knex.schema.createTable(TableName.IdentityOrgMembership, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("role").notNullable(); - t.uuid("roleId"); - t.foreign("roleId").references("id").inTable(TableName.OrgRoles); - t.uuid("orgId").notNullable(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.timestamps(true, true, true); - t.uuid("identityId").notNullable(); - t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); - }); - } - await createOnUpdateTrigger(knex, TableName.IdentityOrgMembership); - - if (!(await knex.schema.hasTable(TableName.IdentityProjectMembership))) { - await knex.schema.createTable(TableName.IdentityProjectMembership, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("role").notNullable(); - t.uuid("roleId"); - t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - t.uuid("identityId").notNullable(); - t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.IdentityProjectMembership); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.IdentityOrgMembership); - await knex.schema.dropTableIfExists(TableName.IdentityProjectMembership); - - await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembership); - await dropOnUpdateTrigger(knex, TableName.IdentityOrgMembership); -} diff --git a/pg-migrator/src/migrations/20240101054849_secret-approval-policy.ts b/pg-migrator/src/migrations/20240101054849_secret-approval-policy.ts deleted file mode 100644 index 7fde0d314..000000000 --- a/pg-migrator/src/migrations/20240101054849_secret-approval-policy.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretApprovalPolicy))) { - await knex.schema.createTable(TableName.SecretApprovalPolicy, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.string("secretPath"); - t.integer("approvals").defaultTo(1).notNullable(); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretApprovalPolicy); - - if (!(await knex.schema.hasTable(TableName.SecretApprovalPolicyApprover))) { - await knex.schema.createTable(TableName.SecretApprovalPolicyApprover, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.uuid("approverId").notNullable(); - t.foreign("approverId") - .references("id") - .inTable(TableName.ProjectMembership) - .onDelete("CASCADE"); - t.uuid("policyId").notNullable(); - t.foreign("policyId") - .references("id") - .inTable(TableName.SecretApprovalPolicy) - .onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - - await createOnUpdateTrigger(knex, TableName.SecretApprovalPolicyApprover); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretApprovalPolicyApprover); - await knex.schema.dropTableIfExists(TableName.SecretApprovalPolicy); - await dropOnUpdateTrigger(knex, TableName.SecretApprovalPolicyApprover); - await dropOnUpdateTrigger(knex, TableName.SecretApprovalPolicy); -} diff --git a/pg-migrator/src/migrations/20240101104907_secret-approval-request.ts b/pg-migrator/src/migrations/20240101104907_secret-approval-request.ts deleted file mode 100644 index 21a9e944e..000000000 --- a/pg-migrator/src/migrations/20240101104907_secret-approval-request.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Knex } from "knex"; - -import { SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretApprovalRequest))) { - await knex.schema.createTable(TableName.SecretApprovalRequest, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.uuid("policyId").notNullable(); - t.boolean("hasMerged").defaultTo(false).notNullable(); - t.string("status").defaultTo("open").notNullable(); - t.jsonb("conflicts"); - t.foreign("policyId") - .references("id") - .inTable(TableName.SecretApprovalPolicy) - .onDelete("CASCADE"); - t.string("slug").notNullable(); - t.uuid("folderId").notNullable(); - t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); - t.uuid("statusChangeBy"); - t.foreign("statusChangeBy") - .references("id") - .inTable(TableName.ProjectMembership) - .onDelete("SET NULL"); - t.uuid("committerId").notNullable(); - t.foreign("committerId") - .references("id") - .inTable(TableName.ProjectMembership) - .onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretApprovalRequest); - - if (!(await knex.schema.hasTable(TableName.SecretApprovalRequestReviewer))) { - await knex.schema.createTable(TableName.SecretApprovalRequestReviewer, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.uuid("member").notNullable(); - t.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); - t.string("status").notNullable(); - t.uuid("requestId").notNullable(); - t.foreign("requestId") - .references("id") - .inTable(TableName.SecretApprovalRequest) - .onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretApprovalRequestReviewer); - - if (!(await knex.schema.hasTable(TableName.SecretApprovalRequestSecret))) { - await knex.schema.createTable(TableName.SecretApprovalRequestSecret, (t) => { - // everything related to secret - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.integer("version").defaultTo(1); - t.text("secretBlindIndex"); - t.text("secretKeyCiphertext").notNullable(); - t.text("secretKeyIV").notNullable(); - t.text("secretKeyTag").notNullable(); - t.text("secretValueCiphertext").notNullable(); - t.text("secretValueIV").notNullable(); // symmetric encryption - t.text("secretValueTag").notNullable(); - t.text("secretCommentCiphertext"); - t.text("secretCommentIV"); - t.text("secretCommentTag"); - t.string("secretReminderNote"); - t.integer("secretReminderRepeatDays"); - t.boolean("skipMultilineEncoding").defaultTo(false); - t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM); - t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); - t.jsonb("metadata"); - t.timestamps(true, true, true); - // commit details - t.uuid("requestId").notNullable(); - t.foreign("requestId") - .references("id") - .inTable(TableName.SecretApprovalRequest) - .onDelete("CASCADE"); - t.string("op").notNullable(); - t.uuid("secretId"); - t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("SET NULL"); - t.uuid("secretVersion"); - t.foreign("secretVersion") - .references("id") - .inTable(TableName.SecretVersion) - .onDelete("SET NULL"); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretApprovalRequestSecret); - - if (!(await knex.schema.hasTable(TableName.SecretApprovalRequestSecretTag))) { - await knex.schema.createTable(TableName.SecretApprovalRequestSecretTag, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.uuid("secretId").notNullable(); - t.foreign("secretId") - .references("id") - .inTable(TableName.SecretApprovalRequestSecret) - .onDelete("CASCADE"); - t.uuid("tagId").notNullable(); - t.foreign("tagId").references("id").inTable(TableName.SecretTag).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretApprovalRequestSecretTag); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretApprovalRequestSecretTag); - await knex.schema.dropTableIfExists(TableName.SecretApprovalRequestSecret); - await knex.schema.dropTableIfExists(TableName.SecretApprovalRequestReviewer); - await knex.schema.dropTableIfExists(TableName.SecretApprovalRequest); - - await dropOnUpdateTrigger(knex, TableName.SecretApprovalRequestSecretTag); - await dropOnUpdateTrigger(knex, TableName.SecretApprovalRequestSecret); - await dropOnUpdateTrigger(knex, TableName.SecretApprovalRequestReviewer); - await dropOnUpdateTrigger(knex, TableName.SecretApprovalRequest); -} diff --git a/pg-migrator/src/migrations/20240102152111_secret-rotation.ts b/pg-migrator/src/migrations/20240102152111_secret-rotation.ts deleted file mode 100644 index f7009488a..000000000 --- a/pg-migrator/src/migrations/20240102152111_secret-rotation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SecretRotation))) { - await knex.schema.createTable(TableName.SecretRotation, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("provider").notNullable(); - t.string("secretPath").notNullable(); - t.integer("interval").notNullable(); - t.datetime("lastRotatedAt"); - t.string("status"); - t.text("statusMessage"); - t.text("encryptedData"); - t.text("encryptedDataIV"); - t.text("encryptedDataTag"); - t.string("algorithm"); - t.string("keyEncoding"); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.SecretRotation); - - if (!(await knex.schema.hasTable(TableName.SecretRotationOutput))) { - await knex.schema.createTable(TableName.SecretRotationOutput, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("key").notNullable(); - t.uuid("secretId").notNullable(); - t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("CASCADE"); - t.uuid("rotationId").notNullable(); - t.foreign("rotationId") - .references("id") - .inTable(TableName.SecretRotation) - .onDelete("CASCADE"); - }); - } -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretRotationOutput); - await knex.schema.dropTableIfExists(TableName.SecretRotation); - await dropOnUpdateTrigger(knex, TableName.SecretRotation); -} diff --git a/pg-migrator/src/migrations/20240104140641_secret-snapshot.ts b/pg-migrator/src/migrations/20240104140641_secret-snapshot.ts deleted file mode 100644 index 01b1e24fb..000000000 --- a/pg-migrator/src/migrations/20240104140641_secret-snapshot.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.Snapshot))) { - await knex.schema.createTable(TableName.Snapshot, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - // this is not a relation kept like that - // this ensure snapshot are not lost when folder gets deleted and rolled back - t.uuid("folderId").notNullable(); - t.uuid("parentFolderId"); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.Snapshot); - - if (!(await knex.schema.hasTable(TableName.SnapshotSecret))) { - await knex.schema.createTable(TableName.SnapshotSecret, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - // not a relation kept like that to keep it when rolled back - t.uuid("secretVersionId").notNullable(); - t.foreign("secretVersionId") - .references("id") - .inTable(TableName.SecretVersion) - .onDelete("CASCADE"); - t.uuid("snapshotId").notNullable(); - t.foreign("snapshotId").references("id").inTable(TableName.Snapshot).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - - if (!(await knex.schema.hasTable(TableName.SnapshotFolder))) { - await knex.schema.createTable(TableName.SnapshotFolder, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.uuid("envId").notNullable(); - t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); - // not a relation kept like that to keep it when rolled back - t.uuid("folderVersionId").notNullable(); - t.foreign("folderVersionId") - .references("id") - .inTable(TableName.SecretFolderVersion) - .onDelete("CASCADE"); - t.uuid("snapshotId").notNullable(); - t.foreign("snapshotId").references("id").inTable(TableName.Snapshot).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SnapshotSecret); - await knex.schema.dropTableIfExists(TableName.SnapshotFolder); - await knex.schema.dropTableIfExists(TableName.Snapshot); - await dropOnUpdateTrigger(knex, TableName.Snapshot); -} diff --git a/pg-migrator/src/migrations/20240107153439_saml-config.ts b/pg-migrator/src/migrations/20240107153439_saml-config.ts deleted file mode 100644 index 76070dc68..000000000 --- a/pg-migrator/src/migrations/20240107153439_saml-config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.SamlConfig))) { - await knex.schema.createTable(TableName.SamlConfig, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("authProvider").notNullable(); - t.boolean("isActive").notNullable(); - t.string("encryptedEntryPoint"); - t.string("entryPointIV"); - t.string("entryPointTag"); - t.string("encryptedIssuer"); - t.string("issuerTag"); - t.string("issuerIV"); - t.text("encryptedCert"); - t.string("certIV"); - t.string("certTag"); - t.timestamps(true, true, true); - t.uuid("orgId").notNullable().unique(); - t.foreign("orgId").references("id").inTable(TableName.Organization); - }); - } - - await createOnUpdateTrigger(knex, TableName.SamlConfig); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SamlConfig); - await dropOnUpdateTrigger(knex, TableName.SamlConfig); -} diff --git a/pg-migrator/src/migrations/20240107163155_org-bot.ts b/pg-migrator/src/migrations/20240107163155_org-bot.ts deleted file mode 100644 index 6e4365f9d..000000000 --- a/pg-migrator/src/migrations/20240107163155_org-bot.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.OrgBot))) { - await knex.schema.createTable(TableName.OrgBot, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("name").notNullable(); - t.text("publicKey").notNullable(); - t.text("encryptedSymmetricKey").notNullable(); - t.text("symmetricKeyIV").notNullable(); - t.text("symmetricKeyTag").notNullable(); - t.string("symmetricKeyAlgorithm").notNullable(); - t.string("symmetricKeyKeyEncoding").notNullable(); - t.text("encryptedPrivateKey").notNullable(); - t.text("privateKeyIV").notNullable(); - t.text("privateKeyTag").notNullable(); - t.string("privateKeyAlgorithm").notNullable(); - t.string("privateKeyKeyEncoding").notNullable(); - // one to one relationship - t.uuid("orgId").notNullable().unique(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - - await createOnUpdateTrigger(knex, TableName.OrgBot); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.OrgBot); - await dropOnUpdateTrigger(knex, TableName.OrgBot); -} diff --git a/pg-migrator/src/migrations/20240108134148_audit-log.ts b/pg-migrator/src/migrations/20240108134148_audit-log.ts deleted file mode 100644 index 9eb74f03d..000000000 --- a/pg-migrator/src/migrations/20240108134148_audit-log.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.AuditLog))) { - await knex.schema.createTable(TableName.AuditLog, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("actor").notNullable(); - t.jsonb("actorMetadata").notNullable(); - t.string("ipAddress"); - t.string("eventType").notNullable(); - t.jsonb("eventMetadata"); - t.string("userAgent"); - t.string("userAgentType"); - t.datetime("expiresAt"); - t.timestamps(true, true, true); - // no trigger needed as this collection is append only - t.uuid("orgId"); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.string("projectId"); - t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); - }); - } -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.AuditLog); -} diff --git a/pg-migrator/src/migrations/20240111051011_secret-scanning.ts b/pg-migrator/src/migrations/20240111051011_secret-scanning.ts deleted file mode 100644 index af011d558..000000000 --- a/pg-migrator/src/migrations/20240111051011_secret-scanning.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.GitAppInstallSession))) { - await knex.schema.createTable(TableName.GitAppInstallSession, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("sessionId").notNullable().unique(); - t.uuid("userId"); - // one to one relationship - t.uuid("orgId").notNullable().unique(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - createOnUpdateTrigger(knex, TableName.GitAppInstallSession); - - if (!(await knex.schema.hasTable(TableName.GitAppOrg))) { - await knex.schema.createTable(TableName.GitAppOrg, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("installationId").notNullable().unique(); - t.uuid("userId").notNullable(); - // one to one relationship - t.uuid("orgId").notNullable().unique(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - createOnUpdateTrigger(knex, TableName.GitAppOrg); - - if (!(await knex.schema.hasTable(TableName.SecretScanningGitRisk))) { - await knex.schema.createTable(TableName.SecretScanningGitRisk, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("description"); - t.string("startLine"); - t.string("endLine"); - t.string("startColumn"); - t.string("endColumn"); - t.string("file"); - t.string("symlinkFile"); - t.string("commit"); - t.string("entropy"); - t.string("author"); - t.string("email"); - t.string("date"); - t.text("message"); - t.specificType("tags", "text[]"); - t.string("ruleID"); - t.string("fingerprint").unique(); - t.string("fingerPrintWithoutCommitId"); - t.boolean("isFalsePositive").defaultTo(false); - t.boolean("isResolved").defaultTo(false); - t.string("riskOwner"); - t.string("installationId").notNullable(); - t.string("repositoryId"); - t.string("repositoryLink"); - t.string("repositoryFullName"); - t.string("pusherName"); - t.string("pusherEmail"); - t.string("status"); - // one to one relationship - t.uuid("orgId").notNullable(); - t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.timestamps(true, true, true); - }); - } - createOnUpdateTrigger(knex, TableName.SecretScanningGitRisk); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SecretScanningGitRisk); - await knex.schema.dropTableIfExists(TableName.GitAppOrg); - await knex.schema.dropTableIfExists(TableName.GitAppInstallSession); - await dropOnUpdateTrigger(knex, TableName.SecretScanningGitRisk); - await dropOnUpdateTrigger(knex, TableName.GitAppOrg); - await dropOnUpdateTrigger(knex, TableName.GitAppInstallSession); -} diff --git a/pg-migrator/src/migrations/20240113103743_trusted-ip.ts b/pg-migrator/src/migrations/20240113103743_trusted-ip.ts deleted file mode 100644 index 2924f0668..000000000 --- a/pg-migrator/src/migrations/20240113103743_trusted-ip.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "../schemas"; -import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; - -export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.TrustedIps))) { - await knex.schema.createTable(TableName.TrustedIps, (t) => { - t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("ipAddress").notNullable(); - t.string("type").notNullable(); - t.integer("prefix"); - t.boolean("isActive").defaultTo(true); - t.string("comment"); - t.string("projectId").notNullable(); - t.foreign("projectId").references("id").inTable(TableName.Project); - t.timestamps(true, true, true); - }); - } - await createOnUpdateTrigger(knex, TableName.TrustedIps); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.TrustedIps); - await dropOnUpdateTrigger(knex, TableName.TrustedIps); -} diff --git a/pg-migrator/src/models/apiKeyData.ts b/pg-migrator/src/models/apiKeyData.ts deleted file mode 100644 index ac6a09358..000000000 --- a/pg-migrator/src/models/apiKeyData.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IAPIKeyData { - _id: Types.ObjectId; - name: string; - user: Types.ObjectId; - lastUsed: Date; - expiresAt: Date; - secretHash: string; -} - -const apiKeyDataSchema = new Schema( - { - name: { - type: String, - required: true, - }, - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - lastUsed: { - type: Date, - }, - expiresAt: { - type: Date, - }, - secretHash: { - type: String, - required: true, - }, - }, - { - timestamps: true, - }, -); - -export const APIKeyData = model("APIKeyData", apiKeyDataSchema); diff --git a/pg-migrator/src/models/apiKeyDataV2.ts b/pg-migrator/src/models/apiKeyDataV2.ts deleted file mode 100644 index 6775a0878..000000000 --- a/pg-migrator/src/models/apiKeyDataV2.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface IAPIKeyDataV2 extends Document { - _id: Types.ObjectId; - name: string; - user: Types.ObjectId; - lastUsed?: Date - usageCount: number; - expiresAt?: Date; -} - -const apiKeyDataV2Schema = new Schema( - { - name: { - type: String, - required: true - }, - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true - }, - lastUsed: { - type: Date, - required: false - }, - usageCount: { - type: Number, - default: 0, - required: true - } - }, - { - timestamps: true - } -); - -export const APIKeyDataV2 = model("APIKeyDataV2", apiKeyDataV2Schema); \ No newline at end of file diff --git a/pg-migrator/src/models/auditLog/auditLog.ts b/pg-migrator/src/models/auditLog/auditLog.ts deleted file mode 100644 index cceabb9f4..000000000 --- a/pg-migrator/src/models/auditLog/auditLog.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Schema, Types, model } from "mongoose"; -import { ActorType, EventType, UserAgentType } from "./enums"; -import { Actor, Event } from "./types"; - -export interface IAuditLog { - actor: Actor; - organization: Types.ObjectId; - workspace: Types.ObjectId; - ipAddress: string; - event: Event; - userAgent: string; - userAgentType: UserAgentType; - expiresAt?: Date; -} - -const auditLogSchema = new Schema( - { - actor: { - type: { - type: String, - enum: ActorType, - required: true, - }, - metadata: { - type: Schema.Types.Mixed, - }, - }, - organization: { - type: Schema.Types.ObjectId, - required: false, - }, - workspace: { - type: Schema.Types.ObjectId, - required: false, - index: true, - }, - ipAddress: { - type: String, - required: true, - }, - event: { - type: { - type: String, - enum: EventType, - required: true, - }, - metadata: { - type: Schema.Types.Mixed, - }, - }, - userAgent: { - type: String, - required: true, - }, - userAgentType: { - type: String, - enum: UserAgentType, - required: true, - }, - expiresAt: { - type: Date, - expires: 0, - }, - }, - { - timestamps: true, - }, -); - -export const AuditLog = model("AuditLog", auditLogSchema); diff --git a/pg-migrator/src/models/auditLog/enums.ts b/pg-migrator/src/models/auditLog/enums.ts deleted file mode 100644 index ad0051bbc..000000000 --- a/pg-migrator/src/models/auditLog/enums.ts +++ /dev/null @@ -1,69 +0,0 @@ -export enum ActorType { // would extend to AWS, Azure, ... - USER = "user", // userIdentity - SERVICE = "service", - IDENTITY = "identity" -} - -export enum UserAgentType { - WEB = "web", - CLI = "cli", - K8_OPERATOR = "k8-operator", - TERRAFORM = "terraform", - OTHER = "other", - PYTHON_SDK = "InfisicalPythonSDK", - NODE_SDK = "InfisicalNodeSDK" -} - -export enum EventType { - GET_SECRETS = "get-secrets", - GET_SECRET = "get-secret", - REVEAL_SECRET = "reveal-secret", - CREATE_SECRET = "create-secret", - CREATE_SECRETS = "create-secrets", - UPDATE_SECRET = "update-secret", - UPDATE_SECRETS = "update-secrets", - DELETE_SECRET = "delete-secret", - DELETE_SECRETS = "delete-secrets", - GET_WORKSPACE_KEY = "get-workspace-key", - AUTHORIZE_INTEGRATION = "authorize-integration", - UNAUTHORIZE_INTEGRATION = "unauthorize-integration", - CREATE_INTEGRATION = "create-integration", - DELETE_INTEGRATION = "delete-integration", - ADD_TRUSTED_IP = "add-trusted-ip", - UPDATE_TRUSTED_IP = "update-trusted-ip", - DELETE_TRUSTED_IP = "delete-trusted-ip", - CREATE_SERVICE_TOKEN = "create-service-token", // v2 - DELETE_SERVICE_TOKEN = "delete-service-token", // v2 - CREATE_IDENTITY = "create-identity", - UPDATE_IDENTITY = "update-identity", - DELETE_IDENTITY = "delete-identity", - LOGIN_IDENTITY_UNIVERSAL_AUTH = "login-identity-universal-auth", - ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", - UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", - GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", - CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", - REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", - GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", - CREATE_ENVIRONMENT = "create-environment", - UPDATE_ENVIRONMENT = "update-environment", - DELETE_ENVIRONMENT = "delete-environment", - ADD_WORKSPACE_MEMBER = "add-workspace-member", - ADD_BATCH_WORKSPACE_MEMBER = "add-workspace-members", - REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", - CREATE_FOLDER = "create-folder", - UPDATE_FOLDER = "update-folder", - DELETE_FOLDER = "delete-folder", - CREATE_WEBHOOK = "create-webhook", - UPDATE_WEBHOOK_STATUS = "update-webhook-status", - DELETE_WEBHOOK = "delete-webhook", - GET_SECRET_IMPORTS = "get-secret-imports", - CREATE_SECRET_IMPORT = "create-secret-import", - UPDATE_SECRET_IMPORT = "update-secret-import", - DELETE_SECRET_IMPORT = "delete-secret-import", - UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions", - SECRET_APPROVAL_MERGED = "secret-approval-merged", - SECRET_APPROVAL_REQUEST = "secret-approval-request", - SECRET_APPROVAL_CLOSED = "secret-approval-closed", - SECRET_APPROVAL_REOPENED = "secret-approval-reopened" -} diff --git a/pg-migrator/src/models/auditLog/index.ts b/pg-migrator/src/models/auditLog/index.ts deleted file mode 100644 index 37b86b5d1..000000000 --- a/pg-migrator/src/models/auditLog/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./auditLog"; -export * from "./enums"; -export * from "./types"; \ No newline at end of file diff --git a/pg-migrator/src/models/auditLog/types.ts b/pg-migrator/src/models/auditLog/types.ts deleted file mode 100644 index a4e470414..000000000 --- a/pg-migrator/src/models/auditLog/types.ts +++ /dev/null @@ -1,585 +0,0 @@ -import { ActorType, EventType } from "./enums"; -import { IIdentityTrustedIp } from "../../../models"; - -interface UserActorMetadata { - userId: string; - email: string; -} - -interface ServiceActorMetadata { - serviceId: string; - name: string; -} - -interface IdentityActorMetadata { - identityId: string; - name: string; -} - -export interface UserActor { - type: ActorType.USER; - metadata: UserActorMetadata; -} - -export interface ServiceActor { - type: ActorType.SERVICE; - metadata: ServiceActorMetadata; -} - -export interface IdentityActor { - type: ActorType.IDENTITY; - metadata: IdentityActorMetadata; -} - -export type Actor = UserActor | ServiceActor | IdentityActor; - -interface GetSecretsEvent { - type: EventType.GET_SECRETS; - metadata: { - environment: string; - secretPath: string; - numberOfSecrets: number; - }; -} - -interface GetSecretEvent { - type: EventType.GET_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface CreateSecretEvent { - type: EventType.CREATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface CreateSecretBatchEvent { - type: EventType.CREATE_SECRETS; - metadata: { - environment: string; - secretPath: string; - secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; - }; -} - -interface UpdateSecretEvent { - type: EventType.UPDATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface UpdateSecretBatchEvent { - type: EventType.UPDATE_SECRETS; - metadata: { - environment: string; - secretPath: string; - secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; - }; -} - -interface DeleteSecretEvent { - type: EventType.DELETE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; -} - -interface DeleteSecretBatchEvent { - type: EventType.DELETE_SECRETS; - metadata: { - environment: string; - secretPath: string; - secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; - }; -} - -interface GetWorkspaceKeyEvent { - type: EventType.GET_WORKSPACE_KEY; - metadata: { - keyId: string; - }; -} - -interface AuthorizeIntegrationEvent { - type: EventType.AUTHORIZE_INTEGRATION; - metadata: { - integration: string; - }; -} - -interface UnauthorizeIntegrationEvent { - type: EventType.UNAUTHORIZE_INTEGRATION; - metadata: { - integration: string; - }; -} - -interface CreateIntegrationEvent { - type: EventType.CREATE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - }; -} - -interface DeleteIntegrationEvent { - type: EventType.DELETE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - }; -} - -interface AddTrustedIPEvent { - type: EventType.ADD_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - }; -} - -interface UpdateTrustedIPEvent { - type: EventType.UPDATE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - }; -} - -interface DeleteTrustedIPEvent { - type: EventType.DELETE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - }; -} - -interface CreateServiceTokenEvent { - type: EventType.CREATE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - }; -} - -interface DeleteServiceTokenEvent { - type: EventType.DELETE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - }; -} - -interface CreateIdentityEvent { // note: currently not logging org-role - type: EventType.CREATE_IDENTITY; - metadata: { - identityId: string; - name: string; - }; -} - -interface UpdateIdentityEvent { - type: EventType.UPDATE_IDENTITY; - metadata: { - identityId: string; - name?: string; - }; -} - -interface DeleteIdentityEvent { - type: EventType.DELETE_IDENTITY; - metadata: { - identityId: string; - }; -} - -interface LoginIdentityUniversalAuthEvent { - type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH ; - metadata: { - identityId: string; - identityUniversalAuthId: string; - clientSecretId: string; - identityAccessTokenId: string; - }; -} - -interface AddIdentityUniversalAuthEvent { - type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH; - metadata: { - identityId: string; - clientSecretTrustedIps: Array; - accessTokenTTL: number; - accessTokenMaxTTL: number; - accessTokenNumUsesLimit: number; - accessTokenTrustedIps: Array; - }; -} - -interface UpdateIdentityUniversalAuthEvent { - type: EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH; - metadata: { - identityId: string; - clientSecretTrustedIps?: Array; - accessTokenTTL?: number; - accessTokenMaxTTL?: number; - accessTokenNumUsesLimit?: number; - accessTokenTrustedIps?: Array; - }; -} - -interface GetIdentityUniversalAuthEvent { - type: EventType.GET_IDENTITY_UNIVERSAL_AUTH; - metadata: { - identityId: string; - }; -} - -interface CreateIdentityUniversalAuthClientSecretEvent { - type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET ; - metadata: { - identityId: string; - clientSecretId: string; - }; -} - -interface GetIdentityUniversalAuthClientSecretsEvent { - type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS; - metadata: { - identityId: string; - }; -} - - -interface RevokeIdentityUniversalAuthClientSecretEvent { - type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET ; - metadata: { - identityId: string; - clientSecretId: string; - }; -} - -interface CreateEnvironmentEvent { - type: EventType.CREATE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - }; -} - -interface UpdateEnvironmentEvent { - type: EventType.UPDATE_ENVIRONMENT; - metadata: { - oldName: string; - newName: string; - oldSlug: string; - newSlug: string; - }; -} - -interface DeleteEnvironmentEvent { - type: EventType.DELETE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - }; -} - -interface AddWorkspaceMemberEvent { - type: EventType.ADD_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - }; -} - -interface AddBatchWorkspaceMemberEvent { - type: EventType.ADD_BATCH_WORKSPACE_MEMBER; - metadata: Array<{ - userId: string; - email: string; - }>; -} - -interface RemoveWorkspaceMemberEvent { - type: EventType.REMOVE_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - }; -} - -interface CreateFolderEvent { - type: EventType.CREATE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - }; -} - -interface UpdateFolderEvent { - type: EventType.UPDATE_FOLDER; - metadata: { - environment: string; - folderId: string; - oldFolderName: string; - newFolderName: string; - folderPath: string; - }; -} - -interface DeleteFolderEvent { - type: EventType.DELETE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - }; -} - -interface CreateWebhookEvent { - type: EventType.CREATE_WEBHOOK; - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - }; -} - -interface UpdateWebhookStatusEvent { - type: EventType.UPDATE_WEBHOOK_STATUS; - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - }; -} - -interface DeleteWebhookEvent { - type: EventType.DELETE_WEBHOOK; - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - }; -} - -interface GetSecretImportsEvent { - type: EventType.GET_SECRET_IMPORTS; - metadata: { - environment: string; - secretImportId: string; - folderId: string; - numberOfImports: number; - }; -} - -interface CreateSecretImportEvent { - type: EventType.CREATE_SECRET_IMPORT; - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - }; -} - -interface UpdateSecretImportEvent { - type: EventType.UPDATE_SECRET_IMPORT; - metadata: { - secretImportId: string; - folderId: string; - importToEnvironment: string; - importToSecretPath: string; - orderBefore: { - environment: string; - secretPath: string; - }[]; - orderAfter: { - environment: string; - secretPath: string; - }[]; - }; -} - -interface DeleteSecretImportEvent { - type: EventType.DELETE_SECRET_IMPORT; - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - }; -} - -interface UpdateUserRole { - type: EventType.UPDATE_USER_WORKSPACE_ROLE; - metadata: { - userId: string; - email: string; - oldRole: string; - newRole: string; - }; -} - -interface UpdateUserDeniedPermissions { - type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS; - metadata: { - userId: string; - email: string; - deniedPermissions: { - environmentSlug: string; - ability: string; - }[]; - }; -} -interface SecretApprovalMerge { - type: EventType.SECRET_APPROVAL_MERGED; - metadata: { - mergedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -interface SecretApprovalClosed { - type: EventType.SECRET_APPROVAL_CLOSED; - metadata: { - closedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -interface SecretApprovalReopened { - type: EventType.SECRET_APPROVAL_REOPENED; - metadata: { - reopenedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -interface SecretApprovalRequest { - type: EventType.SECRET_APPROVAL_REQUEST; - metadata: { - committedBy: string; - secretApprovalRequestSlug: string; - secretApprovalRequestId: string; - }; -} - -export type Event = - | GetSecretsEvent - | GetSecretEvent - | CreateSecretEvent - | CreateSecretBatchEvent - | UpdateSecretEvent - | UpdateSecretBatchEvent - | DeleteSecretEvent - | DeleteSecretBatchEvent - | GetWorkspaceKeyEvent - | AuthorizeIntegrationEvent - | UnauthorizeIntegrationEvent - | CreateIntegrationEvent - | DeleteIntegrationEvent - | AddTrustedIPEvent - | UpdateTrustedIPEvent - | DeleteTrustedIPEvent - | CreateServiceTokenEvent - | DeleteServiceTokenEvent - | CreateIdentityEvent - | UpdateIdentityEvent - | DeleteIdentityEvent - | LoginIdentityUniversalAuthEvent - | AddIdentityUniversalAuthEvent - | UpdateIdentityUniversalAuthEvent - | GetIdentityUniversalAuthEvent - | CreateIdentityUniversalAuthClientSecretEvent - | GetIdentityUniversalAuthClientSecretsEvent - | RevokeIdentityUniversalAuthClientSecretEvent - | CreateEnvironmentEvent - | UpdateEnvironmentEvent - | DeleteEnvironmentEvent - | AddWorkspaceMemberEvent - | AddBatchWorkspaceMemberEvent - | RemoveWorkspaceMemberEvent - | CreateFolderEvent - | UpdateFolderEvent - | DeleteFolderEvent - | CreateWebhookEvent - | UpdateWebhookStatusEvent - | DeleteWebhookEvent - | GetSecretImportsEvent - | CreateSecretImportEvent - | UpdateSecretImportEvent - | DeleteSecretImportEvent - | UpdateUserRole - | UpdateUserDeniedPermissions - | SecretApprovalMerge - | SecretApprovalClosed - | SecretApprovalRequest - | SecretApprovalReopened; diff --git a/pg-migrator/src/models/backupPrivateKey.ts b/pg-migrator/src/models/backupPrivateKey.ts deleted file mode 100644 index 47e4b8909..000000000 --- a/pg-migrator/src/models/backupPrivateKey.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IBackupPrivateKey { - _id: Types.ObjectId; - user: Types.ObjectId; - encryptedPrivateKey: string; - iv: string; - tag: string; - salt: string; - algorithm: string; - keyEncoding: "base64" | "utf8"; - verifier: string; - createdAt: string; - updatedAt: string; -} - -const backupPrivateKeySchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - encryptedPrivateKey: { - type: String, - - required: true, - }, - iv: { - type: String, - - required: true, - }, - tag: { - type: String, - - required: true, - }, - algorithm: { - // the encryption algorithm used - type: String, - required: true, - }, - keyEncoding: { - type: String, - required: true, - }, - salt: { - type: String, - - required: true, - }, - verifier: { - type: String, - - required: true, - }, - }, - { - timestamps: true, - }, -); - -export const BackupPrivateKey = model( - "BackupPrivateKey", - backupPrivateKeySchema, -); diff --git a/pg-migrator/src/models/bot.ts b/pg-migrator/src/models/bot.ts deleted file mode 100644 index 1d024191c..000000000 --- a/pg-migrator/src/models/bot.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IBot { - _id: Types.ObjectId; - name: string; - workspace: Types.ObjectId; - isActive: boolean; - publicKey: string; - encryptedPrivateKey: string; - iv: string; - tag: string; - algorithm: "aes-256-gcm"; - keyEncoding: "base64" | "utf8"; -} - -const botSchema = new Schema( - { - name: { - type: String, - required: true, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - isActive: { - type: Boolean, - required: true, - default: false, - }, - publicKey: { - type: String, - required: true, - }, - encryptedPrivateKey: { - type: String, - required: true, - - }, - iv: { - type: String, - required: true, - - }, - tag: { - type: String, - required: true, - - }, - algorithm: { - // the encryption algorithm used - type: String, - required: true, - - }, - keyEncoding: { - type: String, - required: true, - - }, - }, - { - timestamps: true, - }, -); - -export const Bot = model("Bot", botSchema); diff --git a/pg-migrator/src/models/botKey.ts b/pg-migrator/src/models/botKey.ts deleted file mode 100644 index 02a6d6ea9..000000000 --- a/pg-migrator/src/models/botKey.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IBotKey { - _id: Types.ObjectId; - encryptedKey: string; - nonce: string; - sender: Types.ObjectId; - bot: Types.ObjectId; - workspace: Types.ObjectId; -} - -const botKeySchema = new Schema( - { - encryptedKey: { - type: String, - required: true, - }, - nonce: { - type: String, - required: true, - }, - sender: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - bot: { - type: Schema.Types.ObjectId, - ref: "Bot", - required: true, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - }, - { - timestamps: true, - } -); - -export const BotKey = model("BotKey", botKeySchema); \ No newline at end of file diff --git a/pg-migrator/src/models/botOrg.ts b/pg-migrator/src/models/botOrg.ts deleted file mode 100644 index 9da98e6c9..000000000 --- a/pg-migrator/src/models/botOrg.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IBotOrg { - _id: Types.ObjectId; - name: string; - organization: Types.ObjectId; - publicKey: string; - encryptedSymmetricKey: string; - symmetricKeyIV: string; - symmetricKeyTag: string; - symmetricKeyAlgorithm: "aes-256-gcm"; - symmetricKeyKeyEncoding: "base64" | "utf8"; - encryptedPrivateKey: string; - privateKeyIV: string; - privateKeyTag: string; - privateKeyAlgorithm: "aes-256-gcm"; - privateKeyKeyEncoding: "base64" | "utf8"; -} - -const botOrgSchema = new Schema( - { - name: { - type: String, - required: true, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - required: true, - }, - publicKey: { - type: String, - required: true, - }, - encryptedSymmetricKey: { - type: String, - required: true, - }, - symmetricKeyIV: { - type: String, - required: true, - }, - symmetricKeyTag: { - type: String, - required: true, - }, - symmetricKeyAlgorithm: { - type: String, - required: true, - }, - symmetricKeyKeyEncoding: { - type: String, - required: true, - }, - encryptedPrivateKey: { - type: String, - required: true, - }, - privateKeyIV: { - type: String, - required: true, - }, - privateKeyTag: { - type: String, - required: true, - }, - privateKeyAlgorithm: { - type: String, - required: true, - }, - privateKeyKeyEncoding: { - type: String, - required: true, - }, - }, - { - timestamps: true, - }, -); - -export const BotOrg = model("BotOrg", botOrgSchema); diff --git a/pg-migrator/src/models/folder.ts b/pg-migrator/src/models/folder.ts deleted file mode 100644 index b3016822d..000000000 --- a/pg-migrator/src/models/folder.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export type TFolderRootSchema = { - _id: Types.ObjectId; - workspace: Types.ObjectId; - environment: string; - nodes: TFolderSchema; -}; - -export type TFolderSchema = { - id: string; - name: string; - version: number; - children: TFolderSchema[]; -}; - -const folderSchema = new Schema({ - id: { - required: true, - type: String, - }, - version: { - required: true, - type: Number, - default: 1, - }, - name: { - required: true, - type: String, - default: "root", - }, -}); - -folderSchema.add({ children: [folderSchema] }); - -const folderRootSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - environment: { - type: String, - required: true, - }, - nodes: folderSchema, - }, - { - timestamps: true, - } -); - -export const Folder = model("Folder", folderRootSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/folderVersion.ts b/pg-migrator/src/models/folderVersion.ts deleted file mode 100644 index dbcebcb92..000000000 --- a/pg-migrator/src/models/folderVersion.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export type TFolderRootVersionSchema = { - _id: Types.ObjectId; - workspace: Types.ObjectId; - environment: string; - nodes: TFolderVersionSchema; -}; - -export type TFolderVersionSchema = { - id: string; - name: string; - version: number; - children: TFolderVersionSchema[]; -}; - -const folderVersionSchema = new Schema({ - id: { - required: true, - type: String, - default: "root", - }, - name: { - required: true, - type: String, - default: "root", - }, - version: { - required: true, - type: Number, - default: 1, - }, -}); - -folderVersionSchema.add({ children: [folderVersionSchema] }); - -const folderRootVersionSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - environment: { - type: String, - required: true, - }, - nodes: folderVersionSchema, - }, - { - timestamps: true, - } -); - -export const FolderVersion = model( - "FolderVersion", - folderRootVersionSchema -); \ No newline at end of file diff --git a/pg-migrator/src/models/gitAppInstallationSession.ts b/pg-migrator/src/models/gitAppInstallationSession.ts deleted file mode 100644 index 0cdf8df3c..000000000 --- a/pg-migrator/src/models/gitAppInstallationSession.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -type GitAppInstallationSession = { - id: string; - sessionId: string; - organization: Types.ObjectId; - user: Types.ObjectId; -} - -const gitAppInstallationSession = new Schema({ - id: { - required: true, - type: String, - }, - sessionId: { - type: String, - required: true, - unique: true - }, - organization: { - type: Schema.Types.ObjectId, - required: true, - unique: true - }, - user: { - type: Schema.Types.ObjectId, - ref: "User" - } -}); - - -export const GitAppInstallationSession = model("git_app_installation_session", gitAppInstallationSession); \ No newline at end of file diff --git a/pg-migrator/src/models/gitAppOrganizationInstallation.ts b/pg-migrator/src/models/gitAppOrganizationInstallation.ts deleted file mode 100644 index 4ce55b0cb..000000000 --- a/pg-migrator/src/models/gitAppOrganizationInstallation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Schema, model } from "mongoose"; - -type Installation = { - installationId: string - organizationId: string - user: Schema.Types.ObjectId -}; - - -const gitAppOrganizationInstallation = new Schema({ - installationId: { - type: String, - required: true, - unique: true - }, - organizationId: { - type: String, - required: true, - unique: true - }, - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - } -}); - - -export const GitAppOrganizationInstallation = model("git_app_organization_installation", gitAppOrganizationInstallation); \ No newline at end of file diff --git a/pg-migrator/src/models/gitRisks.ts b/pg-migrator/src/models/gitRisks.ts deleted file mode 100644 index 8d3f59208..000000000 --- a/pg-migrator/src/models/gitRisks.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Schema, model } from "mongoose"; - -export const STATUS_RESOLVED_FALSE_POSITIVE = "RESOLVED_FALSE_POSITIVE"; -export const STATUS_RESOLVED_REVOKED = "RESOLVED_REVOKED"; -export const STATUS_RESOLVED_NOT_REVOKED = "RESOLVED_NOT_REVOKED"; -export const STATUS_UNRESOLVED = "UNRESOLVED"; - -export type IGitRisks = { - id: string; - description: string; - startLine: string; - endLine: string; - startColumn: string; - endColumn: string; - match: string; - secret: string; - file: string; - symlinkFile: string; - commit: string; - entropy: string; - author: string; - email: string; - date: string; - message: string; - tags: string[]; - ruleID: string; - fingerprint: string; - fingerPrintWithoutCommitId: string - - isFalsePositive: boolean; // New field for marking risks as false positives - isResolved: boolean; // New field for marking risks as resolved - riskOwner: string | null; // New field for setting a risk owner (nullable string) - installationId: string, - repositoryId: string, - repositoryLink: string - repositoryFullName: string - status: string - pusher: { - name: string, - email: string - }, - organization: Schema.Types.ObjectId, -} - -const gitRisks = new Schema({ - id: { - type: String, - }, - description: { - type: String, - }, - startLine: { - type: String, - }, - endLine: { - type: String, - }, - startColumn: { - type: String, - }, - endColumn: { - type: String, - }, - file: { - type: String, - }, - symlinkFile: { - type: String, - }, - commit: { - type: String, - }, - entropy: { - type: String, - }, - author: { - type: String, - }, - email: { - type: String, - }, - date: { - type: String, - }, - message: { - type: String, - }, - tags: { - type: [String], - }, - ruleID: { - type: String, - }, - fingerprint: { - type: String, - unique: true - }, - fingerPrintWithoutCommitId: { - type: String, - }, - isFalsePositive: { - type: Boolean, - default: false - }, - isResolved: { - type: Boolean, - default: false - }, - riskOwner: { - type: String, - default: null - }, - installationId: { - type: String, - require: true - }, - repositoryId: { - type: String - }, - repositoryLink: { - type: String - }, - repositoryFullName: { - type: String - }, - pusher: { - name: { - type: String - }, - email: { - type: String - }, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - }, - status: { - type: String, - enum: [ - STATUS_RESOLVED_FALSE_POSITIVE, - STATUS_RESOLVED_REVOKED, - STATUS_RESOLVED_NOT_REVOKED, - STATUS_UNRESOLVED - ], - default: STATUS_UNRESOLVED - } -}, { timestamps: true }); - -export const GitRisks = model("GitRisks", gitRisks); \ No newline at end of file diff --git a/pg-migrator/src/models/identity.ts b/pg-migrator/src/models/identity.ts deleted file mode 100644 index ec4948e1b..000000000 --- a/pg-migrator/src/models/identity.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; -import { IPType } from "../ee/models"; - -export interface IIdentityTrustedIp { - ipAddress: string; - type: IPType; - prefix: number; -} - -export enum IdentityAuthMethod { - UNIVERSAL_AUTH = "universal-auth" -} - -export interface IIdentity extends Document { - _id: Types.ObjectId; - name: string; - authMethod?: IdentityAuthMethod; -} - -const identitySchema = new Schema( - { - name: { - type: String, - required: true - }, - authMethod: { - type: String, - enum: IdentityAuthMethod, - required: false, - }, - - }, - { - timestamps: true - } -); - -export const Identity = model("Identity", identitySchema); diff --git a/pg-migrator/src/models/identityAccessToken.ts b/pg-migrator/src/models/identityAccessToken.ts deleted file mode 100644 index d65c6cbce..000000000 --- a/pg-migrator/src/models/identityAccessToken.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; -import { IIdentityTrustedIp } from "./identity"; - -export interface IIdentityAccessToken extends Document { - _id: Types.ObjectId; - identity: Types.ObjectId; - identityUniversalAuthClientSecret?: Types.ObjectId; - accessTokenLastUsedAt?: Date; - accessTokenLastRenewedAt?: Date; - accessTokenNumUses: number; - accessTokenNumUsesLimit: number; - accessTokenTTL: number; - accessTokenMaxTTL: number; - accessTokenTrustedIps: Array; - isAccessTokenRevoked: boolean; - updatedAt: Date; - createdAt: Date; -} - -const identityAccessTokenSchema = new Schema( - { - identity: { - type: Schema.Types.ObjectId, - ref: "Identity", - required: false, - }, - identityUniversalAuthClientSecret: { - type: Schema.Types.ObjectId, - ref: "IdentityUniversalAuthClientSecret", - required: false, - }, - accessTokenLastUsedAt: { - type: Date, - required: false, - }, - accessTokenLastRenewedAt: { - type: Date, - required: false, - }, - accessTokenNumUses: { - // number of times access token has been used - type: Number, - default: 0, - required: true, - }, - accessTokenNumUsesLimit: { - // number of times access token can be used for - type: Number, - default: 0, // default: used as many times as needed - required: true, - }, - accessTokenTTL: { - // seconds - // incremental lifetime - type: Number, - default: 2592000, // 30 days - required: true, - }, - accessTokenMaxTTL: { - // seconds - // max lifetime - type: Number, - default: 2592000, // 30 days - required: true, - }, - accessTokenTrustedIps: { - type: [ - { - ipAddress: { - type: String, - required: true, - }, - type: { - type: String, - required: true, - }, - prefix: { - type: Number, - required: false, - }, - }, - ], - default: [], - required: true, - }, - isAccessTokenRevoked: { - type: Boolean, - default: false, - required: true, - }, - }, - { - timestamps: true, - }, -); - -export const IdentityAccessToken = model( - "IdentityAccessToken", - identityAccessTokenSchema, -); diff --git a/pg-migrator/src/models/identityMembership.ts b/pg-migrator/src/models/identityMembership.ts deleted file mode 100644 index 4c4a12eba..000000000 --- a/pg-migrator/src/models/identityMembership.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IIdentityMembership { - _id: Types.ObjectId; - identity: Types.ObjectId; - workspace: Types.ObjectId; - role: "admin" | "member" | "viewer" | "no-access" | "custom"; - customRole: Types.ObjectId; -} - -const identityMembershipSchema = new Schema( - { - identity: { - type: Schema.Types.ObjectId, - ref: "Identity", - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - index: true, - }, - role: { - type: String, - required: true, - }, - customRole: { - type: Schema.Types.ObjectId, - ref: "Role", - }, - }, - { - timestamps: true, - }, -); - -export const IdentityMembership = model( - "IdentityMembership", - identityMembershipSchema, -); diff --git a/pg-migrator/src/models/identityMembershipOrg.ts b/pg-migrator/src/models/identityMembershipOrg.ts deleted file mode 100644 index 87699467f..000000000 --- a/pg-migrator/src/models/identityMembershipOrg.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IIdentityMembershipOrg { - _id: Types.ObjectId; - identity: Types.ObjectId; - organization: Types.ObjectId; - role: "admin" | "member" | "no-access" | "custom"; - customRole: Types.ObjectId; -} - -const identityMembershipOrgSchema = new Schema( - { - identity: { - type: Schema.Types.ObjectId, - ref: "Identity", - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - }, - role: { - type: String, - required: true, - }, - customRole: { - type: Schema.Types.ObjectId, - ref: "Role", - }, - }, - { - timestamps: true, - }, -); - -export const IdentityMembershipOrg = model( - "IdentityMembershipOrg", - identityMembershipOrgSchema, -); diff --git a/pg-migrator/src/models/identityUniversalAuth.ts b/pg-migrator/src/models/identityUniversalAuth.ts deleted file mode 100644 index 18b14775a..000000000 --- a/pg-migrator/src/models/identityUniversalAuth.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface IIdentityUniversalAuth extends Document { - _id: Types.ObjectId; - identity: Types.ObjectId; - clientId: string; - clientSecretTrustedIps: Array<{}>; - accessTokenTTL: number; - accessTokenMaxTTL: number; - accessTokenNumUsesLimit: number; - accessTokenTrustedIps: Array<{}>; -} - -const identityUniversalAuthSchema = new Schema( - { - identity: { - type: Schema.Types.ObjectId, - ref: "Identity", - required: true, - }, - clientId: { - type: String, - required: true, - }, - clientSecretTrustedIps: { - type: [ - { - ipAddress: { - type: String, - required: true, - }, - type: { - type: String, - required: true, - }, - prefix: { - type: Number, - required: false, - }, - }, - ], - default: [ - { - ipAddress: "0.0.0.0", - prefix: 0, - }, - ], - required: true, - }, - accessTokenTTL: { - // seconds - // incremental lifetime - type: Number, - default: 7200, - required: true, - }, - accessTokenMaxTTL: { - // seconds - // max lifetime - type: Number, - default: 7200, - required: true, - }, - accessTokenNumUsesLimit: { - // number of times access token can be used for - type: Number, - default: 0, // default: used as many times as needed - required: true, - }, - accessTokenTrustedIps: { - type: [ - { - ipAddress: { - type: String, - required: true, - }, - type: { - type: String, - required: true, - }, - prefix: { - type: Number, - required: false, - }, - }, - ], - default: [], - required: true, - }, - }, - { - timestamps: true, - }, -); - -export const IdentityUniversalAuth = model( - "IdentityUniversalAuth", - identityUniversalAuthSchema, -); diff --git a/pg-migrator/src/models/identityUniversalAuthClientSecret.ts b/pg-migrator/src/models/identityUniversalAuthClientSecret.ts deleted file mode 100644 index af9cc08a4..000000000 --- a/pg-migrator/src/models/identityUniversalAuthClientSecret.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface IIdentityUniversalAuthClientSecret extends Document { - _id: Types.ObjectId; - identity: Types.ObjectId; - identityUniversalAuth : Types.ObjectId; - description: string; - clientSecretPrefix: string; - clientSecretHash: string; - clientSecretLastUsedAt?: Date; - clientSecretNumUses: number; - clientSecretNumUsesLimit: number; - clientSecretTTL: number; - updatedAt: Date; - createdAt: Date; - isClientSecretRevoked: boolean; -} - -const identityUniversalAuthClientSecretSchema = new Schema( - { - identity: { - type: Schema.Types.ObjectId, - ref: "Identity", - required: true - }, - identityUniversalAuth: { - type: Schema.Types.ObjectId, - ref: "IdentityUniversalAuth", - required: true - }, - description: { - type: String, - required: true - }, - clientSecretPrefix: { - type: String, - required: true - }, - clientSecretHash: { - type: String, - required: true - }, - clientSecretLastUsedAt: { - type: Date, - required: false - }, - clientSecretNumUses: { - // number of times client secret has been used - // in login operation - type: Number, - default: 0, - required: true - }, - clientSecretNumUsesLimit: { - // number of times client secret can be used for - // a login operation - type: Number, - default: 0, // default: used as many times as needed - required: true - }, - clientSecretTTL: { - type: Number, - default: 0, // default: does not expire - required: true - }, - isClientSecretRevoked: { - type: Boolean, - default: false, - required: true - } - }, - { - timestamps: true - } -); - -identityUniversalAuthClientSecretSchema.index( - { identityUniversalAuth: 1, isClientSecretRevoked: 1 } -); - -export const IdentityUniversalAuthClientSecret = model("IdentityUniversalAuthClientSecret", identityUniversalAuthClientSecretSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/incidentContactOrg.ts b/pg-migrator/src/models/incidentContactOrg.ts deleted file mode 100644 index 905b9263f..000000000 --- a/pg-migrator/src/models/incidentContactOrg.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IIncidentContactOrg { - _id: Types.ObjectId; - email: string; - organization: Types.ObjectId; -} - -const incidentContactOrgSchema = new Schema( - { - email: { - type: String, - required: true, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - required: true, - }, - }, - { - timestamps: true, - } -); - -export const IncidentContactOrg = model( - "IncidentContactOrg", - incidentContactOrgSchema -); \ No newline at end of file diff --git a/pg-migrator/src/models/index.ts b/pg-migrator/src/models/index.ts deleted file mode 100644 index 2489e30f5..000000000 --- a/pg-migrator/src/models/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -export * from "./backupPrivateKey"; -export * from "./bot"; -export * from "./botOrg"; -export * from "./botKey"; -export * from "./incidentContactOrg"; -export * from "./integration/integration"; -export * from "./integrationAuth"; -export * from "./key"; -export * from "./membership"; -export * from "./membershipOrg"; -export * from "./organization"; -export * from "./secret"; -export * from "./tag"; -export * from "./folder"; -export * from "./secretImports"; -export * from "./secretBlindIndexData"; -export * from "./serviceToken"; // TODO: deprecate -export * from "./tokenData"; -export * from "./user"; -export * from "./userAction"; -export * from "./workspace"; -export * from "./serviceTokenData"; // TODO: deprecate - -// new -export * from "./identity"; -export * from "./identityMembership"; -export * from "./identityMembershipOrg"; -export * from "./identityUniversalAuth"; -export * from "./identityUniversalAuthClientSecret"; -export * from "./identityAccessToken"; - -export * from "./apiKeyData"; // TODO: deprecate -export * from "./apiKeyDataV2"; -export * from "./loginSRPDetail"; -export * from "./tokenVersion"; -export * from "./webhooks"; - -export * from "./secretSnapshot"; -export * from "./secretVersion"; -export * from "./folderVersion"; -export * from "./role"; -export * from "./ssoConfig"; -export * from "./trustedIp"; -export * from "./auditLog"; -export * from "./gitRisks"; -export * from "./gitAppOrganizationInstallation"; -export * from "./gitAppInstallationSession"; -export * from "./secretApprovalPolicy"; -export * from "./secretApprovalRequest"; diff --git a/pg-migrator/src/models/integration/index.ts b/pg-migrator/src/models/integration/index.ts deleted file mode 100644 index 2ed44cd28..000000000 --- a/pg-migrator/src/models/integration/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./integration"; \ No newline at end of file diff --git a/pg-migrator/src/models/integration/integration.ts b/pg-migrator/src/models/integration/integration.ts deleted file mode 100644 index 40578138d..000000000 --- a/pg-migrator/src/models/integration/integration.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Schema, Types, model } from "mongoose"; -import { Metadata } from "./types"; - -export interface IIntegration { - _id: Types.ObjectId; - workspace: Types.ObjectId; - environment: string; - isActive: boolean; - url: string; - app: string; - appId: string; - owner: string; - targetEnvironment: string; - targetEnvironmentId: string; - targetService: string; - targetServiceId: string; - path: string; - region: string; - scope: string; - secretPath: string; - integration: - | "azure-key-vault" - | "aws-parameter-store" - | "aws-secret-manager" - | "heroku" - | "vercel" - | "netlify" - | "github" - | "gitlab" - | "render" - | "railway" - | "flyio" - | "circleci" - | "laravel-forge" - | "travisci" - | "supabase" - | "checkly" - | "qovery" - | "terraform-cloud" - | "teamcity" - | "hashicorp-vault" - | "cloudflare-pages" - | "cloudflare-workers" - | "bitbucket" - | "codefresh" - | "digital-ocean-app-platform" - | "cloud-66" - | "northflank" - | "windmill" - | "gcp-secret-manager" - | "hasura-cloud"; - integrationAuth: Types.ObjectId; - metadata: Metadata; -} - -const integrationSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - environment: { - type: String, - required: true, - }, - isActive: { - type: Boolean, - required: true, - }, - url: { - // for custom self-hosted integrations (e.g. self-hosted GitHub enterprise) - type: String, - default: null, - }, - app: { - // name of app in provider - type: String, - default: null, - }, - appId: { - // id of app in provider - type: String, - default: null, - }, - targetEnvironment: { - // target environment - type: String, - default: null, - }, - targetEnvironmentId: { - type: String, - default: null, - }, - targetService: { - // railway-specific service - // qovery-specific project - type: String, - default: null, - }, - targetServiceId: { - // railway-specific service - // qovery specific project - type: String, - default: null, - }, - owner: { - // github-specific repo owner-login - type: String, - default: null, - }, - path: { - // aws-parameter-store-specific path - // (also) vercel preview-branch - type: String, - default: null, - }, - region: { - // aws-parameter-store-specific path - type: String, - default: null, - }, - scope: { - // qovery-specific scope - type: String, - default: null, - }, - integration: { - type: String, - required: true, - }, - integrationAuth: { - type: Schema.Types.ObjectId, - ref: "IntegrationAuth", - required: true, - }, - secretPath: { - type: String, - required: true, - default: "/", - }, - metadata: { - type: Schema.Types.Mixed, - default: {}, - }, - }, - { - timestamps: true, - }, -); - -export const Integration = model( - "Integration", - integrationSchema, -); diff --git a/pg-migrator/src/models/integration/types.ts b/pg-migrator/src/models/integration/types.ts deleted file mode 100644 index 6bd0bb1a4..000000000 --- a/pg-migrator/src/models/integration/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type Metadata = { - secretPrefix?: string; - secretSuffix?: string; - secretGCPLabel?: { - labelName: string; - labelValue: string; - } - secretAWSTag?: { - key: string; - value: string; - }[] - kmsKeyId?: string; - shouldDisableDelete?: boolean; -} \ No newline at end of file diff --git a/pg-migrator/src/models/integrationAuth/index.ts b/pg-migrator/src/models/integrationAuth/index.ts deleted file mode 100644 index 157095bd2..000000000 --- a/pg-migrator/src/models/integrationAuth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./integrationAuth"; \ No newline at end of file diff --git a/pg-migrator/src/models/integrationAuth/integrationAuth.ts b/pg-migrator/src/models/integrationAuth/integrationAuth.ts deleted file mode 100644 index 1c2e6d450..000000000 --- a/pg-migrator/src/models/integrationAuth/integrationAuth.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; -import { IntegrationAuthMetadata } from "./types"; - -export interface IIntegrationAuth extends Document { - _id: Types.ObjectId; - workspace: Types.ObjectId; - integration: - | "heroku" - | "vercel" - | "netlify" - | "github" - | "gitlab" - | "render" - | "railway" - | "flyio" - | "azure-key-vault" - | "laravel-forge" - | "circleci" - | "travisci" - | "supabase" - | "aws-parameter-store" - | "aws-secret-manager" - | "checkly" - | "qovery" - | "cloudflare-pages" - | "cloudflare-workers" - | "codefresh" - | "digital-ocean-app-platform" - | "bitbucket" - | "cloud-66" - | "terraform-cloud" - | "teamcity" - | "northflank" - | "windmill" - | "gcp-secret-manager" - | "hasura-cloud"; - teamId: string; - accountId: string; - url: string; - namespace: string; - refreshCiphertext?: string; - refreshIV?: string; - refreshTag?: string; - accessIdCiphertext?: string; - accessIdIV?: string; - accessIdTag?: string; - accessCiphertext?: string; - accessIV?: string; - accessTag?: string; - algorithm?: "aes-256-gcm"; - keyEncoding?: "utf8" | "base64"; - accessExpiresAt?: Date; - metadata?: IntegrationAuthMetadata; -} - -const integrationAuthSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - integration: { - type: String, - required: true, - }, - teamId: { - // vercel-specific integration param - type: String, - }, - url: { - // for any self-hosted integrations (e.g. self-hosted hashicorp-vault) - type: String, - }, - namespace: { - // hashicorp-vault-specific integration param - type: String, - }, - accountId: { - // netlify-specific integration param - type: String, - }, - refreshCiphertext: { - type: String, - - }, - refreshIV: { - type: String, - - }, - refreshTag: { - type: String, - - }, - accessIdCiphertext: { - type: String, - - }, - accessIdIV: { - type: String, - - }, - accessIdTag: { - type: String, - - }, - accessCiphertext: { - type: String, - - }, - accessIV: { - type: String, - - }, - accessTag: { - type: String, - - }, - accessExpiresAt: { - type: Date, - - }, - algorithm: { - // the encryption algorithm used - type: String, - required: true, - }, - keyEncoding: { - type: String, - required: true, - }, - metadata: { - type: Schema.Types.Mixed, - }, - }, - { - timestamps: true, - }, -); - -export const IntegrationAuth = model( - "IntegrationAuth", - integrationAuthSchema, -); diff --git a/pg-migrator/src/models/integrationAuth/types.ts b/pg-migrator/src/models/integrationAuth/types.ts deleted file mode 100644 index d29869e3b..000000000 --- a/pg-migrator/src/models/integrationAuth/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -interface GCPIntegrationAuthMetadata { - authMethod: "oauth2" | "serviceAccount" -} - -export type IntegrationAuthMetadata = GCPIntegrationAuthMetadata; \ No newline at end of file diff --git a/pg-migrator/src/models/key.ts b/pg-migrator/src/models/key.ts deleted file mode 100644 index d0d6f80f7..000000000 --- a/pg-migrator/src/models/key.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IKey { - _id: Types.ObjectId; - encryptedKey: string; - nonce: string; - sender: Types.ObjectId; - receiver: Types.ObjectId; - workspace: Types.ObjectId; -} - -const keySchema = new Schema( - { - encryptedKey: { - type: String, - required: true, - }, - nonce: { - type: String, - required: true, - }, - sender: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - receiver: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - }, - { - timestamps: true, - }, -); - -export const Key = model("Key", keySchema); diff --git a/pg-migrator/src/models/loginSRPDetail.ts b/pg-migrator/src/models/loginSRPDetail.ts deleted file mode 100644 index 26f897270..000000000 --- a/pg-migrator/src/models/loginSRPDetail.ts +++ /dev/null @@ -1,27 +0,0 @@ -import mongoose, { Schema, Types, model } from "mongoose"; - -export interface ILoginSRPDetail { - _id: Types.ObjectId; - clientPublicKey: string; - email: string; - serverBInt: mongoose.Schema.Types.Buffer; - userId: string; - expireAt: Date; -} - -const loginSRPDetailSchema = new Schema( - { - clientPublicKey: { - type: String, - required: true, - }, - email: { - type: String, - unique: true, - }, - serverBInt: { type: mongoose.Schema.Types.Buffer }, - expireAt: { type: Date }, - } -); - -export const LoginSRPDetail = model("LoginSRPDetail", loginSRPDetailSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/membership.ts b/pg-migrator/src/models/membership.ts deleted file mode 100644 index eb2787082..000000000 --- a/pg-migrator/src/models/membership.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IMembershipPermission { - environmentSlug: string; - ability: string; -} - -export interface IMembership { - _id: Types.ObjectId; - user: Types.ObjectId; - inviteEmail?: string; - workspace: Types.ObjectId; - role: "admin" | "member" | "viewer" | "no-access" | "custom"; - customRole: Types.ObjectId; - deniedPermissions: IMembershipPermission[]; -} - -const membershipSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - }, - inviteEmail: { - type: String, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - deniedPermissions: { - type: [ - { - environmentSlug: String, - ability: { - type: String, - enum: ["read", "write"], - }, - }, - ], - default: [], - }, - role: { - type: String, - required: true, - }, - customRole: { - type: Schema.Types.ObjectId, - ref: "Role", - }, - }, - { - timestamps: true, - }, -); - -export const Membership = model("Membership", membershipSchema); diff --git a/pg-migrator/src/models/membershipOrg.ts b/pg-migrator/src/models/membershipOrg.ts deleted file mode 100644 index 2f02fc28a..000000000 --- a/pg-migrator/src/models/membershipOrg.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface IMembershipOrg extends Document { - _id: Types.ObjectId; - user: Types.ObjectId; - inviteEmail: string; - organization: Types.ObjectId; - role: "admin" | "member" | "no-access" | "custom"; - customRole: Types.ObjectId; - status: "invited" | "accepted"; - createdAt: string; - updatedAt: string; -} - -const membershipOrgSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - }, - inviteEmail: { - type: String, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - }, - role: { - type: String, - required: true, - }, - status: { - type: String, - required: true, - }, - customRole: { - type: Schema.Types.ObjectId, - ref: "Role", - }, - }, - { - timestamps: true, - }, -); - -export const MembershipOrg = model( - "MembershipOrg", - membershipOrgSchema, -); diff --git a/pg-migrator/src/models/organization.ts b/pg-migrator/src/models/organization.ts deleted file mode 100644 index 31a27411b..000000000 --- a/pg-migrator/src/models/organization.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IOrganization { - _id: Types.ObjectId; - name: string; - customerId?: string; - createdAt: string; - updatedAt: string; -} - -const organizationSchema = new Schema( - { - name: { - type: String, - required: true, - }, - customerId: { - type: String, - }, - }, - { - timestamps: true, - }, -); - -export const Organization = model( - "Organization", - organizationSchema, -); diff --git a/pg-migrator/src/models/role.ts b/pg-migrator/src/models/role.ts deleted file mode 100644 index 536363312..000000000 --- a/pg-migrator/src/models/role.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IRole { - _id: Types.ObjectId; - name: string; - description: string; - slug: string; - permissions: Array; - workspace: Types.ObjectId; - organization: Types.ObjectId; - isOrgRole: boolean; - createdAt: string; - updatedAt: string; -} - -const roleSchema = new Schema( - { - name: { - type: String, - required: true, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - required: true, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - }, - isOrgRole: { - type: Boolean, - required: true, - - }, - description: { - type: String, - }, - slug: { - type: String, - required: true, - }, - permissions: { - type: Array, - required: true, - }, - }, - { - timestamps: true, - }, -); - -roleSchema.index({ organization: 1, workspace: 1 }); - -export const Role = model("Role", roleSchema); diff --git a/pg-migrator/src/models/secret.ts b/pg-migrator/src/models/secret.ts deleted file mode 100644 index 1dbb6a84e..000000000 --- a/pg-migrator/src/models/secret.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface ISecret { - _id: Types.ObjectId; - version: number; - workspace: Types.ObjectId; - type: string; - user?: Types.ObjectId; - environment: string; - secretBlindIndex?: string; - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretKeyHash: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - secretValueHash: string; - secretCommentCiphertext?: string; - secretCommentIV?: string; - secretCommentTag?: string; - secretCommentHash?: string; - - // ? NOTE: This works great for workspace-level reminders. - // ? If we want to do it on a user-basis, we should ideally have a seperate model for reminders. - secretReminderRepeatDays?: number | null; - secretReminderNote?: string | null; - - skipMultilineEncoding?: boolean; - algorithm: "aes-256-gcm"; - keyEncoding: "utf8" | "base64"; - tags?: string[]; - folder?: string; - metadata?: { - [key: string]: string; - }; -} - -const secretSchema = new Schema( - { - version: { - type: Number, - required: true, - default: 1, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - type: { - type: String, - required: true, - }, - user: { - // user associated with the personal secret - type: Schema.Types.ObjectId, - ref: "User", - }, - tags: { - ref: "Tag", - type: [Schema.Types.ObjectId], - default: [], - }, - environment: { - type: String, - required: true, - }, - secretBlindIndex: { - type: String, - - }, - secretKeyCiphertext: { - type: String, - required: true, - }, - secretKeyIV: { - type: String, // symmetric - required: true, - }, - secretKeyTag: { - type: String, // symmetric - required: true, - }, - secretKeyHash: { - type: String, - }, - secretValueCiphertext: { - type: String, - required: true, - }, - secretValueIV: { - type: String, // symmetric - required: true, - }, - secretValueTag: { - type: String, // symmetric - required: true, - }, - secretValueHash: { - type: String, - }, - secretCommentCiphertext: { - type: String, - required: false, - }, - secretCommentIV: { - type: String, // symmetric - required: false, - }, - secretCommentTag: { - type: String, // symmetric - required: false, - }, - secretCommentHash: { - type: String, - required: false, - }, - - secretReminderRepeatDays: { - type: Number, - required: false, - default: null, - }, - secretReminderNote: { - type: String, - required: false, - default: null, - }, - - skipMultilineEncoding: { - type: Boolean, - required: false, - }, - - algorithm: { - // the encryption algorithm used - type: String, - required: true, - }, - keyEncoding: { - type: String, - required: true, - }, - folder: { - type: String, - default: "root", - }, - metadata: { - type: Schema.Types.Mixed, - }, - }, - { - timestamps: true, - }, -); - -secretSchema.index({ tags: 1 }, { background: true }); - -export const Secret = model("Secret", secretSchema); diff --git a/pg-migrator/src/models/secretApprovalPolicy.ts b/pg-migrator/src/models/secretApprovalPolicy.ts deleted file mode 100644 index 376b541c7..000000000 --- a/pg-migrator/src/models/secretApprovalPolicy.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface ISecretApprovalPolicy { - _id: Types.ObjectId; - workspace: Types.ObjectId; - name: string; - environment: string; - secretPath?: string; - approvers: Types.ObjectId[]; - approvals: number; -} - -const secretApprovalPolicySchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true - }, - approvers: [ - { - // user associated with the personal secret - type: Schema.Types.ObjectId, - ref: "Membership" - } - ], - name: { - type: String - }, - environment: { - type: String, - required: true - }, - secretPath: { - type: String, - required: false - }, - approvals: { - type: Number, - default: 1 - } - }, - { - timestamps: true - } -); - -export const SecretApprovalPolicy = model( - "SecretApprovalPolicy", - secretApprovalPolicySchema -); diff --git a/pg-migrator/src/models/secretApprovalRequest.ts b/pg-migrator/src/models/secretApprovalRequest.ts deleted file mode 100644 index 983163d1a..000000000 --- a/pg-migrator/src/models/secretApprovalRequest.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export enum ApprovalStatus { - PENDING = "pending", - APPROVED = "approved", - REJECTED = "rejected", -} - -export enum CommitType { - DELETE = "delete", - UPDATE = "update", - CREATE = "create", -} - -export interface ISecretApprovalSecChange { - _id: Types.ObjectId; - version: number; - secretBlindIndex?: string; - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - secretCommentIV?: string; - secretCommentTag?: string; - secretCommentCiphertext?: string; - skipMultilineEncoding?: boolean; - algorithm?: "aes-256-gcm"; - keyEncoding?: "utf8" | "base64"; - tags?: string[]; -} - -export type ISecretCommits = Array< - | { - newVersion: ISecretApprovalSecChange; - op: CommitType.CREATE; - } - | { - // secret is recorded to get the latest version, we can keep ref to secret for pulling change as it will also get changed - // on merge - secretVersion: J; - secret: T; - newVersion: Partial> & { - _id: Types.ObjectId; - }; - op: CommitType.UPDATE; - } - | { - secret: T; - secretVersion: J; - op: CommitType.DELETE; - } ->; -export interface ISecretApprovalRequest { - _id: Types.ObjectId; - committer: Types.ObjectId; - slug: string; - statusChangeBy: Types.ObjectId; - reviewers: { - member: Types.ObjectId; - status: ApprovalStatus; - }[]; - workspace: Types.ObjectId; - environment: string; - folderId: string; - hasMerged: boolean; - status: "open" | "close"; - policy: Types.ObjectId; - commits: ISecretCommits; - conflicts: Array<{ secretId: string; op: CommitType }>; -} - -const secretApprovalSecretChangeSchema = new Schema({ - version: { - type: Number, - default: 1, - required: true, - }, - secretBlindIndex: { - type: String, - - }, - secretKeyCiphertext: { - type: String, - required: true, - }, - secretKeyIV: { - type: String, // symmetric - required: true, - }, - secretKeyTag: { - type: String, // symmetric - required: true, - }, - secretValueCiphertext: { - type: String, - required: true, - }, - secretValueIV: { - type: String, // symmetric - required: true, - }, - secretValueTag: { - type: String, // symmetric - required: true, - }, - skipMultilineEncoding: { - type: Boolean, - required: false, - }, - algorithm: { - // the encryption algorithm used - type: String, - required: true, - }, - keyEncoding: { - type: String, - required: true, - }, - tags: { - ref: "Tag", - type: [Schema.Types.ObjectId], - default: [], - }, -}); - -const secretApprovalRequestSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - environment: { - type: String, - required: true, - }, - folderId: { - type: String, - required: true, - default: "root", - }, - slug: { - type: String, - }, - reviewers: { - type: [ - { - member: { - // user associated with the personal secret - type: Schema.Types.ObjectId, - ref: "Membership", - }, - status: { - type: String, - enum: ApprovalStatus, - default: ApprovalStatus.PENDING, - }, - }, - ], - default: [], - }, - policy: { type: Schema.Types.ObjectId, ref: "SecretApprovalPolicy" }, - hasMerged: { type: Boolean, default: false }, - status: { type: String, enum: ["close", "open"], default: "open" }, - committer: { type: Schema.Types.ObjectId, ref: "Membership" }, - statusChangeBy: { type: Schema.Types.ObjectId, ref: "Membership" }, - commits: [ - { - secret: { type: Types.ObjectId, ref: "Secret" }, - newVersion: secretApprovalSecretChangeSchema, - secretVersion: { type: Types.ObjectId, ref: "SecretVersion" }, - op: { type: String, enum: [CommitType], required: true }, - }, - ], - conflicts: { - type: [ - { - secretId: { type: String, required: true }, - op: { type: String, enum: [CommitType], required: true }, - }, - ], - default: [], - }, - }, - { - timestamps: true, - }, -); - -export const SecretApprovalRequest = model( - "SecretApprovalRequest", - secretApprovalRequestSchema, -); diff --git a/pg-migrator/src/models/secretBlindIndexData.ts b/pg-migrator/src/models/secretBlindIndexData.ts deleted file mode 100644 index e1eea4c3b..000000000 --- a/pg-migrator/src/models/secretBlindIndexData.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface ISecretBlindIndexData extends Document { - _id: Types.ObjectId; - workspace: Types.ObjectId; - encryptedSaltCiphertext: string; - saltIV: string; - saltTag: string; - algorithm: "aes-256-gcm"; - keyEncoding: "base64" | "utf8"; -} - -const secretBlindIndexDataSchema = new Schema({ - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - encryptedSaltCiphertext: { - // TODO: make these - type: String, - required: true, - }, - saltIV: { - type: String, - required: true, - }, - saltTag: { - type: String, - required: true, - }, - algorithm: { - type: String, - required: true, - - }, - keyEncoding: { - type: String, - required: true, - - }, -}); - -secretBlindIndexDataSchema.index({ workspace: 1 }); - -export const SecretBlindIndexData = model( - "SecretBlindIndexData", - secretBlindIndexDataSchema, -); diff --git a/pg-migrator/src/models/secretImports.ts b/pg-migrator/src/models/secretImports.ts deleted file mode 100644 index 79046a489..000000000 --- a/pg-migrator/src/models/secretImports.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface ISecretImports { - _id: Types.ObjectId; - workspace: Types.ObjectId; - environment: string; - folderId: string; - imports: Array<{ - environment: string; - secretPath: string; - }>; -} - -const secretImportSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true - }, - environment: { - type: String, - required: true - }, - folderId: { - type: String, - required: true, - default: "root" - }, - imports: { - type: [ - { - environment: { - type: String, - required: true - }, - secretPath: { - type: String, - required: true - } - } - ], - default: [] - } - }, - { - timestamps: true - } -); - -export const SecretImport = model("SecretImports", secretImportSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/secretRotation.ts b/pg-migrator/src/models/secretRotation.ts deleted file mode 100644 index 238cef536..000000000 --- a/pg-migrator/src/models/secretRotation.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Document, Schema, model, Types } from "mongoose"; - -export interface ISecretRotation extends Document { - _id: Types.ObjectId; - name: string; - interval: number; - provider: string; - customProvider: Types.ObjectId; - workspace: Types.ObjectId; - environment: string; - secretPath: string; - outputs: Array<{ - key: string; - secret: Types.ObjectId; - }>; - status?: "success" | "failed"; - lastRotatedAt?: string; - statusMessage?: string; - encryptedData: string; - encryptedDataIV: string; - encryptedDataTag: string; - algorithm: string; - keyEncoding: string; -} -const secretRotationSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - }, - provider: { - type: String, - required: true, - }, - customProvider: { - type: Schema.Types.ObjectId, - ref: "SecretRotationProvider", - }, - environment: { - type: String, - required: true, - }, - secretPath: { - type: String, - required: true, - }, - interval: { - type: Number, - required: true, - }, - lastRotatedAt: { - type: String, - }, - status: { - type: String, - enum: ["success", "failed"], - }, - statusMessage: { - type: String, - }, - // encrypted data on input keys and secrets got - encryptedData: { - type: String, - select: false, - }, - encryptedDataIV: { - type: String, - select: false, - }, - encryptedDataTag: { - type: String, - select: false, - }, - algorithm: { - // the encryption algorithm used - type: String, - required: true, - select: false, - }, - keyEncoding: { - type: String, - required: true, - select: false, - }, - outputs: [ - { - key: { - type: String, - required: true, - }, - secret: { - type: Schema.Types.ObjectId, - ref: "Secret", - }, - }, - ], - }, - { - timestamps: true, - }, -); - -export const SecretRotation = model( - "SecretRotation", - secretRotationSchema, -); diff --git a/pg-migrator/src/models/secretSnapshot.ts b/pg-migrator/src/models/secretSnapshot.ts deleted file mode 100644 index 132329f4a..000000000 --- a/pg-migrator/src/models/secretSnapshot.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface ISecretSnapshot { - id: string; - _id: string; - workspace: Types.ObjectId; - environment: string; - folderId: string | "root"; - version: number; - secretVersions: Types.ObjectId[]; - folderVersion: Types.ObjectId; -} - -const secretSnapshotSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - environment: { - type: String, - required: true, - }, - folderId: { - type: String, - default: "root", - }, - version: { - type: Number, - default: 1, - required: true, - }, - secretVersions: [ - { - type: Schema.Types.ObjectId, - ref: "SecretVersion", - required: true, - }, - ], - folderVersion: { - type: Schema.Types.ObjectId, - ref: "FolderVersion", - }, - }, - { - timestamps: true, - }, -); - -export const SecretSnapshot = model( - "SecretSnapshot", - secretSnapshotSchema, -); diff --git a/pg-migrator/src/models/secretVersion.ts b/pg-migrator/src/models/secretVersion.ts deleted file mode 100644 index 6922216d1..000000000 --- a/pg-migrator/src/models/secretVersion.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface ISecretVersion { - _id: Types.ObjectId; - secret: Types.ObjectId; - version: number; - workspace: Types.ObjectId; // new - type: string; // new - user?: Types.ObjectId; // new - environment: string; // new - isDeleted: boolean; - secretBlindIndex?: string; - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - skipMultilineEncoding?: boolean; - algorithm: "aes-256-gcm"; - keyEncoding: "utf8" | "base64"; - createdAt: string; - folder?: string; - tags?: string[]; -} - -const secretVersionSchema = new Schema( - { - secret: { - // could be deleted - type: Schema.Types.ObjectId, - ref: "Secret", - required: true, - }, - version: { - type: Number, - default: 1, - required: true, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - type: { - type: String, - required: true, - }, - user: { - // user associated with the personal secret - type: Schema.Types.ObjectId, - ref: "User", - }, - environment: { - type: String, - required: true, - }, - isDeleted: { - // consider removing field - type: Boolean, - default: false, - required: true, - }, - secretBlindIndex: { - type: String, - - }, - secretKeyCiphertext: { - type: String, - required: true, - }, - secretKeyIV: { - type: String, // symmetric - required: true, - }, - secretKeyTag: { - type: String, // symmetric - required: true, - }, - secretValueCiphertext: { - type: String, - required: true, - }, - secretValueIV: { - type: String, // symmetric - required: true, - }, - secretValueTag: { - type: String, // symmetric - required: true, - }, - skipMultilineEncoding: { - type: Boolean, - required: false, - }, - algorithm: { - // the encryption algorithm used - type: String, - required: true, - }, - keyEncoding: { - type: String, - required: true, - }, - folder: { - type: String, - required: true, - }, - tags: { - ref: "Tag", - type: [Schema.Types.ObjectId], - default: [], - }, - }, - { - timestamps: true, - }, -); - -export const SecretVersion = model( - "SecretVersion", - secretVersionSchema, -); diff --git a/pg-migrator/src/models/serverConfig.ts b/pg-migrator/src/models/serverConfig.ts deleted file mode 100644 index 13e469bd5..000000000 --- a/pg-migrator/src/models/serverConfig.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IServerConfig { - _id: Types.ObjectId; - initialized: boolean; - allowSignUp: boolean; -} - -const serverConfigSchema = new Schema( - { - initialized: { - type: Boolean, - default: false - }, - allowSignUp: { - type: Boolean, - default: true - } - }, - { - timestamps: true - } -); - -export const ServerConfig = model("ServerConfig", serverConfigSchema); diff --git a/pg-migrator/src/models/serviceToken.ts b/pg-migrator/src/models/serviceToken.ts deleted file mode 100644 index 0e943b177..000000000 --- a/pg-migrator/src/models/serviceToken.ts +++ /dev/null @@ -1,60 +0,0 @@ -// TODO: deprecate -import { Schema, Types, model } from "mongoose"; -export interface IServiceToken { - _id: Types.ObjectId; - name: string; - user: Types.ObjectId; - workspace: Types.ObjectId; - environment: string; - expiresAt: Date; - publicKey: string; - encryptedKey: string; - nonce: string; -} - -const serviceTokenSchema = new Schema( - { - name: { - type: String, - required: true, - }, - user: { - // token issuer - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - environment: { - type: String, - required: true, - }, - expiresAt: { - type: Date, - }, - publicKey: { - type: String, - required: true, - select: true, - }, - encryptedKey: { - type: String, - required: true, - select: true, - }, - nonce: { - type: String, - required: true, - select: true, - }, - }, - { - timestamps: true, - } -); - -export const ServiceToken = model("ServiceToken", serviceTokenSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/serviceTokenData.ts b/pg-migrator/src/models/serviceTokenData.ts deleted file mode 100644 index a8357d689..000000000 --- a/pg-migrator/src/models/serviceTokenData.ts +++ /dev/null @@ -1,93 +0,0 @@ -// TODO: deprecate -import { Document, Schema, Types, model } from "mongoose"; - -export interface IServiceTokenData extends Document { - _id: Types.ObjectId; - name: string; - workspace: Types.ObjectId; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - user: Types.ObjectId; - serviceAccount: Types.ObjectId; - lastUsed: Date; - expiresAt: Date; - secretHash: string; - encryptedKey: string; - iv: string; - tag: string; - permissions: string[]; -} - -const serviceTokenDataSchema = new Schema( - { - name: { - type: String, - required: true - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true - }, - scopes: { - type: [ - { - environment: { - type: String, - required: true - }, - secretPath: { - type: String, - default: "/", - required: true - } - } - ], - required: true - }, - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true - }, - serviceAccount: { - type: Schema.Types.ObjectId, - ref: "ServiceAccount" - }, - lastUsed: { - type: Date - }, - expiresAt: { - type: Date - }, - secretHash: { - type: String, - required: true, - - }, - encryptedKey: { - type: String, - - }, - iv: { - type: String, - - }, - tag: { - type: String, - - }, - permissions: { - type: [String], - enum: ["read", "write"], - default: ["read"] - } - }, - { - timestamps: true - } -); - -export const ServiceTokenData = model("ServiceTokenData", serviceTokenDataSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/ssoConfig.ts b/pg-migrator/src/models/ssoConfig.ts deleted file mode 100644 index b591b8817..000000000 --- a/pg-migrator/src/models/ssoConfig.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export enum AuthProvider { - OKTA_SAML = "okta-saml", - AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" -} - -export interface ISSOConfig { - organization: Types.ObjectId; - authProvider: AuthProvider; - isActive: boolean; - encryptedEntryPoint: string; - entryPointIV: string; - entryPointTag: string; - encryptedIssuer: string; - issuerIV: string; - issuerTag: string; - encryptedCert: string; - certIV: string; - certTag: string; -} - -const ssoConfigSchema = new Schema( - { - organization: { - type: Schema.Types.ObjectId, - ref: "Organization" - }, - authProvider: { - type: String, - enum: AuthProvider, - required: true - }, - isActive: { - type: Boolean, - required: true - }, - encryptedEntryPoint: { - type: String - }, - entryPointIV: { - type: String - }, - entryPointTag: { - type: String - }, - encryptedIssuer: { - type: String - }, - issuerIV: { - type: String - }, - issuerTag: { - type: String - }, - encryptedCert: { - type: String - }, - certIV: { - type: String - }, - certTag: { - type: String - } - }, - { - timestamps: true - } -); - -export const SSOConfig = model("SSOConfig", ssoConfigSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/tag.ts b/pg-migrator/src/models/tag.ts deleted file mode 100644 index a5f0bd307..000000000 --- a/pg-migrator/src/models/tag.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface ITag { - _id: Types.ObjectId; - name: string; - tagColor: string; - slug: string; - user: Types.ObjectId; - workspace: Types.ObjectId; -} - -const tagSchema = new Schema( - { - name: { - type: String, - required: true, - trim: true, - }, - tagColor: { - type: String, - required: false, - trim: true, - }, - slug: { - type: String, - required: true, - trim: true, - lowercase: true, - validate: [ - function (value: any) { - return value.indexOf(" ") === -1; - }, - "slug cannot contain spaces", - ], - }, - user: { - type: Schema.Types.ObjectId, - ref: "User", - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - }, - }, - { - timestamps: true, - } -); - -tagSchema.index({ slug: 1, workspace: 1 }, { unique: true }) -tagSchema.index({ workspace: 1 }) - -export const Tag = model("Tag", tagSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/token.ts b/pg-migrator/src/models/token.ts deleted file mode 100644 index 62d342b0a..000000000 --- a/pg-migrator/src/models/token.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Schema, model } from "mongoose"; - -export interface IToken { - email: string; - token: string; - createdAt: Date; - ttl: number; -} - -const tokenSchema = new Schema({ - email: { - type: String, - required: true, - }, - token: { - type: String, - required: true, - }, - createdAt: { - type: Date, - default: Date.now, - }, - ttl: { - type: Number, - }, -}); - -tokenSchema.index({ email: 1 }); - -export const Token = model("Token", tokenSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/tokenData.ts b/pg-migrator/src/models/tokenData.ts deleted file mode 100644 index 80c6016c1..000000000 --- a/pg-migrator/src/models/tokenData.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface ITokenData { - type: string; - email?: string; - phoneNumber?: string; - organization?: Types.ObjectId; - tokenHash: string; - triesLeft?: number; - expiresAt: Date; - createdAt: Date; - updatedAt: Date; -} - -const tokenDataSchema = new Schema({ - type: { - type: String, - enum: [ - "emailConfirmation", - "emailMfa", - "organizationInvitation", - "passwordReset", - ], - required: true, - }, - email: { - type: String, - }, - phoneNumber: { - type: String, - }, - organization: { // organizationInvitation-specific field - type: Schema.Types.ObjectId, - ref: "Organization", - }, - tokenHash: { - type: String, - - required: true, - }, - triesLeft: { - type: Number, - }, - expiresAt: { - type: Date, - expires: 0, - required: true, - }, -}, { - timestamps: true, -}); - -export const TokenData = model("TokenData", tokenDataSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/tokenVersion.ts b/pg-migrator/src/models/tokenVersion.ts deleted file mode 100644 index b162e019e..000000000 --- a/pg-migrator/src/models/tokenVersion.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface ITokenVersion extends Document { - user: Types.ObjectId; - ip: string; - userAgent: string; - refreshVersion: number; - accessVersion: number; - lastUsed: Date; -} - -const tokenVersionSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - ip: { - type: String, - required: true, - }, - userAgent: { - type: String, - required: true, - }, - refreshVersion: { - type: Number, - required: true, - }, - accessVersion: { - type: Number, - required: true, - }, - lastUsed: { - type: Date, - required: true, - }, - }, - { - timestamps: true, - } -); - -export const TokenVersion = model("TokenVersion", tokenVersionSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/trustedIp.ts b/pg-migrator/src/models/trustedIp.ts deleted file mode 100644 index 85616be11..000000000 --- a/pg-migrator/src/models/trustedIp.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export enum IPType { - IPV4 = "ipv4", - IPV6 = "ipv6" -} - -export interface ITrustedIP { - _id: Types.ObjectId; - workspace: Types.ObjectId; - ipAddress: string; - type: "ipv4" | "ipv6", // either IPv4/IPv6 address or network IPv4/IPv6 address - isActive: boolean; - comment: string; - prefix?: number; // CIDR -} - -const trustedIpSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true - }, - ipAddress: { - type: String, - required: true - }, - type: { - type: String, - enum: [ - IPType.IPV4, - IPType.IPV6 - ], - required: true - }, - prefix: { - type: Number, - required: false - }, - isActive: { - type: Boolean, - required: true - }, - comment: { - type: String - } - }, - { - timestamps: true - } -); - -export const TrustedIP = model("TrustedIP", trustedIpSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/user.ts b/pg-migrator/src/models/user.ts deleted file mode 100644 index 08ce2bb46..000000000 --- a/pg-migrator/src/models/user.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export enum AuthMethod { - EMAIL = "email", - GOOGLE = "google", - GITHUB = "github", - GITLAB = "gitlab", - OKTA_SAML = "okta-saml", - AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml", -} - -export interface IUser extends Document { - _id: Types.ObjectId; - authProvider?: AuthMethod; - authMethods: AuthMethod[]; - email: string; - superAdmin?: boolean; - firstName?: string; - lastName?: string; - encryptionVersion: number; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey?: string; - encryptedPrivateKey?: string; - iv?: string; - tag?: string; - salt?: string; - verifier?: string; - isMfaEnabled: boolean; - mfaMethods: boolean; - devices: { - ip: string; - userAgent: string; - }[]; - createdAt: string; - updatedAt: string; -} - -const userSchema = new Schema( - { - authProvider: { - // TODO field: deprecate - type: String, - enum: AuthMethod, - }, - authMethods: { - type: [ - { - type: String, - enum: AuthMethod, - }, - ], - default: [AuthMethod.EMAIL], - required: true, - }, - email: { - type: String, - required: true, - unique: true, - }, - firstName: { - type: String, - }, - lastName: { - type: String, - }, - encryptionVersion: { - type: Number, - - default: 1, // to resolve backward-compatibility issues - }, - protectedKey: { - // introduced as part of encryption version 2 - type: String, - - }, - protectedKeyIV: { - // introduced as part of encryption version 2 - type: String, - - }, - protectedKeyTag: { - // introduced as part of encryption version 2 - type: String, - - }, - publicKey: { - type: String, - - }, - encryptedPrivateKey: { - type: String, - - }, - superAdmin: { - type: Boolean, - }, - iv: { - // iv of [encryptedPrivateKey] - type: String, - - }, - tag: { - // tag of [encryptedPrivateKey] - type: String, - - }, - salt: { - type: String, - - }, - verifier: { - type: String, - - }, - isMfaEnabled: { - type: Boolean, - default: false, - }, - mfaMethods: [ - { - type: String, - }, - ], - devices: { - type: [ - { - ip: String, - userAgent: String, - }, - ], - default: [], - - }, - }, - { - timestamps: true, - }, -); - -export const User = model("User", userSchema); diff --git a/pg-migrator/src/models/userAction.ts b/pg-migrator/src/models/userAction.ts deleted file mode 100644 index 68fae22be..000000000 --- a/pg-migrator/src/models/userAction.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IUserAction { - _id: Types.ObjectId; - user: Types.ObjectId; - action: string; -} - -const userActionSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - required: true, - }, - action: { - type: String, - required: true, - }, - }, - { - timestamps: true, - } -); - -export const UserAction = model("UserAction", userActionSchema); \ No newline at end of file diff --git a/pg-migrator/src/models/webhooks.ts b/pg-migrator/src/models/webhooks.ts deleted file mode 100644 index db3eadf1e..000000000 --- a/pg-migrator/src/models/webhooks.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Document, Schema, Types, model } from "mongoose"; - -export interface IWebhook extends Document { - _id: Types.ObjectId; - workspace: Types.ObjectId; - environment: string; - secretPath: string; - url: string; - lastStatus: "success" | "failed"; - lastRunErrorMessage?: string; - isDisabled: boolean; - encryptedSecretKey: string; - iv: string; - tag: string; - algorithm: "aes-256-gcm"; - keyEncoding: "base64" | "utf8"; -} - -const WebhookSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - environment: { - type: String, - required: true, - }, - secretPath: { - type: String, - required: true, - default: "/", - }, - url: { - type: String, - required: true, - }, - lastStatus: { - type: String, - enum: ["success", "failed"], - }, - lastRunErrorMessage: { - type: String, - }, - isDisabled: { - type: Boolean, - default: false, - }, - // used for webhook signature - encryptedSecretKey: { - type: String, - - }, - iv: { - type: String, - - }, - tag: { - type: String, - - }, - algorithm: { - // the encryption algorithm used - type: String, - - }, - keyEncoding: { - type: String, - - }, - }, - { - timestamps: true, - }, -); - -export const Webhook = model("Webhook", WebhookSchema); diff --git a/pg-migrator/src/models/workspace.ts b/pg-migrator/src/models/workspace.ts deleted file mode 100644 index 9d7a19fcc..000000000 --- a/pg-migrator/src/models/workspace.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Schema, Types, model } from "mongoose"; - -export interface IWorkspace { - _id: Types.ObjectId; - name: string; - organization: Types.ObjectId; - environments: Array<{ - name: string; - slug: string; - }>; - autoCapitalization: boolean; -} - -const workspaceSchema = new Schema({ - name: { - type: String, - required: true, - }, - autoCapitalization: { - type: Boolean, - default: true, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - required: true, - }, - environments: { - type: [ - { - name: String, - slug: String, - }, - ], - default: [ - { - name: "Development", - slug: "dev", - }, - { - name: "Staging", - slug: "staging", - }, - { - name: "Production", - slug: "prod", - }, - ], - }, -}); - -export const Workspace = model("Workspace", workspaceSchema); \ No newline at end of file diff --git a/pg-migrator/src/rollback.ts b/pg-migrator/src/rollback.ts deleted file mode 100644 index 8d12bbddc..000000000 --- a/pg-migrator/src/rollback.ts +++ /dev/null @@ -1,29 +0,0 @@ -import dotenv from "dotenv"; -import knex from "knex"; -import { Level } from "level"; -import path from "path"; - -const main = async () => { - dotenv.config(); - let postgres_url = process.env.POSTGRES_DB_URL; - - console.log("Checking postgres connection..."); - const db = knex({ - client: "pg", - connection: postgres_url, - migrations: { - directory: path.join(__dirname, "./migrations"), - extension: "ts", - tableName: "infisical_migrations", - }, - }); - console.log("Good to go with postgres"); - const kdb = new Level("./db", { valueEncoding: "json" }); - console.log("Starting rolling back to latest"); - await db.migrate.rollback({}, true); - console.log("Rolling back completed"); - kdb.clear(); - process.exit(0); -}; - -main(); diff --git a/pg-migrator/src/schemas/api-keys.ts b/pg-migrator/src/schemas/api-keys.ts deleted file mode 100644 index c3f384a5a..000000000 --- a/pg-migrator/src/schemas/api-keys.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ApiKeysSchema = z.object({ - id: z.string(), - name: z.string(), - lastUsed: z.date().nullable().optional(), - expiresAt: z.date().nullable().optional(), - secretHash: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - userId: z.string().uuid(), -}); - -export type TApiKeys = z.infer; -export type TApiKeysInsert = Omit; -export type TApiKeysUpdate = Partial>; diff --git a/pg-migrator/src/schemas/audit-logs.ts b/pg-migrator/src/schemas/audit-logs.ts deleted file mode 100644 index 90c389b94..000000000 --- a/pg-migrator/src/schemas/audit-logs.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const AuditLogsSchema = z.object({ - id: z.string().uuid(), - actor: z.string(), - actorMetadata: z.unknown(), - ipAddress: z.string().nullable().optional(), - eventType: z.string(), - eventMetadata: z.unknown().nullable().optional(), - userAgent: z.string().nullable().optional(), - userAgentType: z.string().nullable().optional(), - expiresAt: z.date().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - orgId: z.string().uuid().nullable().optional(), - projectId: z.string().nullable().optional(), -}); - -export type TAuditLogs = z.infer; -export type TAuditLogsInsert = Omit; -export type TAuditLogsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/auth-token-sessions.ts b/pg-migrator/src/schemas/auth-token-sessions.ts deleted file mode 100644 index 9dbf8e09f..000000000 --- a/pg-migrator/src/schemas/auth-token-sessions.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const AuthTokenSessionsSchema = z.object({ - id: z.string().uuid(), - ip: z.string(), - userAgent: z.string().nullable().optional(), - refreshVersion: z.number().default(1), - accessVersion: z.number().default(1), - lastUsed: z.date(), - createdAt: z.date(), - updatedAt: z.date(), - userId: z.string().uuid(), -}); - -export type TAuthTokenSessions = z.infer; -export type TAuthTokenSessionsInsert = Omit; -export type TAuthTokenSessionsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/auth-tokens.ts b/pg-migrator/src/schemas/auth-tokens.ts deleted file mode 100644 index 4a612b11d..000000000 --- a/pg-migrator/src/schemas/auth-tokens.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const AuthTokensSchema = z.object({ - id: z.string().uuid(), - type: z.string(), - phoneNumber: z.string().nullable().optional(), - tokenHash: z.string(), - triesLeft: z.number().nullable().optional(), - expiresAt: z.date(), - createdAt: z.date(), - updatedAt: z.date(), - userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid().nullable().optional(), -}); - -export type TAuthTokens = z.infer; -export type TAuthTokensInsert = Omit; -export type TAuthTokensUpdate = Partial>; diff --git a/pg-migrator/src/schemas/backup-private-key.ts b/pg-migrator/src/schemas/backup-private-key.ts deleted file mode 100644 index 9b6e787b1..000000000 --- a/pg-migrator/src/schemas/backup-private-key.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const BackupPrivateKeySchema = z.object({ - id: z.string().uuid(), - encryptedPrivateKey: z.string(), - iv: z.string(), - tag: z.string(), - algorithm: z.string(), - keyEncoding: z.string(), - salt: z.string(), - verifier: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - userId: z.string().uuid(), -}); - -export type TBackupPrivateKey = z.infer; -export type TBackupPrivateKeyInsert = Omit; -export type TBackupPrivateKeyUpdate = Partial>; diff --git a/pg-migrator/src/schemas/git-app-install-sessions.ts b/pg-migrator/src/schemas/git-app-install-sessions.ts deleted file mode 100644 index 16f62eab7..000000000 --- a/pg-migrator/src/schemas/git-app-install-sessions.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const GitAppInstallSessionsSchema = z.object({ - id: z.string().uuid(), - sessionId: z.string(), - userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TGitAppInstallSessions = z.infer; -export type TGitAppInstallSessionsInsert = Omit; -export type TGitAppInstallSessionsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/git-app-org.ts b/pg-migrator/src/schemas/git-app-org.ts deleted file mode 100644 index f5226811d..000000000 --- a/pg-migrator/src/schemas/git-app-org.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const GitAppOrgSchema = z.object({ - id: z.string().uuid(), - installationId: z.string(), - userId: z.string().uuid(), - orgId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TGitAppOrg = z.infer; -export type TGitAppOrgInsert = Omit; -export type TGitAppOrgUpdate = Partial>; diff --git a/pg-migrator/src/schemas/identities.ts b/pg-migrator/src/schemas/identities.ts deleted file mode 100644 index b8ff6c36f..000000000 --- a/pg-migrator/src/schemas/identities.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IdentitiesSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - authMethod: z.string().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TIdentities = z.infer; -export type TIdentitiesInsert = Omit; -export type TIdentitiesUpdate = Partial>; diff --git a/pg-migrator/src/schemas/identity-access-tokens.ts b/pg-migrator/src/schemas/identity-access-tokens.ts deleted file mode 100644 index aa86e0356..000000000 --- a/pg-migrator/src/schemas/identity-access-tokens.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IdentityAccessTokensSchema = z.object({ - id: z.string().uuid(), - accessTokenTTL: z.number().default(2592000), - accessTokenMaxTTL: z.number().default(2592000), - accessTokenNumUses: z.number().default(0), - accessTokenNumUsesLimit: z.number().default(0), - accessTokenLastUsedAt: z.date().nullable().optional(), - accessTokenLastRenewedAt: z.date().nullable().optional(), - isAccessTokenRevoked: z.boolean().default(false), - identityUAClientSecretId: z.string().nullable().optional(), - identityId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TIdentityAccessTokens = z.infer; -export type TIdentityAccessTokensInsert = Omit< - TIdentityAccessTokens, - TImmutableDBKeys ->; -export type TIdentityAccessTokensUpdate = Partial< - Omit ->; diff --git a/pg-migrator/src/schemas/identity-org-memberships.ts b/pg-migrator/src/schemas/identity-org-memberships.ts deleted file mode 100644 index c5c9a0f61..000000000 --- a/pg-migrator/src/schemas/identity-org-memberships.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IdentityOrgMembershipsSchema = z.object({ - id: z.string().uuid(), - role: z.string(), - roleId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), - identityId: z.string().uuid(), -}); - -export type TIdentityOrgMemberships = z.infer; -export type TIdentityOrgMembershipsInsert = Omit; -export type TIdentityOrgMembershipsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/identity-project-memberships.ts b/pg-migrator/src/schemas/identity-project-memberships.ts deleted file mode 100644 index 9a57952a4..000000000 --- a/pg-migrator/src/schemas/identity-project-memberships.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IdentityProjectMembershipsSchema = z.object({ - id: z.string().uuid(), - role: z.string(), - roleId: z.string().uuid().nullable().optional(), - projectId: z.string(), - identityId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TIdentityProjectMemberships = z.infer; -export type TIdentityProjectMembershipsInsert = Omit; -export type TIdentityProjectMembershipsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/identity-ua-client-secrets.ts b/pg-migrator/src/schemas/identity-ua-client-secrets.ts deleted file mode 100644 index a7b7cb9a8..000000000 --- a/pg-migrator/src/schemas/identity-ua-client-secrets.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IdentityUaClientSecretsSchema = z.object({ - id: z.string(), - description: z.string(), - clientSecretPrefix: z.string(), - clientSecretHash: z.string(), - clientSecretLastUsedAt: z.date().nullable().optional(), - clientSecretNumUses: z.number().default(0), - clientSecretNumUsesLimit: z.number().default(0), - clientSecretTTL: z.number().default(0), - isClientSecretRevoked: z.boolean().default(false), - createdAt: z.date(), - updatedAt: z.date(), - identityUAId: z.string().uuid(), -}); - -export type TIdentityUaClientSecrets = z.infer< - typeof IdentityUaClientSecretsSchema ->; -export type TIdentityUaClientSecretsInsert = Omit< - TIdentityUaClientSecrets, - TImmutableDBKeys ->; -export type TIdentityUaClientSecretsUpdate = Partial< - Omit ->; diff --git a/pg-migrator/src/schemas/identity-universal-auths.ts b/pg-migrator/src/schemas/identity-universal-auths.ts deleted file mode 100644 index 82b9fb23e..000000000 --- a/pg-migrator/src/schemas/identity-universal-auths.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IdentityUniversalAuthsSchema = z.object({ - id: z.string().uuid(), - clientId: z.string(), - accessTokenTTL: z.number().default(7200), - accessTokenMaxTTL: z.number().default(7200), - accessTokenNumUsesLimit: z.number().default(0), - clientSecretTrustedIps: z.unknown(), - accessTokenTrustedIps: z.unknown(), - createdAt: z.date(), - updatedAt: z.date(), - identityId: z.string().uuid(), -}); - -export type TIdentityUniversalAuths = z.infer; -export type TIdentityUniversalAuthsInsert = Omit; -export type TIdentityUniversalAuthsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/incident-contacts.ts b/pg-migrator/src/schemas/incident-contacts.ts deleted file mode 100644 index c1492e0fa..000000000 --- a/pg-migrator/src/schemas/incident-contacts.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IncidentContactsSchema = z.object({ - id: z.string().uuid(), - email: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - orgId: z.string().uuid(), -}); - -export type TIncidentContacts = z.infer; -export type TIncidentContactsInsert = Omit; -export type TIncidentContactsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/index.ts b/pg-migrator/src/schemas/index.ts deleted file mode 100644 index fe02658f9..000000000 --- a/pg-migrator/src/schemas/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -export * from "./api-keys"; -export * from "./audit-logs"; -export * from "./auth-token-sessions"; -export * from "./auth-tokens"; -export * from "./backup-private-key"; -export * from "./git-app-install-sessions"; -export * from "./git-app-org"; -export * from "./identities"; -export * from "./identity-access-tokens"; -export * from "./identity-org-memberships"; -export * from "./identity-project-memberships"; -export * from "./identity-ua-client-secrets"; -export * from "./identity-universal-auths"; -export * from "./incident-contacts"; -export * from "./integration-auths"; -export * from "./integrations"; -export * from "./models"; -export * from "./org-bots"; -export * from "./org-memberships"; -export * from "./org-roles"; -export * from "./organizations"; -export * from "./project-bots"; -export * from "./project-environments"; -export * from "./project-keys"; -export * from "./project-memberships"; -export * from "./project-roles"; -export * from "./projects"; -export * from "./secret-approval-request-secret-tags"; -export * from "./secret-approval-requests-secrets"; -export * from "./saml-configs"; -export * from "./secret-approval-policies-approvers"; -export * from "./secret-approval-requests-reviewers"; -export * from "./secret-approval-policies"; -export * from "./secret-approval-requests"; -export * from "./secret-blind-indexes"; -export * from "./secret-folder-versions"; -export * from "./secret-folders"; -export * from "./secret-imports"; -export * from "./secret-rotation-outputs"; -export * from "./secret-rotations"; -export * from "./secret-scanning-git-risks"; -export * from "./secret-snapshot-folders"; -export * from "./secret-snapshot-secrets"; -export * from "./secret-snapshots"; -export * from "./secret-tag-junction"; -export * from "./secret-tags"; -export * from "./secret-versions"; -export * from "./secrets"; -export * from "./service-tokens"; -export * from "./super-admin"; -export * from "./trusted-ips"; -export * from "./user-actions"; -export * from "./user-encryption-keys"; -export * from "./users"; -export * from "./webhooks"; -export * from "./secret-version-tag-junction"; diff --git a/pg-migrator/src/schemas/integration-auths.ts b/pg-migrator/src/schemas/integration-auths.ts deleted file mode 100644 index d2983658c..000000000 --- a/pg-migrator/src/schemas/integration-auths.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IntegrationAuthsSchema = z.object({ - id: z.string().uuid(), - integration: z.string(), - teamId: z.string().nullable().optional(), - url: z.string().nullable().optional(), - namespace: z.string().nullable().optional(), - accountId: z.string().nullable().optional(), - refreshCiphertext: z.string().nullable().optional(), - refreshIV: z.string().nullable().optional(), - refreshTag: z.string().nullable().optional(), - accessIdCiphertext: z.string().nullable().optional(), - accessIdIV: z.string().nullable().optional(), - accessIdTag: z.string().nullable().optional(), - accessCiphertext: z.string().nullable().optional(), - accessIV: z.string().nullable().optional(), - accessTag: z.string().nullable().optional(), - accessExpiresAt: z.date().nullable().optional(), - metadata: z.unknown().nullable().optional(), - algorithm: z.string(), - keyEncoding: z.string(), - projectId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TIntegrationAuths = z.infer; -export type TIntegrationAuthsInsert = Omit; -export type TIntegrationAuthsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/integrations.ts b/pg-migrator/src/schemas/integrations.ts deleted file mode 100644 index b2163dc4d..000000000 --- a/pg-migrator/src/schemas/integrations.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const IntegrationsSchema = z.object({ - id: z.string().uuid(), - isActive: z.boolean(), - url: z.string().nullable().optional(), - app: z.string().nullable().optional(), - appId: z.string().nullable().optional(), - targetEnvironment: z.string().nullable().optional(), - targetEnvironmentId: z.string().nullable().optional(), - targetService: z.string().nullable().optional(), - targetServiceId: z.string().nullable().optional(), - owner: z.string().nullable().optional(), - path: z.string().nullable().optional(), - region: z.string().nullable().optional(), - scope: z.string().nullable().optional(), - integration: z.string(), - metadata: z.unknown().nullable().optional(), - integrationAuthId: z.string().uuid(), - envId: z.string().uuid(), - secretPath: z.string().default('/'), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TIntegrations = z.infer; -export type TIntegrationsInsert = Omit; -export type TIntegrationsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/models.ts b/pg-migrator/src/schemas/models.ts deleted file mode 100644 index eba69f621..000000000 --- a/pg-migrator/src/schemas/models.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { z } from "zod"; - -export enum TableName { - Users = "users", - UserEncryptionKey = "user_encryption_keys", - AuthTokens = "auth_tokens", - AuthTokenSession = "auth_token_sessions", - BackupPrivateKey = "backup_private_key", - Organization = "organizations", - OrgMembership = "org_memberships", - OrgRoles = "org_roles", - OrgBot = "org_bots", - IncidentContact = "incident_contacts", - UserAction = "user_actions", - SuperAdmin = "super_admin", - ApiKey = "api_keys", - Project = "projects", - ProjectBot = "project_bots", - Environment = "project_environments", - ProjectMembership = "project_memberships", - ProjectRoles = "project_roles", - ProjectKeys = "project_keys", - Secret = "secrets", - SecretBlindIndex = "secret_blind_indexes", - SecretVersion = "secret_versions", - SecretFolder = "secret_folders", - SecretFolderVersion = "secret_folder_versions", - SecretImport = "secret_imports", - Snapshot = "secret_snapshots", - SnapshotSecret = "secret_snapshot_secrets", - SnapshotFolder = "secret_snapshot_folders", - SecretTag = "secret_tags", - Integration = "integrations", - IntegrationAuth = "integration_auths", - ServiceToken = "service_tokens", - Webhook = "webhooks", - Identity = "identities", - IdentityAccessToken = "identity_access_tokens", - IdentityUniversalAuth = "identity_universal_auths", - IdentityUaClientSecret = "identity_ua_client_secrets", - IdentityOrgMembership = "identity_org_memberships", - IdentityProjectMembership = "identity_project_memberships", - SecretApprovalPolicy = "secret_approval_policies", - SecretApprovalPolicyApprover = "secret_approval_policies_approvers", - SecretApprovalRequest = "secret_approval_requests", - SecretApprovalRequestReviewer = "secret_approval_requests_reviewers", - SecretApprovalRequestSecret = "secret_approval_requests_secrets", - SecretApprovalRequestSecretTag = "secret_approval_request_secret_tags", - SecretRotation = "secret_rotations", - SecretRotationOutput = "secret_rotation_outputs", - SamlConfig = "saml_configs", - AuditLog = "audit_logs", - GitAppInstallSession = "git_app_install_sessions", - GitAppOrg = "git_app_org", - SecretScanningGitRisk = "secret_scanning_git_risks", - TrustedIps = "trusted_ips", - // junction tables with tags - JnSecretTag = "secret_tag_junction", - SecretVersionTag = "secret_version_tag_junction", -} - -export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; - -export const UserDeviceSchema = z - .object({ - ip: z.string(), - userAgent: z.string(), - }) - .array() - .default([]); - -export const ServiceTokenScopes = z - .object({ - environment: z.string(), - secretPath: z.string().default("/"), - }) - .array(); - -export enum OrgMembershipRole { - Admin = "admin", - Member = "member", - NoAccess = "no-access", - Custom = "custom", -} - -export enum OrgMembershipStatus { - Invited = "invited", - Accepted = "accepted", -} - -export enum ProjectMembershipRole { - Admin = "admin", - Member = "member", - Custom = "custom", - Viewer = "viewer", - NoAccess = "no-access", -} - -export enum SecretEncryptionAlgo { - AES_256_GCM = "aes-256-gcm", -} - -export enum SecretKeyEncoding { - UTF8 = "utf8", - BASE64 = "base64", - HEX = "hex", -} - -export enum SecretType { - Shared = "shared", - Personal = "personal", -} - -export enum IdentityAuthMethod { - Univeral = "universal", -} diff --git a/pg-migrator/src/schemas/org-bots.ts b/pg-migrator/src/schemas/org-bots.ts deleted file mode 100644 index 400ab19c8..000000000 --- a/pg-migrator/src/schemas/org-bots.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const OrgBotsSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - publicKey: z.string(), - encryptedSymmetricKey: z.string(), - symmetricKeyIV: z.string(), - symmetricKeyTag: z.string(), - symmetricKeyAlgorithm: z.string(), - symmetricKeyKeyEncoding: z.string(), - encryptedPrivateKey: z.string(), - privateKeyIV: z.string(), - privateKeyTag: z.string(), - privateKeyAlgorithm: z.string(), - privateKeyKeyEncoding: z.string(), - orgId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TOrgBots = z.infer; -export type TOrgBotsInsert = Omit; -export type TOrgBotsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/org-memberships.ts b/pg-migrator/src/schemas/org-memberships.ts deleted file mode 100644 index 932c84d00..000000000 --- a/pg-migrator/src/schemas/org-memberships.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const OrgMembershipsSchema = z.object({ - id: z.string().uuid(), - role: z.string(), - status: z.string().default('invited'), - inviteEmail: z.string().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid(), - roleId: z.string().uuid().nullable().optional(), -}); - -export type TOrgMemberships = z.infer; -export type TOrgMembershipsInsert = Omit; -export type TOrgMembershipsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/org-roles.ts b/pg-migrator/src/schemas/org-roles.ts deleted file mode 100644 index 9718cdb26..000000000 --- a/pg-migrator/src/schemas/org-roles.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const OrgRolesSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - description: z.string().nullable().optional(), - slug: z.string(), - permissions: z.unknown(), - createdAt: z.date(), - updatedAt: z.date(), - orgId: z.string().uuid(), -}); - -export type TOrgRoles = z.infer; -export type TOrgRolesInsert = Omit; -export type TOrgRolesUpdate = Partial>; diff --git a/pg-migrator/src/schemas/organizations.ts b/pg-migrator/src/schemas/organizations.ts deleted file mode 100644 index 0aa214a00..000000000 --- a/pg-migrator/src/schemas/organizations.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const OrganizationsSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - slug: z.string(), - customerId: z.string().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TOrganizations = z.infer; -export type TOrganizationsInsert = Omit; -export type TOrganizationsUpdate = Partial< - Omit ->; diff --git a/pg-migrator/src/schemas/project-bots.ts b/pg-migrator/src/schemas/project-bots.ts deleted file mode 100644 index 90ced9b3e..000000000 --- a/pg-migrator/src/schemas/project-bots.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ProjectBotsSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - isActive: z.boolean().default(false), - encryptedPrivateKey: z.string(), - publicKey: z.string(), - iv: z.string(), - tag: z.string(), - algorithm: z.string(), - keyEncoding: z.string(), - encryptedProjectKey: z.string().nullable().optional(), - encryptedProjectKeyNonce: z.string().nullable().optional(), - projectId: z.string(), - senderId: z.string().uuid().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TProjectBots = z.infer; -export type TProjectBotsInsert = Omit; -export type TProjectBotsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/project-environments.ts b/pg-migrator/src/schemas/project-environments.ts deleted file mode 100644 index aa3e392c7..000000000 --- a/pg-migrator/src/schemas/project-environments.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ProjectEnvironmentsSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - slug: z.string(), - position: z.number(), - projectId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TProjectEnvironments = z.infer; -export type TProjectEnvironmentsInsert = Omit; -export type TProjectEnvironmentsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/project-keys.ts b/pg-migrator/src/schemas/project-keys.ts deleted file mode 100644 index 64e33d574..000000000 --- a/pg-migrator/src/schemas/project-keys.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ProjectKeysSchema = z.object({ - id: z.string().uuid(), - encryptedKey: z.string(), - nonce: z.string(), - receiverId: z.string().uuid(), - senderId: z.string().uuid().nullable().optional(), - projectId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TProjectKeys = z.infer; -export type TProjectKeysInsert = Omit; -export type TProjectKeysUpdate = Partial>; diff --git a/pg-migrator/src/schemas/project-memberships.ts b/pg-migrator/src/schemas/project-memberships.ts deleted file mode 100644 index c98befb38..000000000 --- a/pg-migrator/src/schemas/project-memberships.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ProjectMembershipsSchema = z.object({ - id: z.string().uuid(), - role: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - userId: z.string().uuid(), - projectId: z.string(), - roleId: z.string().uuid().nullable().optional(), -}); - -export type TProjectMemberships = z.infer; -export type TProjectMembershipsInsert = Omit; -export type TProjectMembershipsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/project-roles.ts b/pg-migrator/src/schemas/project-roles.ts deleted file mode 100644 index 190dd1cec..000000000 --- a/pg-migrator/src/schemas/project-roles.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ProjectRolesSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - description: z.string().nullable().optional(), - slug: z.string(), - permissions: z.unknown(), - createdAt: z.date(), - updatedAt: z.date(), - projectId: z.string(), -}); - -export type TProjectRoles = z.infer; -export type TProjectRolesInsert = Omit; -export type TProjectRolesUpdate = Partial>; diff --git a/pg-migrator/src/schemas/projects.ts b/pg-migrator/src/schemas/projects.ts deleted file mode 100644 index 005e4bbde..000000000 --- a/pg-migrator/src/schemas/projects.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ProjectsSchema = z.object({ - id: z.string(), - name: z.string(), - slug: z.string(), - autoCapitalization: z.boolean().default(true).nullable().optional(), - orgId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TProjects = z.infer; -export type TProjectsInsert = Omit; -export type TProjectsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/saml-configs.ts b/pg-migrator/src/schemas/saml-configs.ts deleted file mode 100644 index 392fb2bef..000000000 --- a/pg-migrator/src/schemas/saml-configs.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SamlConfigsSchema = z.object({ - id: z.string().uuid(), - authProvider: z.string(), - isActive: z.boolean(), - encryptedEntryPoint: z.string().nullable().optional(), - entryPointIV: z.string().nullable().optional(), - entryPointTag: z.string().nullable().optional(), - encryptedIssuer: z.string().nullable().optional(), - issuerTag: z.string().nullable().optional(), - issuerIV: z.string().nullable().optional(), - encryptedCert: z.string().nullable().optional(), - certIV: z.string().nullable().optional(), - certTag: z.string().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - orgId: z.string().uuid(), -}); - -export type TSamlConfigs = z.infer; -export type TSamlConfigsInsert = Omit; -export type TSamlConfigsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-approval-policies-approvers.ts b/pg-migrator/src/schemas/secret-approval-policies-approvers.ts deleted file mode 100644 index c6fb75f06..000000000 --- a/pg-migrator/src/schemas/secret-approval-policies-approvers.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretApprovalPoliciesApproversSchema = z.object({ - id: z.string().uuid(), - approverId: z.string().uuid(), - policyId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretApprovalPoliciesApprovers = z.infer; -export type TSecretApprovalPoliciesApproversInsert = Omit; -export type TSecretApprovalPoliciesApproversUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-approval-policies.ts b/pg-migrator/src/schemas/secret-approval-policies.ts deleted file mode 100644 index ec859bb4e..000000000 --- a/pg-migrator/src/schemas/secret-approval-policies.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretApprovalPoliciesSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - secretPath: z.string().nullable().optional(), - approvals: z.number().default(1), - envId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretApprovalPolicies = z.infer; -export type TSecretApprovalPoliciesInsert = Omit; -export type TSecretApprovalPoliciesUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-approval-request-secret-tags.ts b/pg-migrator/src/schemas/secret-approval-request-secret-tags.ts deleted file mode 100644 index 47e11e6a9..000000000 --- a/pg-migrator/src/schemas/secret-approval-request-secret-tags.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretApprovalRequestSecretTagsSchema = z.object({ - id: z.string().uuid(), - secretId: z.string().uuid(), - tagId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretApprovalRequestSecretTags = z.infer; -export type TSecretApprovalRequestSecretTagsInsert = Omit; -export type TSecretApprovalRequestSecretTagsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-approval-requests-reviewers.ts b/pg-migrator/src/schemas/secret-approval-requests-reviewers.ts deleted file mode 100644 index 4ed9b50a1..000000000 --- a/pg-migrator/src/schemas/secret-approval-requests-reviewers.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretApprovalRequestsReviewersSchema = z.object({ - id: z.string().uuid(), - member: z.string().uuid(), - status: z.string(), - requestId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretApprovalRequestsReviewers = z.infer; -export type TSecretApprovalRequestsReviewersInsert = Omit; -export type TSecretApprovalRequestsReviewersUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-approval-requests-secrets.ts b/pg-migrator/src/schemas/secret-approval-requests-secrets.ts deleted file mode 100644 index 2fe6c6692..000000000 --- a/pg-migrator/src/schemas/secret-approval-requests-secrets.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretApprovalRequestsSecretsSchema = z.object({ - id: z.string().uuid(), - version: z.number().default(1).nullable().optional(), - secretBlindIndex: z.string(), - secretKeyCiphertext: z.string(), - secretKeyIV: z.string(), - secretKeyTag: z.string(), - secretValueCiphertext: z.string(), - secretValueIV: z.string(), - secretValueTag: z.string(), - secretCommentCiphertext: z.string().nullable().optional(), - secretCommentIV: z.string().nullable().optional(), - secretCommentTag: z.string().nullable().optional(), - secretReminderNote: z.string().nullable().optional(), - secretReminderRepeatDays: z.number().nullable().optional(), - skipMultilineEncoding: z.boolean().default(false).nullable().optional(), - algorithm: z.string().default('aes-256-gcm'), - keyEncoding: z.string().default('utf8'), - metadata: z.unknown().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - requestId: z.string().uuid(), - op: z.string(), - secretId: z.string().uuid().nullable().optional(), - secretVersion: z.string().uuid().nullable().optional(), -}); - -export type TSecretApprovalRequestsSecrets = z.infer; -export type TSecretApprovalRequestsSecretsInsert = Omit; -export type TSecretApprovalRequestsSecretsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-approval-requests.ts b/pg-migrator/src/schemas/secret-approval-requests.ts deleted file mode 100644 index 7d5f2f443..000000000 --- a/pg-migrator/src/schemas/secret-approval-requests.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretApprovalRequestsSchema = z.object({ - id: z.string().uuid(), - policyId: z.string().uuid(), - hasMerged: z.boolean().default(false), - status: z.string().default('open'), - conflicts: z.unknown().nullable().optional(), - slug: z.string(), - folderId: z.string().uuid(), - statusChangeBy: z.string().uuid().nullable().optional(), - committerId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretApprovalRequests = z.infer; -export type TSecretApprovalRequestsInsert = Omit; -export type TSecretApprovalRequestsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-blind-indexes.ts b/pg-migrator/src/schemas/secret-blind-indexes.ts deleted file mode 100644 index 17eacb473..000000000 --- a/pg-migrator/src/schemas/secret-blind-indexes.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretBlindIndexesSchema = z.object({ - id: z.string().uuid(), - encryptedSaltCipherText: z.string(), - saltIV: z.string(), - saltTag: z.string(), - algorithm: z.string().default('aes-256-gcm'), - keyEncoding: z.string().default('utf8'), - projectId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretBlindIndexes = z.infer; -export type TSecretBlindIndexesInsert = Omit; -export type TSecretBlindIndexesUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-folder-versions.ts b/pg-migrator/src/schemas/secret-folder-versions.ts deleted file mode 100644 index 895f81ebc..000000000 --- a/pg-migrator/src/schemas/secret-folder-versions.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretFolderVersionsSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - version: z.number().default(1).nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - envId: z.string().uuid(), - folderId: z.string().uuid(), -}); - -export type TSecretFolderVersions = z.infer; -export type TSecretFolderVersionsInsert = Omit; -export type TSecretFolderVersionsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-folders.ts b/pg-migrator/src/schemas/secret-folders.ts deleted file mode 100644 index aa437c753..000000000 --- a/pg-migrator/src/schemas/secret-folders.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretFoldersSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - version: z.number().default(1).nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - envId: z.string().uuid(), - parentId: z.string().uuid().nullable().optional(), -}); - -export type TSecretFolders = z.infer; -export type TSecretFoldersInsert = Omit; -export type TSecretFoldersUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-imports.ts b/pg-migrator/src/schemas/secret-imports.ts deleted file mode 100644 index 109d41ec2..000000000 --- a/pg-migrator/src/schemas/secret-imports.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretImportsSchema = z.object({ - id: z.string().uuid(), - version: z.number().default(1).nullable().optional(), - importPath: z.string(), - importEnv: z.string().uuid(), - position: z.number(), - createdAt: z.date(), - updatedAt: z.date(), - folderId: z.string().uuid(), -}); - -export type TSecretImports = z.infer; -export type TSecretImportsInsert = Omit; -export type TSecretImportsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-rotation-outputs.ts b/pg-migrator/src/schemas/secret-rotation-outputs.ts deleted file mode 100644 index b98338c75..000000000 --- a/pg-migrator/src/schemas/secret-rotation-outputs.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretRotationOutputsSchema = z.object({ - id: z.string().uuid(), - key: z.string(), - secretId: z.string().uuid(), - rotationId: z.string().uuid(), -}); - -export type TSecretRotationOutputs = z.infer; -export type TSecretRotationOutputsInsert = Omit; -export type TSecretRotationOutputsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-rotations.ts b/pg-migrator/src/schemas/secret-rotations.ts deleted file mode 100644 index 6e2bf6547..000000000 --- a/pg-migrator/src/schemas/secret-rotations.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretRotationsSchema = z.object({ - id: z.string().uuid(), - provider: z.string(), - secretPath: z.string(), - interval: z.number(), - lastRotatedAt: z.date().nullable().optional(), - status: z.string().nullable().optional(), - statusMessage: z.string().nullable().optional(), - encryptedData: z.string().nullable().optional(), - encryptedDataIV: z.string().nullable().optional(), - encryptedDataTag: z.string().nullable().optional(), - algorithm: z.string().nullable().optional(), - keyEncoding: z.string().nullable().optional(), - envId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretRotations = z.infer; -export type TSecretRotationsInsert = Omit; -export type TSecretRotationsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-scanning-git-risks.ts b/pg-migrator/src/schemas/secret-scanning-git-risks.ts deleted file mode 100644 index 0951d3ba5..000000000 --- a/pg-migrator/src/schemas/secret-scanning-git-risks.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretScanningGitRisksSchema = z.object({ - id: z.string().uuid(), - description: z.string().nullable().optional(), - startLine: z.string().nullable().optional(), - endLine: z.string().nullable().optional(), - startColumn: z.string().nullable().optional(), - endColumn: z.string().nullable().optional(), - file: z.string().nullable().optional(), - symlinkFile: z.string().nullable().optional(), - commit: z.string().nullable().optional(), - entropy: z.string().nullable().optional(), - author: z.string().nullable().optional(), - email: z.string().nullable().optional(), - date: z.string().nullable().optional(), - message: z.string().nullable().optional(), - tags: z.string().array().nullable().optional(), - ruleID: z.string().nullable().optional(), - fingerprint: z.string().nullable().optional(), - fingerPrintWithoutCommitId: z.string().nullable().optional(), - isFalsePositive: z.boolean().default(false).nullable().optional(), - isResolved: z.boolean().default(false).nullable().optional(), - riskOwner: z.string().nullable().optional(), - installationId: z.string(), - repositoryId: z.string().nullable().optional(), - repositoryLink: z.string().nullable().optional(), - repositoryFullName: z.string().nullable().optional(), - pusherName: z.string().nullable().optional(), - pusherEmail: z.string().nullable().optional(), - status: z.string().nullable().optional(), - orgId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretScanningGitRisks = z.infer; -export type TSecretScanningGitRisksInsert = Omit; -export type TSecretScanningGitRisksUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-snapshot-folders.ts b/pg-migrator/src/schemas/secret-snapshot-folders.ts deleted file mode 100644 index 5f3b1a88c..000000000 --- a/pg-migrator/src/schemas/secret-snapshot-folders.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretSnapshotFoldersSchema = z.object({ - id: z.string().uuid(), - envId: z.string().uuid(), - folderVersionId: z.string().uuid(), - snapshotId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretSnapshotFolders = z.infer; -export type TSecretSnapshotFoldersInsert = Omit; -export type TSecretSnapshotFoldersUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-snapshot-secrets.ts b/pg-migrator/src/schemas/secret-snapshot-secrets.ts deleted file mode 100644 index f8a69a695..000000000 --- a/pg-migrator/src/schemas/secret-snapshot-secrets.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretSnapshotSecretsSchema = z.object({ - id: z.string().uuid(), - envId: z.string().uuid(), - secretVersionId: z.string().uuid(), - snapshotId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretSnapshotSecrets = z.infer; -export type TSecretSnapshotSecretsInsert = Omit; -export type TSecretSnapshotSecretsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-snapshots.ts b/pg-migrator/src/schemas/secret-snapshots.ts deleted file mode 100644 index ef9e0b7d0..000000000 --- a/pg-migrator/src/schemas/secret-snapshots.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretSnapshotsSchema = z.object({ - id: z.string().uuid(), - envId: z.string().uuid(), - folderId: z.string().uuid(), - parentFolderId: z.string().uuid().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretSnapshots = z.infer; -export type TSecretSnapshotsInsert = Omit; -export type TSecretSnapshotsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-tag-junction.ts b/pg-migrator/src/schemas/secret-tag-junction.ts deleted file mode 100644 index 467ef7b15..000000000 --- a/pg-migrator/src/schemas/secret-tag-junction.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretTagJunctionSchema = z.object({ - id: z.string().uuid(), - secretsId: z.string().uuid(), - secret_tagsId: z.string().uuid(), -}); - -export type TSecretTagJunction = z.infer; -export type TSecretTagJunctionInsert = Omit; -export type TSecretTagJunctionUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-tags.ts b/pg-migrator/src/schemas/secret-tags.ts deleted file mode 100644 index 78f03dedd..000000000 --- a/pg-migrator/src/schemas/secret-tags.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretTagsSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - slug: z.string(), - color: z.string().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - createdBy: z.string().uuid().nullable().optional(), - projectId: z.string(), -}); - -export type TSecretTags = z.infer; -export type TSecretTagsInsert = Omit; -export type TSecretTagsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-version-tag-junction.ts b/pg-migrator/src/schemas/secret-version-tag-junction.ts deleted file mode 100644 index 11b3f0032..000000000 --- a/pg-migrator/src/schemas/secret-version-tag-junction.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretVersionTagJunctionSchema = z.object({ - id: z.string().uuid(), - secret_versionsId: z.string().uuid(), - secret_tagsId: z.string().uuid(), -}); - -export type TSecretVersionTagJunction = z.infer; -export type TSecretVersionTagJunctionInsert = Omit; -export type TSecretVersionTagJunctionUpdate = Partial>; diff --git a/pg-migrator/src/schemas/secret-versions.ts b/pg-migrator/src/schemas/secret-versions.ts deleted file mode 100644 index aff51bd83..000000000 --- a/pg-migrator/src/schemas/secret-versions.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretVersionsSchema = z.object({ - id: z.string().uuid(), - version: z.number().default(1), - type: z.string().default("shared"), - secretBlindIndex: z.string().nullable().optional(), - secretKeyCiphertext: z.string(), - secretKeyIV: z.string(), - secretKeyTag: z.string(), - secretValueCiphertext: z.string(), - secretValueIV: z.string(), - secretValueTag: z.string(), - secretCommentCiphertext: z.string().nullable().optional(), - secretCommentIV: z.string().nullable().optional(), - secretCommentTag: z.string().nullable().optional(), - secretReminderNote: z.string().nullable().optional(), - secretReminderRepeatDays: z.number().nullable().optional(), - skipMultilineEncoding: z.boolean().default(false).nullable().optional(), - algorithm: z.string().default("aes-256-gcm"), - keyEncoding: z.string().default("utf8"), - metadata: z.unknown().nullable().optional(), - envId: z.string().uuid().nullable().optional(), - secretId: z.string().uuid(), - folderId: z.string().uuid(), - userId: z.string().uuid().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecretVersions = z.infer; -export type TSecretVersionsInsert = Omit; -export type TSecretVersionsUpdate = Partial< - Omit ->; diff --git a/pg-migrator/src/schemas/secrets.ts b/pg-migrator/src/schemas/secrets.ts deleted file mode 100644 index 646f9d070..000000000 --- a/pg-migrator/src/schemas/secrets.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SecretsSchema = z.object({ - id: z.string().uuid(), - version: z.number().default(1), - type: z.string().default("shared"), - secretBlindIndex: z.string().nullable().optional(), - secretKeyCiphertext: z.string(), - secretKeyIV: z.string(), - secretKeyTag: z.string(), - secretValueCiphertext: z.string(), - secretValueIV: z.string(), - secretValueTag: z.string(), - secretCommentCiphertext: z.string().nullable().optional(), - secretCommentIV: z.string().nullable().optional(), - secretCommentTag: z.string().nullable().optional(), - secretReminderNote: z.string().nullable().optional(), - secretReminderRepeatDays: z.number().nullable().optional(), - skipMultilineEncoding: z.boolean().default(false).nullable().optional(), - algorithm: z.string().default("aes-256-gcm"), - keyEncoding: z.string().default("utf8"), - metadata: z.unknown().nullable().optional(), - userId: z.string().uuid().nullable().optional(), - folderId: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSecrets = z.infer; -export type TSecretsInsert = Omit; -export type TSecretsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/service-tokens.ts b/pg-migrator/src/schemas/service-tokens.ts deleted file mode 100644 index 29b0cd49f..000000000 --- a/pg-migrator/src/schemas/service-tokens.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const ServiceTokensSchema = z.object({ - id: z.string().uuid(), - name: z.string(), - scopes: z.unknown(), - permissions: z.string().array(), - lastUsed: z.date().nullable().optional(), - expiresAt: z.date().nullable().optional(), - secretHash: z.string(), - encryptedKey: z.string().nullable().optional(), - iv: z.string().nullable().optional(), - tag: z.string().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - createdBy: z.string(), - projectId: z.string(), -}); - -export type TServiceTokens = z.infer; -export type TServiceTokensInsert = Omit; -export type TServiceTokensUpdate = Partial>; diff --git a/pg-migrator/src/schemas/super-admin.ts b/pg-migrator/src/schemas/super-admin.ts deleted file mode 100644 index f998cf325..000000000 --- a/pg-migrator/src/schemas/super-admin.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const SuperAdminSchema = z.object({ - id: z.string().uuid(), - initialized: z.boolean().default(false).nullable().optional(), - allowSignUp: z.boolean().default(true).nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TSuperAdmin = z.infer; -export type TSuperAdminInsert = Omit; -export type TSuperAdminUpdate = Partial>; diff --git a/pg-migrator/src/schemas/trusted-ips.ts b/pg-migrator/src/schemas/trusted-ips.ts deleted file mode 100644 index c3311340d..000000000 --- a/pg-migrator/src/schemas/trusted-ips.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const TrustedIpsSchema = z.object({ - id: z.string().uuid(), - ipAddress: z.string(), - type: z.string(), - prefix: z.number().nullable().optional(), - isActive: z.boolean().default(true).nullable().optional(), - comment: z.string().nullable().optional(), - projectId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TTrustedIps = z.infer; -export type TTrustedIpsInsert = Omit; -export type TTrustedIpsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/user-actions.ts b/pg-migrator/src/schemas/user-actions.ts deleted file mode 100644 index a3a07d699..000000000 --- a/pg-migrator/src/schemas/user-actions.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const UserActionsSchema = z.object({ - id: z.string().uuid(), - action: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - userId: z.string().uuid(), -}); - -export type TUserActions = z.infer; -export type TUserActionsInsert = Omit; -export type TUserActionsUpdate = Partial>; diff --git a/pg-migrator/src/schemas/user-encryption-keys.ts b/pg-migrator/src/schemas/user-encryption-keys.ts deleted file mode 100644 index 0fde37ae7..000000000 --- a/pg-migrator/src/schemas/user-encryption-keys.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const UserEncryptionKeysSchema = z.object({ - id: z.string().uuid(), - clientPublicKey: z.string().nullable().optional(), - serverPrivateKey: z.string().nullable().optional(), - encryptionVersion: z.number().default(2).nullable().optional(), - protectedKey: z.string(), - protectedKeyIV: z.string(), - protectedKeyTag: z.string(), - publicKey: z.string(), - encryptedPrivateKey: z.string(), - iv: z.string(), - tag: z.string(), - salt: z.string(), - verifier: z.string(), - userId: z.string().uuid(), -}); - -export type TUserEncryptionKeys = z.infer; -export type TUserEncryptionKeysInsert = Omit; -export type TUserEncryptionKeysUpdate = Partial>; diff --git a/pg-migrator/src/schemas/users.ts b/pg-migrator/src/schemas/users.ts deleted file mode 100644 index b9689883d..000000000 --- a/pg-migrator/src/schemas/users.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const UsersSchema = z.object({ - id: z.string().uuid(), - email: z.string(), - authMethods: z.string().array().nullable().optional(), - superAdmin: z.boolean().default(false).nullable().optional(), - firstName: z.string().nullable().optional(), - lastName: z.string().nullable().optional(), - isAccepted: z.boolean().default(false).nullable().optional(), - isMfaEnabled: z.boolean().default(false).nullable().optional(), - mfaMethods: z.string().array().nullable().optional(), - devices: z.unknown().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type TUsers = z.infer; -export type TUsersInsert = Omit; -export type TUsersUpdate = Partial>; diff --git a/pg-migrator/src/schemas/webhooks.ts b/pg-migrator/src/schemas/webhooks.ts deleted file mode 100644 index 2b7e36cab..000000000 --- a/pg-migrator/src/schemas/webhooks.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const WebhooksSchema = z.object({ - id: z.string().uuid(), - secretPath: z.string().default('/'), - url: z.string(), - lastStatus: z.string().nullable().optional(), - lastRunErrorMessage: z.string().nullable().optional(), - isDisabled: z.boolean().default(false), - encryptedSecretKey: z.string().nullable().optional(), - iv: z.string().nullable().optional(), - tag: z.string().nullable().optional(), - algorithm: z.string().nullable().optional(), - keyEncoding: z.string().nullable().optional(), - createdAt: z.date(), - updatedAt: z.date(), - envId: z.string().uuid(), -}); - -export type TWebhooks = z.infer; -export type TWebhooksInsert = Omit; -export type TWebhooksUpdate = Partial>; diff --git a/pg-migrator/src/utils.ts b/pg-migrator/src/utils.ts deleted file mode 100644 index b59ae8bbe..000000000 --- a/pg-migrator/src/utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "./schemas"; - -export const createJunctionTable = ( - knex: Knex, - tableName: TableName, - table1Name: TableName, - table2Name: TableName, -) => - knex.schema.createTable(tableName, (table) => { - table.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - table.uuid(`${table1Name}Id`).unsigned().notNullable(); // Foreign key for table1 - table.uuid(`${table2Name}Id`).unsigned().notNullable(); // Foreign key for table2 - table - .foreign(`${table1Name}Id`) - .references("id") - .inTable(table1Name) - .onDelete("CASCADE"); - table - .foreign(`${table2Name}Id`) - .references("id") - .inTable(table2Name) - .onDelete("CASCADE"); - }); - -// one time logic -// this is a postgres function log to set updateAt to present time whenever row gets updated -export const createUpdateAtTriggerFunction = (knex: Knex) => - knex.raw(` -CREATE OR REPLACE FUNCTION on_update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW."updatedAt" = NOW(); -RETURN NEW; -END; -$$ LANGUAGE plpgsql; -`); - -export const dropUpdatedAtTriggerFunction = (knex: Knex) => - knex.raw(` -DROP FUNCTION IF EXISTS on_update_timestamp() CASCADE; -`); - -// we would be using this to apply updatedAt where ever we wanta -// remember to set `timestamps(true,true,true)` before this on schema -export const createOnUpdateTrigger = (knex: Knex, tableName: string) => - knex.raw(` -CREATE TRIGGER "${tableName}_updatedAt" -BEFORE UPDATE ON ${tableName} -FOR EACH ROW -EXECUTE PROCEDURE on_update_timestamp(); -`); - -export const dropOnUpdateTrigger = (knex: Knex, tableName: string) => - knex.raw(`DROP TRIGGER IF EXISTS "${tableName}_updatedAt" ON ${tableName}`); diff --git a/pg-migrator/tsconfig.json b/pg-migrator/tsconfig.json deleted file mode 100644 index f17360603..000000000 --- a/pg-migrator/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "module": "commonjs", - "lib": ["es6"], - "typeRoots": ["./node_modules/@types", "./src/@types", "./node_modules"], - "allowJs": true, - "outDir": "build", - "rootDir": "src", - "strict": true, - "noImplicitAny": true, - "esModuleInterop": true, - "resolveJsonModule": true - } -} From a6f750fafbb5e4c7d8dc1511ff02b2249f5e7d3c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 10 May 2024 13:57:00 +0800 Subject: [PATCH 237/244] feat: added batch update endpoint for folders --- .../server/routes/v1/secret-folder-router.ts | 65 +++++++++++++ .../secret-folder/secret-folder-service.ts | 96 ++++++++++++++++++- .../secret-folder/secret-folder-types.ts | 9 ++ 3 files changed, 169 insertions(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 3b8d0988f..1ca97c030 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -127,6 +127,71 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } }); + server.route({ + url: "/batch", + method: "PATCH", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Update folders by batch", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + workspaceId: z.string().trim().describe(FOLDERS.UPDATE.workspaceId), + folders: z + .object({ + id: z.string().describe(FOLDERS.UPDATE.folderId), + environment: z.string().trim().describe(FOLDERS.UPDATE.environment), + name: z.string().trim().describe(FOLDERS.UPDATE.name), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.UPDATE.path) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { newFolders, oldFolders } = await server.services.folder.updateManyFolders({ + folders: req.body.folders, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.workspaceId + }); + + await Promise.all( + req.body.folders.map(async (folder, ind) => { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.workspaceId, + event: { + type: EventType.UPDATE_FOLDER, + metadata: { + environment: oldFolders[ind].envId, + folderId: oldFolders[ind].id, + folderPath: folder.path, + newFolderName: newFolders[ind].name, + oldFolderName: oldFolders[ind].name + } + } + }); + }) + ); + + return { folders: newFolders }; + } + }); + // TODO(daniel): Expose this route in api reference and write docs for it. server.route({ method: "DELETE", diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 9f602172f..7693ab92c 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -10,7 +10,13 @@ import { BadRequestError } from "@app/lib/errors"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal"; -import { TCreateFolderDTO, TDeleteFolderDTO, TGetFolderDTO, TUpdateFolderDTO } from "./secret-folder-types"; +import { + TCreateFolderDTO, + TDeleteFolderDTO, + TGetFolderDTO, + TUpdateFolderDTO, + TUpdateManyFoldersDTO +} from "./secret-folder-types"; import { TSecretFolderVersionDALFactory } from "./secret-folder-version-dal"; type TSecretFolderServiceFactoryDep = { @@ -116,6 +122,93 @@ export const secretFolderServiceFactory = ({ return folder; }; + const updateManyFolders = async ({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + folders + }: TUpdateManyFoldersDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + folders.forEach(({ environment, path: secretPath }) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + }); + + const result = await folderDAL.transaction(async (tx) => + Promise.all( + folders.map(async (newFolder) => { + const { environment, path: secretPath, id, name } = newFolder; + + const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); + + const env = await projectEnvDAL.findOne({ projectId, slug: environment }); + if (!env) throw new BadRequestError({ message: "Environment not found", name: "Update folder" }); + const folder = await folderDAL + .findOne({ envId: env.id, id, parentId: parentFolder.id }) + // now folder api accepts id based change + // this is for cli backward compatiability and when cli removes this, we will remove this logic + .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); + + if (!folder) { + throw new BadRequestError({ message: "Folder not found" }); + } + if (name !== folder.name) { + // ensure that new folder name is unique + const folderToCheck = await folderDAL.findOne({ + name, + envId: env.id, + parentId: parentFolder.id + }); + + if (folderToCheck) { + throw new BadRequestError({ + message: "Folder with specified name already exists", + name: "Batch update folder" + }); + } + } + + const [doc] = await folderDAL.update( + { envId: env.id, id: folder.id, parentId: parentFolder.id }, + { name }, + tx + ); + await folderVersionDAL.create( + { + name: doc.name, + envId: doc.envId, + version: doc.version, + folderId: doc.id + }, + tx + ); + if (!doc) { + throw new BadRequestError({ message: "Folder not found", name: "Batch update folder" }); + } + + return { oldFolder: folder, newFolder: doc }; + }) + ) + ); + + return { + newFolders: result.map((res) => res.newFolder), + oldFolders: result.map((res) => res.oldFolder) + }; + }; + const updateFolder = async ({ projectId, actor, @@ -254,6 +347,7 @@ export const secretFolderServiceFactory = ({ return { createFolder, updateFolder, + updateManyFolders, deleteFolder, getFolders }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 88b7b1017..18c677b49 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -13,6 +13,15 @@ export type TUpdateFolderDTO = { name: string; } & TProjectPermission; +export type TUpdateManyFoldersDTO = { + folders: { + environment: string; + path: string; + id: string; + name: string; + }[]; +} & TProjectPermission; + export type TDeleteFolderDTO = { environment: string; path: string; From f32abbdc250f95fe411b4399c0123147dc98086c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 10 May 2024 14:00:49 +0800 Subject: [PATCH 238/244] feat: integrate overview folder rename with new batch endpoint --- .../src/hooks/api/secretFolders/queries.tsx | 41 ++++++++++++ frontend/src/hooks/api/secretFolders/types.ts | 10 +++ .../SecretOverviewPage/SecretOverviewPage.tsx | 67 +++++++++---------- 3 files changed, 82 insertions(+), 36 deletions(-) diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 71c63f3eb..9bf266ccf 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -16,6 +16,7 @@ import { TGetFoldersByEnvDTO, TGetProjectFoldersDTO, TSecretFolder, + TUpdateFolderBatchDTO, TUpdateFolderDTO } from "./types"; @@ -190,3 +191,43 @@ export const useDeleteFolder = () => { } }); }; + +export const useUpdateFolderBatch = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateFolderBatchDTO>({ + mutationFn: async ({ projectId, folders }) => { + const { data } = await apiRequest.patch("/api/v1/folders/batch", { + folders, + workspaceId: projectId + }); + + return data; + }, + onSuccess: (_, { projectId, folders }) => { + folders.forEach((folder) => { + queryClient.invalidateQueries( + folderQueryKeys.getSecretFolders({ + projectId, + environment: folder.environment, + path: folder.path + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ + workspaceId: projectId, + environment: folder.environment, + directory: folder.path + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ + workspaceId: projectId, + environment: folder.environment, + directory: folder.path + }) + ); + }); + } + }); +}; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index eac202389..0ae2cfef2 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -36,3 +36,13 @@ export type TDeleteFolderDTO = { folderId: string; path?: string; }; + +export type TUpdateFolderBatchDTO = { + projectId: string; + folders: { + name: string; + environment: string; + id: string; + path?: string; + }[]; +}; diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index e7517b37e..af1ef5d7d 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -60,9 +60,10 @@ import { useGetImportedSecretsAllEnvs, useGetProjectSecretsAllEnv, useGetUserWsKey, - useUpdateFolder, useUpdateSecretV3 } from "@app/hooks/api"; +import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; +import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; import { TSecretFolder } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; @@ -205,7 +206,7 @@ export const SecretOverviewPage = () => { const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); - const { mutateAsync: updateFolder } = useUpdateFolder(); + const { mutateAsync: updateFolderBatch } = useUpdateFolderBatch(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addSecretsInAllEnvs", @@ -244,60 +245,54 @@ export const SecretOverviewPage = () => { const handleFolderUpdate = async (newFolderName: string) => { const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder; - let processedUpdates = 0; - let totalFolderMatchCount = 0; - - await Promise.allSettled( - userAvailableEnvs.map(async (env) => { - if ( - permission.cannot( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) - ) - ) { - return; - } + const updatedFolders: TUpdateFolderBatchDTO["folders"] = []; + userAvailableEnvs.forEach((env) => { + if ( + permission.can( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) + ) { const folder = getFolderByNameAndEnv(oldFolderName, env.slug); if (folder) { - totalFolderMatchCount += 1; - - await updateFolder({ - folderId: folder.id, - name: newFolderName, - path: secretPath, + updatedFolders.push({ environment: env.slug, - projectId: workspaceId + name: newFolderName, + id: folder.id, + path: secretPath }); - - processedUpdates += 1; } - }) - ); + } + }); - if (totalFolderMatchCount === 0) { + if (updatedFolders.length === 0) { createNotification({ type: "info", text: "You don't have access to rename selected folder" }); - } else if (processedUpdates === totalFolderMatchCount) { + + handlePopUpClose("updateFolder"); + return; + } + + try { + await updateFolderBatch({ + projectId: workspaceId, + folders: updatedFolders + }); createNotification({ type: "success", text: "Successfully renamed folder across environments" }); - } else if (processedUpdates > 0) { - createNotification({ - type: "warning", - text: "Partially renamed folder across environments" - }); - } else { + } catch (err) { createNotification({ type: "error", text: "Failed to rename folder across environments" }); + } finally { + handlePopUpClose("updateFolder"); } - - handlePopUpClose("updateFolder"); }; const handleSecretCreate = async (env: string, key: string, value: string) => { From 2559809eac46e8419d4190a58886d5c16323fb4a Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 10 May 2024 14:41:35 +0800 Subject: [PATCH 239/244] misc: addressed formatting issues --- .../src/services/secret-folder/secret-folder-service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 7693ab92c..6e1d9f745 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -151,10 +151,14 @@ export const secretFolderServiceFactory = ({ const { environment, path: secretPath, id, name } = newFolder; const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); - if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); + if (!parentFolder) { + throw new BadRequestError({ message: "Secret path not found", name: "Batch update folder" }); + } const env = await projectEnvDAL.findOne({ projectId, slug: environment }); - if (!env) throw new BadRequestError({ message: "Environment not found", name: "Update folder" }); + if (!env) { + throw new BadRequestError({ message: "Environment not found", name: "Batch update folder" }); + } const folder = await folderDAL .findOne({ envId: env.id, id, parentId: parentFolder.id }) // now folder api accepts id based change From 3fff272cb3406d038ca9b89490b6e5998a297c88 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 10 May 2024 15:46:31 +0800 Subject: [PATCH 240/244] feat: added snapshot for batch --- backend/src/services/secret-folder/secret-folder-service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 6e1d9f745..66e96e013 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -207,6 +207,8 @@ export const secretFolderServiceFactory = ({ ) ); + await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string))); + return { newFolders: result.map((res) => res.newFolder), oldFolders: result.map((res) => res.oldFolder) From 0866a90c8e074401ee19c58ad18aa89cb012ee3d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 10 May 2024 16:29:12 +0800 Subject: [PATCH 241/244] misc: updated documentation for github integration --- docs/integrations/cicd/githubactions.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 10caabc2f..936c8974a 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -3,9 +3,15 @@ title: "GitHub Actions" description: "How to sync secrets from Infisical to GitHub Actions" --- + + Alternatively, you can use Infisical's official Github Action + [here](https://github.com/Infisical/secrets-action). + + Infisical lets you sync secrets to GitHub at the organization-level, repository-level, and repository environment-level. Prerequisites: + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - Ensure that you have admin privileges to the repository you want to sync secrets to. From b93aaffe7708393c902b87753e929469d3405b6d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 10 May 2024 22:34:16 +0800 Subject: [PATCH 242/244] adjustment: updated to use project slug --- backend/src/lib/api-docs/constants.ts | 1 + backend/src/server/routes/index.ts | 4 +++- .../src/server/routes/v1/secret-folder-router.ts | 11 +++++------ .../secret-folder/secret-folder-service.ts | 15 +++++++++++++-- .../services/secret-folder/secret-folder-types.ts | 3 ++- frontend/src/hooks/api/secretFolders/queries.tsx | 6 +++--- frontend/src/hooks/api/secretFolders/types.ts | 1 + .../SecretOverviewPage/SecretOverviewPage.tsx | 5 +++-- 8 files changed, 31 insertions(+), 15 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 336385472..6f9cbac34 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -252,6 +252,7 @@ export const FOLDERS = { name: "The new name of the folder.", path: "The path of the folder to update.", directory: "The new directory of the folder to update. (Deprecated in favor of path)", + projectSlug: "The slug of the project where the folder is located.", workspaceId: "The ID of the project where the folder is located." }, DELETE: { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 62dad3cf4..94ba5db55 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -538,8 +538,10 @@ export const registerRoutes = async ( folderDAL, folderVersionDAL, projectEnvDAL, - snapshotService + snapshotService, + projectDAL }); + const integrationAuthService = integrationAuthServiceFactory({ integrationAuthDAL, integrationDAL, diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 1ca97c030..95cd47c7b 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -141,7 +141,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } ], body: z.object({ - workspaceId: z.string().trim().describe(FOLDERS.UPDATE.workspaceId), + projectSlug: z.string().trim().describe(FOLDERS.UPDATE.projectSlug), folders: z .object({ id: z.string().describe(FOLDERS.UPDATE.folderId), @@ -160,20 +160,19 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { newFolders, oldFolders } = await server.services.folder.updateManyFolders({ - folders: req.body.folders, + const { newFolders, oldFolders, projectId } = await server.services.folder.updateManyFolders({ + ...req.body, actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.body.workspaceId + actorOrgId: req.permission.orgId }); await Promise.all( req.body.folders.map(async (folder, ind) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId, event: { type: EventType.UPDATE_FOLDER, metadata: { diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 66e96e013..5caeea720 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -8,6 +8,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal"; import { @@ -25,6 +26,7 @@ type TSecretFolderServiceFactoryDep = { folderDAL: TSecretFolderDALFactory; projectEnvDAL: Pick; folderVersionDAL: TSecretFolderVersionDALFactory; + projectDAL: Pick; }; export type TSecretFolderServiceFactory = ReturnType; @@ -34,7 +36,8 @@ export const secretFolderServiceFactory = ({ snapshotService, permissionService, projectEnvDAL, - folderVersionDAL + folderVersionDAL, + projectDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ projectId, @@ -125,11 +128,18 @@ export const secretFolderServiceFactory = ({ const updateManyFolders = async ({ actor, actorId, - projectId, + projectSlug, actorAuthMethod, actorOrgId, folders }: TUpdateManyFoldersDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -210,6 +220,7 @@ export const secretFolderServiceFactory = ({ await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string))); return { + projectId, newFolders: result.map((res) => res.newFolder), oldFolders: result.map((res) => res.oldFolder) }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 18c677b49..1405f8bd7 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -14,13 +14,14 @@ export type TUpdateFolderDTO = { } & TProjectPermission; export type TUpdateManyFoldersDTO = { + projectSlug: string; folders: { environment: string; path: string; id: string; name: string; }[]; -} & TProjectPermission; +} & Omit; export type TDeleteFolderDTO = { environment: string; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 9bf266ccf..5bd3444d0 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -196,10 +196,10 @@ export const useUpdateFolderBatch = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateFolderBatchDTO>({ - mutationFn: async ({ projectId, folders }) => { + mutationFn: async ({ projectSlug, folders }) => { const { data } = await apiRequest.patch("/api/v1/folders/batch", { - folders, - workspaceId: projectId + projectSlug, + folders }); return data; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index 0ae2cfef2..8fde9c63d 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -39,6 +39,7 @@ export type TDeleteFolderDTO = { export type TUpdateFolderBatchDTO = { projectId: string; + projectSlug: string; folders: { name: string; environment: string; diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index af1ef5d7d..afe806c5e 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -278,8 +278,9 @@ export const SecretOverviewPage = () => { try { await updateFolderBatch({ - projectId: workspaceId, - folders: updatedFolders + projectSlug, + folders: updatedFolders, + projectId: workspaceId }); createNotification({ type: "success", From 9880977098ab003e7e2720064b33f47102a9c954 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 10 May 2024 22:52:08 +0800 Subject: [PATCH 243/244] misc: addressed naming suggestion --- backend/src/server/routes/v1/secret-folder-router.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 95cd47c7b..1a1747f64 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -169,18 +169,18 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }); await Promise.all( - req.body.folders.map(async (folder, ind) => { + req.body.folders.map(async (folder, index) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId, event: { type: EventType.UPDATE_FOLDER, metadata: { - environment: oldFolders[ind].envId, - folderId: oldFolders[ind].id, + environment: oldFolders[index].envId, + folderId: oldFolders[index].id, folderPath: folder.path, - newFolderName: newFolders[ind].name, - oldFolderName: oldFolders[ind].name + newFolderName: newFolders[index].name, + oldFolderName: oldFolders[index].name } } }); From 86aaa486b474ca66aa2ce661c061e86d0202e9ff Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 10 May 2024 17:00:30 +0200 Subject: [PATCH 244/244] Update secret-folder-service.ts --- .../services/secret-folder/secret-folder-service.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 5caeea720..da429d88a 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -138,12 +138,10 @@ export const secretFolderServiceFactory = ({ throw new BadRequestError({ message: "Project not found" }); } - const projectId = project.id; - const { permission } = await permissionService.getProjectPermission( actor, actorId, - projectId, + project.id, actorAuthMethod, actorOrgId ); @@ -160,12 +158,12 @@ export const secretFolderServiceFactory = ({ folders.map(async (newFolder) => { const { environment, path: secretPath, id, name } = newFolder; - const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath); if (!parentFolder) { throw new BadRequestError({ message: "Secret path not found", name: "Batch update folder" }); } - const env = await projectEnvDAL.findOne({ projectId, slug: environment }); + const env = await projectEnvDAL.findOne({ projectId: project.id, slug: environment }); if (!env) { throw new BadRequestError({ message: "Environment not found", name: "Batch update folder" }); } @@ -220,7 +218,7 @@ export const secretFolderServiceFactory = ({ await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string))); return { - projectId, + projectId: project.id, newFolders: result.map((res) => res.newFolder), oldFolders: result.map((res) => res.oldFolder) };