Merge pull request #2333 from Infisical/daniel/secret-change-emails

Feat: Email notification on secret change requests
This commit is contained in:
Daniel Hougaard
2024-08-26 21:50:30 +04:00
committed by GitHub
10 changed files with 171 additions and 18 deletions
@@ -20,7 +20,15 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
`${TableName.SecretApprovalPolicy}.id`, `${TableName.SecretApprovalPolicy}.id`,
`${TableName.SecretApprovalPolicyApprover}.policyId` `${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( .select(
tx.ref("name").withSchema(TableName.Environment).as("envName"), tx.ref("name").withSchema(TableName.Environment).as("envName"),
tx.ref("slug").withSchema(TableName.Environment).as("envSlug"), tx.ref("slug").withSchema(TableName.Environment).as("envSlug"),
@@ -47,8 +55,11 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
{ {
key: "approverUserId", key: "approverUserId",
label: "userApprovers" as const, label: "userApprovers" as const,
mapper: ({ approverUserId }) => ({ mapper: ({ approverUserId, approverEmail, approverFirstName, approverLastName }) => ({
userId: approverUserId userId: approverUserId,
email: approverEmail,
firstName: approverFirstName,
lastName: approverLastName
}) })
} }
] ]
@@ -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<TSecretApprovalPolicyDALFactory, "findById">;
projectDAL: Pick<TProjectDALFactory, "findProjectWithOrg">;
smtpService: Pick<TSmtpService, "sendMail">;
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
});
}
};
@@ -53,8 +53,10 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; 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 { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service";
import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal";
import { sendApprovalEmailsFn } from "./secret-approval-request-fns";
import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-request-reviewer-dal"; import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-request-reviewer-dal";
import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal"; import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal";
import { import {
@@ -89,7 +91,10 @@ type TSecretApprovalRequestServiceFactoryDep = {
smtpService: Pick<TSmtpService, "sendMail">; smtpService: Pick<TSmtpService, "sendMail">;
userDAL: Pick<TUserDALFactory, "find" | "findOne">; userDAL: Pick<TUserDALFactory, "find" | "findOne">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findById" | "findProjectById">; projectDAL: Pick<
TProjectDALFactory,
"checkProjectUpgradeStatus" | "findById" | "findProjectById" | "findProjectWithOrg"
>;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">; secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithInputKey" | "decryptWithInputKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithInputKey" | "decryptWithInputKey">;
secretV2BridgeDAL: Pick< secretV2BridgeDAL: Pick<
@@ -98,6 +103,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
>; >;
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">; secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">; secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
secretApprovalPolicyDAL: Pick<TSecretApprovalPolicyDALFactory, "findById">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
}; };
@@ -121,6 +127,7 @@ export const secretApprovalRequestServiceFactory = ({
smtpService, smtpService,
userDAL, userDAL,
projectEnvDAL, projectEnvDAL,
secretApprovalPolicyDAL,
kmsService, kmsService,
secretV2BridgeDAL, secretV2BridgeDAL,
secretVersionV2BridgeDAL, secretVersionV2BridgeDAL,
@@ -1061,6 +1068,15 @@ export const secretApprovalRequestServiceFactory = ({
} }
return { ...doc, commits: approvalCommits }; return { ...doc, commits: approvalCommits };
}); });
await sendApprovalEmailsFn({
projectDAL,
secretApprovalPolicyDAL,
secretApprovalRequest,
smtpService,
projectId
});
return secretApprovalRequest; return secretApprovalRequest;
}; };
@@ -1311,8 +1327,17 @@ export const secretApprovalRequestServiceFactory = ({
tx tx
); );
} }
return { ...doc, commits: approvalCommits }; return { ...doc, commits: approvalCommits };
}); });
await sendApprovalEmailsFn({
projectDAL,
secretApprovalPolicyDAL,
secretApprovalRequest,
smtpService,
projectId
});
return secretApprovalRequest; return secretApprovalRequest;
}; };
+1
View File
@@ -845,6 +845,7 @@ export const registerRoutes = async (
secretQueueService, secretQueueService,
kmsService, kmsService,
secretV2BridgeDAL, secretV2BridgeDAL,
secretApprovalPolicyDAL,
secretVersionV2BridgeDAL, secretVersionV2BridgeDAL,
secretVersionTagV2BridgeDAL, secretVersionTagV2BridgeDAL,
smtpService, smtpService,
@@ -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 { return {
...projectOrm, ...projectOrm,
findAllProjects, findAllProjects,
@@ -288,6 +316,7 @@ export const projectDALFactory = (db: TDbClient) => {
findProjectById, findProjectById,
findProjectByFilter, findProjectByFilter,
findProjectBySlug, findProjectBySlug,
findProjectWithOrg,
checkProjectUpgradeStatus checkProjectUpgradeStatus
}; };
}; };
@@ -25,6 +25,7 @@ export enum SmtpTemplates {
UnlockAccount = "unlockAccount.handlebars", UnlockAccount = "unlockAccount.handlebars",
AccessApprovalRequest = "accessApprovalRequest.handlebars", AccessApprovalRequest = "accessApprovalRequest.handlebars",
AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars", AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars",
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview.handlebars",
HistoricalSecretList = "historicalSecretLeakIncident.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
NewDeviceJoin = "newDevice.handlebars", NewDeviceJoin = "newDevice.handlebars",
OrgInvite = "organizationInvitation.handlebars", OrgInvite = "organizationInvitation.handlebars",
@@ -0,0 +1,22 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Secret Change Approval Request</title>
</head>
<body>
<h2>Hi {{firstName}},</h2>
<h2>New secret change requests are pending review.</h2>
<br />
<p>You have a secret change request pending your review in project "{{projectName}}", in the "{{organizationName}}"
organization.</p>
<p>
View the request and approve or deny it
<a href="{{approvalUrl}}">here</a>.
</p>
</body>
</html>
@@ -9,7 +9,15 @@ export const useCreateSecretApprovalPolicy = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretPolicyDTO>({ 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", { const { data } = await apiRequest.post("/api/v1/secret-approvals", {
environment, environment,
workspaceId, workspaceId,
@@ -45,11 +45,11 @@ const formSchema = z
name: z.string().optional(), name: z.string().optional(),
secretPath: z.string().optional(), secretPath: z.string().optional(),
approvals: z.number().min(1), approvals: z.number().min(1),
approverUserIds: z.string().array().min(1), approvers: z.string().array().min(1),
policyType: z.nativeEnum(PolicyType), policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel) enforcementLevel: z.nativeEnum(EnforcementLevel)
}) })
.refine((data) => data.approvals <= data.approverUserIds.length, { .refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"], path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers." message: "The number of approvals should be lower than the number of approvers."
}); });
@@ -75,8 +75,7 @@ export const AccessPolicyForm = ({
? { ? {
...editValues, ...editValues,
environment: editValues.environment.slug, environment: editValues.environment.slug,
approverUserIds: approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers
editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers
} }
: undefined : undefined
}); });
@@ -264,7 +263,7 @@ export const AccessPolicyForm = ({
/> />
<Controller <Controller
control={control} control={control}
name="approverUserIds" name="approvers"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Required Approvers" label="Required Approvers"
@@ -1,4 +1,5 @@
import { Fragment, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { import {
faCheck, faCheck,
faCheckCircle, faCheckCircle,
@@ -31,7 +32,7 @@ import {
useGetSecretApprovalRequests, useGetSecretApprovalRequests,
useGetWorkspaceUsers useGetWorkspaceUsers
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ApprovalStatus, TSecretApprovalRequest } from "@app/hooks/api/types"; import { ApprovalStatus } from "@app/hooks/api/types";
import { import {
generateCommitText, generateCommitText,
@@ -41,12 +42,13 @@ import {
export const SecretApprovalRequest = () => { export const SecretApprovalRequest = () => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const [selectedApproval, setSelectedApproval] = useState<TSecretApprovalRequest | null>(null); const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null);
// filters // filters
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
const [envFilter, setEnvFilter] = useState<string>(); const [envFilter, setEnvFilter] = useState<string>();
const [committerFilter, setCommitterFilter] = useState<string>(); const [committerFilter, setCommitterFilter] = useState<string>();
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
const { const {
data: secretApprovalRequests, data: secretApprovalRequests,
@@ -64,12 +66,21 @@ export const SecretApprovalRequest = () => {
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId }); useGetSecretApprovalRequestCount({ workspaceId });
const { user: userSession } = useUser(); const { user: userSession } = useUser();
const router = useRouter();
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { data: members } = useGetWorkspaceUsers(workspaceId); 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 = () => { const handleGoBackSecretRequestDetail = () => {
setSelectedApproval(null); setSelectedApprovalId(null);
refetch({ refetchPage: (_page, index) => index === 0 }); refetch({ refetchPage: (_page, index) => index === 0 });
}; };
@@ -88,7 +99,7 @@ export const SecretApprovalRequest = () => {
> >
<SecretApprovalRequestChanges <SecretApprovalRequestChanges
workspaceId={workspaceId} workspaceId={workspaceId}
approvalRequestId={selectedApproval?.id || ""} approvalRequestId={selectedApprovalId || ""}
onGoBack={handleGoBackSecretRequestDetail} onGoBack={handleGoBackSecretRequestDetail}
/> />
</motion.div> </motion.div>
@@ -219,9 +230,9 @@ export const SecretApprovalRequest = () => {
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700" className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setSelectedApproval(secretApproval)} onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => { onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApproval(secretApproval); if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}} }}
> >
<div className="mb-1"> <div className="mb-1">