diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index daf6d0bd8..6d5168f26 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -20,7 +20,15 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicy}.id`, `${TableName.SecretApprovalPolicyApprover}.policyId` ) - .select(tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover)) + + .leftJoin(TableName.Users, `${TableName.SecretApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`) + + .select( + tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), + tx.ref("email").withSchema(TableName.Users).as("approverEmail"), + tx.ref("firstName").withSchema(TableName.Users).as("approverFirstName"), + tx.ref("lastName").withSchema(TableName.Users).as("approverLastName") + ) .select( tx.ref("name").withSchema(TableName.Environment).as("envName"), tx.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -47,8 +55,11 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { { key: "approverUserId", label: "userApprovers" as const, - mapper: ({ approverUserId }) => ({ - userId: approverUserId + mapper: ({ approverUserId, approverEmail, approverFirstName, approverLastName }) => ({ + userId: approverUserId, + email: approverEmail, + firstName: approverFirstName, + lastName: approverLastName }) } ] diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts new file mode 100644 index 000000000..01b2451ac --- /dev/null +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts @@ -0,0 +1,46 @@ +import { TSecretApprovalRequests } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; + +type TSendApprovalEmails = { + secretApprovalPolicyDAL: Pick; + projectDAL: Pick; + smtpService: Pick; + projectId: string; + secretApprovalRequest: TSecretApprovalRequests; +}; + +export const sendApprovalEmailsFn = async ({ + secretApprovalPolicyDAL, + projectDAL, + smtpService, + projectId, + secretApprovalRequest +}: TSendApprovalEmails) => { + const cfg = getConfig(); + + const policy = await secretApprovalPolicyDAL.findById(secretApprovalRequest.policyId); + + const project = await projectDAL.findProjectWithOrg(projectId); + + // now we need to go through each of the reviewers and print out all the commits that they need to approve + for await (const reviewerUser of policy.userApprovers) { + await smtpService.sendMail({ + recipients: [reviewerUser?.email as string], + subjectLine: "Infisical Secret Change Request", + + substitutions: { + firstName: reviewerUser.firstName, + projectName: project.name, + organizationName: project.organization.name, + approvalUrl: `${cfg.isDevelopmentMode ? "https" : "http"}://${cfg.SITE_URL}/project/${ + project.id + }/approval?requestId=${secretApprovalRequest.id}` + }, + template: SmtpTemplates.SecretApprovalRequestNeedsReview + }); + } +}; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index f6bb33168..80913c3d9 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -53,8 +53,10 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; +import { sendApprovalEmailsFn } from "./secret-approval-request-fns"; import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-request-reviewer-dal"; import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal"; import { @@ -89,7 +91,10 @@ type TSecretApprovalRequestServiceFactoryDep = { smtpService: Pick; userDAL: Pick; projectEnvDAL: Pick; - projectDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "checkProjectUpgradeStatus" | "findById" | "findProjectById" | "findProjectWithOrg" + >; secretQueueService: Pick; kmsService: Pick; secretV2BridgeDAL: Pick< @@ -98,6 +103,7 @@ type TSecretApprovalRequestServiceFactoryDep = { >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; + secretApprovalPolicyDAL: Pick; licenseService: Pick; }; @@ -121,6 +127,7 @@ export const secretApprovalRequestServiceFactory = ({ smtpService, userDAL, projectEnvDAL, + secretApprovalPolicyDAL, kmsService, secretV2BridgeDAL, secretVersionV2BridgeDAL, @@ -1061,6 +1068,15 @@ export const secretApprovalRequestServiceFactory = ({ } return { ...doc, commits: approvalCommits }; }); + + await sendApprovalEmailsFn({ + projectDAL, + secretApprovalPolicyDAL, + secretApprovalRequest, + smtpService, + projectId + }); + return secretApprovalRequest; }; @@ -1311,8 +1327,17 @@ export const secretApprovalRequestServiceFactory = ({ tx ); } + return { ...doc, commits: approvalCommits }; }); + + await sendApprovalEmailsFn({ + projectDAL, + secretApprovalPolicyDAL, + secretApprovalRequest, + smtpService, + projectId + }); return secretApprovalRequest; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a15482226..7a1c405b3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -845,6 +845,7 @@ export const registerRoutes = async ( secretQueueService, kmsService, secretV2BridgeDAL, + secretApprovalPolicyDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, smtpService, diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index ce7f6324e..64500814f 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -279,6 +279,34 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const findProjectWithOrg = async (projectId: string) => { + // we just need the project, and we need to include a new .organization field that includes the org from the orgId reference + + const project = await db(TableName.Project) + .where({ [`${TableName.Project}.id` as "id"]: projectId }) + + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) + + .select( + db.ref("id").withSchema(TableName.Organization).as("organizationId"), + db.ref("name").withSchema(TableName.Organization).as("organizationName") + ) + .select(selectAllTableCols(TableName.Project)) + .first(); + + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + return { + ...ProjectsSchema.parse(project), + organization: { + id: project.organizationId, + name: project.organizationName + } + }; + }; + return { ...projectOrm, findAllProjects, @@ -288,6 +316,7 @@ export const projectDALFactory = (db: TDbClient) => { findProjectById, findProjectByFilter, findProjectBySlug, + findProjectWithOrg, checkProjectUpgradeStatus }; }; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 94d84b98c..d0e1e0774 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -25,6 +25,7 @@ export enum SmtpTemplates { UnlockAccount = "unlockAccount.handlebars", AccessApprovalRequest = "accessApprovalRequest.handlebars", AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars", + SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", diff --git a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars new file mode 100644 index 000000000..9dd6fe747 --- /dev/null +++ b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars @@ -0,0 +1,22 @@ + + + + + + Secret Change Approval Request + + + +

Hi {{firstName}},

+

New secret change requests are pending review.

+
+

You have a secret change request pending your review in project "{{projectName}}", in the "{{organizationName}}" + organization.

+ +

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

+ + + \ No newline at end of file diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index 2ad79932b..ceebd3493 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -9,7 +9,15 @@ export const useCreateSecretApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretPolicyDTO>({ - mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name, enforcementLevel }) => { + mutationFn: async ({ + environment, + workspaceId, + approvals, + approvers, + secretPath, + name, + enforcementLevel + }) => { const { data } = await apiRequest.post("/api/v1/secret-approvals", { environment, workspaceId, diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index b9b0523ee..24a470bcc 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -45,11 +45,11 @@ const formSchema = z name: z.string().optional(), secretPath: z.string().optional(), approvals: z.number().min(1), - approverUserIds: z.string().array().min(1), + approvers: z.string().array().min(1), policyType: z.nativeEnum(PolicyType), enforcementLevel: z.nativeEnum(EnforcementLevel) }) - .refine((data) => data.approvals <= data.approverUserIds.length, { + .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], message: "The number of approvals should be lower than the number of approvers." }); @@ -75,8 +75,7 @@ export const AccessPolicyForm = ({ ? { ...editValues, environment: editValues.environment.slug, - approverUserIds: - editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers + approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers } : undefined }); @@ -264,7 +263,7 @@ export const AccessPolicyForm = ({ /> ( { const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const [selectedApproval, setSelectedApproval] = useState(null); + const [selectedApprovalId, setSelectedApprovalId] = useState(null); // filters const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); const [envFilter, setEnvFilter] = useState(); const [committerFilter, setCommitterFilter] = useState(); + const [usingUrlRequestId, setUsingUrlRequestId] = useState(false); const { data: secretApprovalRequests, @@ -64,12 +66,21 @@ export const SecretApprovalRequest = () => { const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = useGetSecretApprovalRequestCount({ workspaceId }); const { user: userSession } = useUser(); + const router = useRouter(); const { permission } = useProjectPermission(); const { data: members } = useGetWorkspaceUsers(workspaceId); - const isSecretApprovalScreen = Boolean(selectedApproval); + const isSecretApprovalScreen = Boolean(selectedApprovalId); + const { requestId } = router.query; + + useEffect(() => { + if (!requestId || usingUrlRequestId) return; + + setSelectedApprovalId(requestId as string); + setUsingUrlRequestId(true); + }, [requestId]); const handleGoBackSecretRequestDetail = () => { - setSelectedApproval(null); + setSelectedApprovalId(null); refetch({ refetchPage: (_page, index) => index === 0 }); }; @@ -88,7 +99,7 @@ export const SecretApprovalRequest = () => { > @@ -219,9 +230,9 @@ export const SecretApprovalRequest = () => { className="flex flex-col px-8 py-4 hover:bg-mineshaft-700" role="button" tabIndex={0} - onClick={() => setSelectedApproval(secretApproval)} + onClick={() => setSelectedApprovalId(secretApproval.id)} onKeyDown={(evt) => { - if (evt.key === "Enter") setSelectedApproval(secretApproval); + if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); }} >