From d604ef2480a355d4df52389408fe1977ab66887a Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 2 Sep 2024 15:38:05 +0800 Subject: [PATCH] feat: integrated secret approval request --- backend/package-lock.json | 1 + backend/package.json | 1 + .../secret-approval-request-fns.ts | 68 ++++++++++++++ .../secret-approval-request-service.ts | 31 ++++++- backend/src/server/routes/index.ts | 3 +- backend/src/services/slack/slack-fns.ts | 88 +++++++++++++++++++ backend/src/services/slack/slack-service.ts | 5 +- backend/src/services/slack/slack-types.ts | 5 ++ 8 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 backend/src/services/slack/slack-fns.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 395e4ddd5..a4b3fa3cb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -34,6 +34,7 @@ "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", + "@slack/web-api": "^7.3.4", "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", diff --git a/backend/package.json b/backend/package.json index 813a6dc1c..dd81bd579 100644 --- a/backend/package.json +++ b/backend/package.json @@ -131,6 +131,7 @@ "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", + "@slack/web-api": "^7.3.4", "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", 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 index 05b7280b2..ddedceac1 100644 --- 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 @@ -1,7 +1,12 @@ import { TSecretApprovalRequests } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { triggerSlackNotification } from "@app/services/slack/slack-fns"; +import { TSlackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal"; +import { SlackTriggerFeature } from "@app/services/slack/slack-types"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; @@ -13,6 +18,69 @@ type TSendApprovalEmails = { secretApprovalRequest: TSecretApprovalRequests; }; +type TTriggerSecretApprovalSlackNotif = { + environment: string; + projectId: string; + projectDAL: Pick; + kmsService: Pick; + secretApprovalRequest: TSecretApprovalRequests; + secretPath: string; + slackIntegrationDAL: Pick; + userDAL: Pick; +}; + +export const triggerSecretApprovalSlackNotif = async ({ + projectId, + projectDAL, + kmsService, + secretApprovalRequest, + slackIntegrationDAL, + userDAL, + environment, + secretPath +}: TTriggerSecretApprovalSlackNotif) => { + // construct message here + const appCfg = getConfig(); + const project = await projectDAL.findProjectWithOrg(projectId); + const user = await userDAL.findById(secretApprovalRequest.committerUserId); + + const messageBody = `A secret approval request has been opened by ${user.email}. + *Environment*: ${environment} + *Secret path*: ${secretPath || "/"} + + View the complete details <${appCfg.SITE_URL}/project/${project.id}/approval?requestId=${ + secretApprovalRequest.id + }|here>.`; + + const payloadBlocks = [ + { + type: "header", + text: { + type: "plain_text", + text: "Secret approval request", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: messageBody + } + } + ]; + + await triggerSlackNotification({ + projectId, + projectDAL, + kmsService, + slackIntegrationDAL, + payloadMessage: messageBody, + payloadBlocks, + feature: SlackTriggerFeature.SECRET_APPROVAL + }); +}; + export const sendApprovalEmailsFn = async ({ secretApprovalPolicyDAL, projectDAL, 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 80913c3d9..38ce76e08 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 @@ -47,6 +47,7 @@ import { } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; +import { TSlackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -56,7 +57,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/pr 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 { sendApprovalEmailsFn, triggerSecretApprovalSlackNotif } from "./secret-approval-request-fns"; import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-request-reviewer-dal"; import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal"; import { @@ -89,7 +90,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretVersionDAL: Pick; secretVersionTagDAL: Pick; smtpService: Pick; - userDAL: Pick; + userDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick< TProjectDALFactory, @@ -104,6 +105,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; secretApprovalPolicyDAL: Pick; + slackIntegrationDAL: Pick; licenseService: Pick; }; @@ -132,6 +134,7 @@ export const secretApprovalRequestServiceFactory = ({ secretV2BridgeDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, + slackIntegrationDAL, licenseService }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { @@ -1069,6 +1072,18 @@ export const secretApprovalRequestServiceFactory = ({ return { ...doc, commits: approvalCommits }; }); + const env = await projectEnvDAL.findOne({ id: policy.envId }); + await triggerSecretApprovalSlackNotif({ + projectId, + secretPath: policy.secretPath as string, + environment: env.name, + projectDAL, + kmsService, + secretApprovalRequest, + slackIntegrationDAL, + userDAL + }); + await sendApprovalEmailsFn({ projectDAL, secretApprovalPolicyDAL, @@ -1331,6 +1346,18 @@ export const secretApprovalRequestServiceFactory = ({ return { ...doc, commits: approvalCommits }; }); + const env = await projectEnvDAL.findOne({ id: policy.envId }); + await triggerSecretApprovalSlackNotif({ + secretPath: policy.secretPath as string, + environment: env.name, + projectId, + projectDAL, + kmsService, + secretApprovalRequest, + slackIntegrationDAL, + userDAL + }); + await sendApprovalEmailsFn({ projectDAL, secretApprovalPolicyDAL, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8d740d1f0..8b9b8e329 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -876,7 +876,8 @@ export const registerRoutes = async ( smtpService, projectEnvDAL, userDAL, - licenseService + licenseService, + slackIntegrationDAL }); const secretService = secretServiceFactory({ diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts new file mode 100644 index 000000000..3edb08f14 --- /dev/null +++ b/backend/src/services/slack/slack-fns.ts @@ -0,0 +1,88 @@ +import { Block, WebClient } from "@slack/web-api"; + +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TProjectDALFactory } from "../project/project-dal"; +import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; +import { SlackTriggerFeature } from "./slack-types"; + +export const triggerSlackNotification = async ({ + projectId, + payloadBlocks, + payloadMessage, + slackIntegrationDAL, + projectDAL, + kmsService, + feature +}: { + projectId: string; + payloadBlocks: Block[]; + payloadMessage: string; + slackIntegrationDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + feature: SlackTriggerFeature; +}) => { + const project = await projectDAL.findById(projectId); + const slackIntegration = await slackIntegrationDAL.findOne({ + projectId + }); + + if (!slackIntegration) { + return; + } + + let targetChannels: string[] = []; + if (feature === SlackTriggerFeature.ACCESS_REQUEST) { + targetChannels = slackIntegration.accessRequestChannels?.split(",") || []; + if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) { + return; + } + } else if (feature === SlackTriggerFeature.SECRET_APPROVAL) { + targetChannels = slackIntegration.secretRequestChannels?.split(",") || []; + if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) { + return; + } + } + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: project.orgId + }); + + const botKey = orgDataKeyDecryptor({ + cipherTextBlob: slackIntegration.encryptedBotAccessToken + }).toString("utf8"); + + const targetChannelSet = new Set(targetChannels); + const slackWebClient = new WebClient(botKey); + const channelIdsToSendNotif: string[] = []; + let cursor; + + do { + // eslint-disable-next-line no-await-in-loop + const response = await slackWebClient.conversations.list({ + cursor, + limit: 1000, + types: "public_channel,private_channel" + }); + + response.channels?.forEach((channel) => { + if (channel.name_normalized && targetChannelSet.has(channel.name_normalized)) { + channelIdsToSendNotif.push(channel.id as string); + } + }); + + // Set the cursor for the next page + cursor = response.response_metadata?.next_cursor; + } while (cursor); // Continue while there is a cursor + + for await (const conversationId of channelIdsToSendNotif) { + // we send both text and blocks for compatibility with barebone clients + await slackWebClient.chat.postMessage({ + channel: conversationId, + text: payloadMessage, + blocks: payloadBlocks + }); + } +}; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index e98a28bdc..b5627c3ba 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -102,9 +102,12 @@ export const slackServiceFactory = ({ slackBotUserId: installation.bot?.userId || "" }); }, + // for our use-case we don't need to implement this because this will only be used + // when listening for events from slack fetchInstallation: () => { return {} as never; }, + // for our use-case we don't need to implement this yet deleteInstallation: () => { return {} as never; } @@ -132,7 +135,7 @@ export const slackServiceFactory = ({ const installer = await getSlackInstaller(); const url = await installer.generateInstallUrl({ - scopes: ["chat:write"], + scopes: ["chat:write.public", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"], metadata: JSON.stringify({ projectId: project.id }), diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index 571e30a74..b76ce1e82 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -12,3 +12,8 @@ export type TCompleteSlackIntegrationDTO = { slackBotId: string; slackBotUserId: string; }; + +export enum SlackTriggerFeature { + SECRET_APPROVAL = "secret-approval", + ACCESS_REQUEST = "access-request" +}