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 1027995a7..83d3a23a7 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 @@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({ ); const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; - const approvalPath = `/projects/secret-management/${project.id}/approval`; + const projectPath = `/projects/secret-management/${project.id}`; + const approvalPath = `${projectPath}/approval`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await triggerWorkflowIntegrationNotification({ @@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({ type: TriggerFeature.ACCESS_REQUEST, payload: { projectName: project.name, + projectPath, requesterFullName, isTemporary, requesterEmail: requestedByUser.email as string, diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts index 08f75d89c..db0725475 100644 --- a/backend/src/lib/workflow-integrations/types.ts +++ b/backend/src/lib/workflow-integrations/types.ts @@ -32,6 +32,7 @@ export type TNotification = secretPath: string; environment: string; projectName: string; + projectPath: string; permissions: string[]; approvalUrl: string; note?: string; @@ -61,6 +62,10 @@ export type TNotification = syncDestination: string; failureMessage: string; syncUrl: string; + environment: string; + secretPath: string; + projectName: string; + projectPath: string; }; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b42d01850..0125df798 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1244,7 +1244,10 @@ export const registerRoutes = async ( licenseService, gatewayService, gatewayV2Service, - notificationService + notificationService, + projectSlackConfigDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService }); const secretQueueService = secretQueueFactory({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index c1f4140e5..3bd050ac8 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -769,7 +769,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), @@ -873,7 +875,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { accessRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField, isAccessRequestNotificationEnabled: z.boolean(), - isSecretRequestNotificationEnabled: z.boolean() + isSecretRequestNotificationEnabled: z.boolean(), + secretSyncErrorChannels: validateSlackChannelsField, + isSecretSyncErrorNotificationEnabled: z.boolean() }), z.object({ integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), @@ -891,7 +895,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index e29f18404..3c6d541ce 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1568,8 +1568,14 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels - }: TUpdateProjectWorkflowIntegration) => { + secretRequestChannels, + secretSyncErrorChannels, + isSecretSyncErrorNotificationEnabled + }: TUpdateProjectWorkflowIntegration & { + // workaround intersection type while we don't have the microsoft teams integration for failed secret syncs + isSecretSyncErrorNotificationEnabled: boolean; + secretSyncErrorChannels: string; + }) => { const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ @@ -1591,6 +1597,7 @@ export const projectServiceFactory = ({ const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels); const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels); + const sanitizedSecretSyncErrorChannels = validateSlackChannelsField.parse(secretSyncErrorChannels); const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); @@ -1628,7 +1635,9 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels: sanitizedAccessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels: sanitizedSecretRequestChannels + secretRequestChannels: sanitizedSecretRequestChannels, + isSecretSyncErrorNotificationEnabled, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels }, tx ); @@ -1641,7 +1650,9 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels: sanitizedAccessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels: sanitizedSecretRequestChannels + secretRequestChannels: sanitizedSecretRequestChannels, + isSecretSyncErrorNotificationEnabled, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels }, tx ); @@ -1651,6 +1662,7 @@ export const projectServiceFactory = ({ ...updatedWorkflowIntegration, accessRequestChannels: sanitizedAccessRequestChannels, secretRequestChannels: sanitizedSecretRequestChannels, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels, integrationId: slackIntegration.id, integration: WorkflowIntegration.SLACK } as const; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 18ae74350..318b08a8d 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -184,8 +184,10 @@ export type TUpdateProjectWorkflowIntegration = ( integration: WorkflowIntegration.SLACK; isAccessRequestNotificationEnabled: boolean; isSecretRequestNotificationEnabled: boolean; + isSecretSyncErrorNotificationEnabled: boolean; accessRequestChannels?: string; secretRequestChannels?: string; + secretSyncErrorChannels?: string; } | { integrationId: string; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index acb2ea4d1..f6e23dded 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -932,7 +932,9 @@ export const secretSyncQueueFactory = ({ break; } - const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`; + const baseProjectPath = `/projects/secret-management/${projectId}`; + const overviewPath = `${baseProjectPath}/overview`; + const syncPath = `${baseProjectPath}/integrations/secret-syncs/${destination}/${secretSync.id}`; const notifications = [ triggerWorkflowIntegrationNotification({ @@ -944,10 +946,14 @@ export const secretSyncQueueFactory = ({ syncDestination, failureMessage: failureMessage || "An unknown error occurred", syncUrl: `${appCfg.SITE_URL}${syncPath}`, - syncActionLabel: actionLabel + syncActionLabel: actionLabel, + environment: environment?.name || "-", + secretPath: folder?.path || "-", + projectName: project.name, + projectPath: overviewPath } }, - projectId: project.id + projectId }, dependencies: { projectDAL, diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 92684a707..d813cb9ae 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -76,6 +76,7 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo ]; return { + headerBlocks: [], payloadMessage: messageBody, payloadBlocks, color: COMPANY_BRAND_COLOR @@ -83,20 +84,15 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo } case TriggerFeature.ACCESS_REQUEST: { const { payload } = notification; - const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ - payload.isTemporary ? "temporary" : "permanent" - } access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. - -The following permissions are requested: ${payload.permissions.join(", ")} + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}`; + const accessType = payload.isTemporary ? "temporary" : "permanent"; + const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", "); -View the request and approve or deny it <${payload.approvalUrl}|here>.${ - payload.note - ? ` -User Note: ${payload.note}` - : "" + const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${accessType} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${ + payload.note ? `\n\nUser note\n${payload.note}` : "" }`; - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { @@ -104,17 +100,38 @@ User Note: ${payload.note}` text: "New access approval request pending for review", emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", - text: messageBody + text: `*${payload.requesterFullName}* (${payload.requesterEmail}) has requested *${accessType}* access to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${ + payload.note ? `\n\n*User note*\n${payload.note}` : "" + }` } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View request", + emoji: true + }, + style: "primary", + url: payload.approvalUrl + } + ] } ]; return { + headerBlocks, payloadMessage: messageBody, payloadBlocks, color: COMPANY_BRAND_COLOR @@ -125,7 +142,7 @@ User Note: ${payload.note}` const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${ payload.isTemporary ? "temporary" : "permanent" } access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. - + The following permissions are requested: ${payload.permissions.join(", ")} View the request and approve or deny it <${payload.approvalUrl}|here>.${ @@ -154,6 +171,7 @@ Editor Note: ${payload.editNote}` ]; return { + headerBlocks: [], payloadMessage: messageBody, payloadBlocks, color: COMPANY_BRAND_COLOR @@ -161,24 +179,26 @@ Editor Note: ${payload.editNote}` } case TriggerFeature.SECRET_SYNC_ERROR: { const { payload } = notification; - const messageBody = `${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel} + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}`; + const messageBody = `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}\n\n\nEnvironment: ${payload.environment}\n\n\nSecret Path: ${payload.secretPath}\n\n\nProject: ${payload.projectName} (${projectUrl})\n\n\nReason:\n${payload.failureMessage}`; -Sync Error: ${payload.failureMessage}`; - - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { type: "plain_text", - text: `${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`, + text: `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`, emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", - text: `*Sync Error:* ${payload.failureMessage}` + text: `*Environment*\n${payload.environment}\n\n\n*Secret Path*\n${payload.secretPath}\n\n\n*Project*\n<${projectUrl}|${payload.projectName}>\n\n\n*Reason*\n${payload.failureMessage}` } }, { @@ -188,9 +208,10 @@ Sync Error: ${payload.failureMessage}`; type: "button", text: { type: "plain_text", - text: `Open ${payload.syncName}`, + text: "Open secret sync", emoji: true }, + style: "primary", url: payload.syncUrl } ] @@ -199,6 +220,7 @@ Sync Error: ${payload.failureMessage}`; return { payloadMessage: messageBody, + headerBlocks, payloadBlocks, color: ERROR_COLOR }; @@ -227,14 +249,16 @@ export const sendSlackNotification = async ({ }).toString("utf8"); const slackWebClient = new WebClient(botKey); - const { payloadMessage, payloadBlocks, color } = buildSlackPayload(notification); + const { payloadMessage, payloadBlocks, color, headerBlocks } = buildSlackPayload(notification); for await (const conversationId of targetChannelIds) { // we send both text and blocks for compatibility with barebone clients + await slackWebClient.chat .postMessage({ channel: conversationId, text: payloadMessage, + blocks: headerBlocks, attachments: [ { color, diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index 668850124..7c51ae259 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -86,6 +86,8 @@ export type ProjectWorkflowIntegrationConfig = accessRequestChannels: string; isSecretRequestNotificationEnabled: boolean; secretRequestChannels: string; + isSecretSyncErrorNotificationEnabled: boolean; + secretSyncErrorChannels: string; } | { id: string; @@ -112,6 +114,8 @@ export type TUpdateProjectWorkflowIntegrationConfigDTO = accessRequestChannels: string; isSecretRequestNotificationEnabled: boolean; secretRequestChannels: string; + isSecretSyncErrorNotificationEnabled: boolean; + secretSyncErrorChannels: string; } | { integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx index fb48717cc..2dbb43fe6 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx @@ -111,7 +111,7 @@ export const WorkflowIntegrationTab = () => {
Secret Sync Errors
+