From fe096772e0ca905d0e9ca399bef016429e61c7c8 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 31 Aug 2024 02:56:02 +0800 Subject: [PATCH 01/27] feat: initial installation flow --- .env.example | 3 + backend/package-lock.json | 147 +++++++++++++++++ backend/package.json | 1 + backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 16 ++ ...20240830142938_native-slack-integration.ts | 47 ++++++ backend/src/db/schemas/admin-slack-configs.ts | 22 +++ backend/src/db/schemas/index.ts | 2 + backend/src/db/schemas/models.ts | 4 +- backend/src/db/schemas/slack-integrations.ts | 32 ++++ backend/src/lib/config/env.ts | 4 +- backend/src/server/routes/index.ts | 14 +- backend/src/server/routes/v1/index.ts | 2 + backend/src/server/routes/v1/slack-router.ts | 65 ++++++++ .../services/slack/admin-slack-config-dal.ts | 11 ++ .../services/slack/slack-integration-dal.ts | 11 ++ backend/src/services/slack/slack-service.ts | 152 ++++++++++++++++++ backend/src/services/slack/slack-types.ts | 14 ++ frontend/src/hooks/api/slack/queries.tsx | 11 ++ .../ProjectSettingsPage.tsx | 5 + .../NotificationSection/NotificationTab.tsx | 37 +++++ .../components/NotificationSection/index.tsx | 1 + 22 files changed, 600 insertions(+), 3 deletions(-) create mode 100644 backend/src/db/migrations/20240830142938_native-slack-integration.ts create mode 100644 backend/src/db/schemas/admin-slack-configs.ts create mode 100644 backend/src/db/schemas/slack-integrations.ts create mode 100644 backend/src/server/routes/v1/slack-router.ts create mode 100644 backend/src/services/slack/admin-slack-config-dal.ts create mode 100644 backend/src/services/slack/slack-integration-dal.ts create mode 100644 backend/src/services/slack/slack-service.ts create mode 100644 backend/src/services/slack/slack-types.ts create mode 100644 frontend/src/hooks/api/slack/queries.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx diff --git a/.env.example b/.env.example index be7e0a621..e8f85c080 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,6 @@ PLAIN_API_KEY= PLAIN_WISH_LABEL_IDS= SSL_CLIENT_CERTIFICATE_HEADER_KEY= + +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= diff --git a/backend/package-lock.json b/backend/package-lock.json index b3eef6bd1..395e4ddd5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -33,6 +33,7 @@ "@peculiar/x509": "^1.12.1", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", + "@slack/oauth": "^3.0.1", "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", @@ -5868,6 +5869,78 @@ "node": ">=8" } }, + "node_modules/@slack/logger": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", + "integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==", + "dependencies": { + "@types/node": ">=18.0.0" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/oauth": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-3.0.1.tgz", + "integrity": "sha512-TuR9PI6bYKX6qHC7FQI4keMnhj45TNfSNQtTU3mtnHUX4XLM2dYLvRkUNADyiLTle2qu2rsOQtCIsZJw6H0sDA==", + "dependencies": { + "@slack/logger": "^4", + "@slack/web-api": "^7.3.4", + "@types/jsonwebtoken": "^9", + "@types/node": ">=18", + "jsonwebtoken": "^9", + "lodash.isstring": "^4" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.12.0.tgz", + "integrity": "sha512-yFewzUomYZ2BYaGJidPuIgjoYj5wqPDmi7DLSaGIkf+rCi4YZ2Z3DaiYIbz7qb/PL2NmamWjCvB7e9ArI5HkKg==", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.3.4.tgz", + "integrity": "sha512-KwLK8dlz2lhr3NO7kbYQ7zgPTXPKrhq1JfQc0etJ0K8LSJhYYnf8GbVznvgDT/Uz1/pBXfFQnoXjrQIOKAdSuw==", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/types": "^2.9.0", + "@types/node": ">=18.0.0", + "@types/retry": "0.12.0", + "axios": "^1.7.4", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.0", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/web-api/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@smithy/abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.1.tgz", @@ -7073,6 +7146,11 @@ "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", "dev": true }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, "node_modules/@types/safe-regex": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@types/safe-regex/-/safe-regex-1.1.6.tgz", @@ -10251,6 +10329,11 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -11998,6 +12081,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -13861,6 +13949,14 @@ "node": ">=14.6" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "engines": { + "node": ">=4" + } + }, "node_modules/p-is-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", @@ -13899,6 +13995,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-throttle": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-5.1.0.tgz", @@ -13910,6 +14038,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -15260,6 +15399,14 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", diff --git a/backend/package.json b/backend/package.json index 86d92c086..813a6dc1c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -130,6 +130,7 @@ "@peculiar/x509": "^1.12.1", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", + "@slack/oauth": "^3.0.1", "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 49a11691c..de64e58ea 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -70,6 +70,7 @@ import { TSecretReplicationServiceFactory } from "@app/services/secret-replicati import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service"; +import { TSlackServiceFactory } from "@app/services/slack/slack-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -177,6 +178,7 @@ declare module "fastify" { userEngagement: TUserEngagementServiceFactory; externalKms: TExternalKmsServiceFactory; orgAdmin: TOrgAdminServiceFactory; + slack: TSlackServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f689f3357..dad478d0e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -14,6 +14,9 @@ import { TAccessApprovalRequestsReviewersInsert, TAccessApprovalRequestsReviewersUpdate, TAccessApprovalRequestsUpdate, + TAdminSlackConfigs, + TAdminSlackConfigsInsert, + TAdminSlackConfigsUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, @@ -299,6 +302,9 @@ import { TServiceTokens, TServiceTokensInsert, TServiceTokensUpdate, + TSlackIntegrations, + TSlackIntegrationsInsert, + TSlackIntegrationsUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -776,5 +782,15 @@ declare module "knex/types/tables" { TKmsKeyVersionsInsert, TKmsKeyVersionsUpdate >; + [TableName.SlackIntegrations]: KnexOriginal.CompositeTableType< + TSlackIntegrations, + TSlackIntegrationsInsert, + TSlackIntegrationsUpdate + >; + [TableName.AdminSlackConfig]: KnexOriginal.CompositeTableType< + TAdminSlackConfigs, + TAdminSlackConfigsInsert, + TAdminSlackConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20240830142938_native-slack-integration.ts b/backend/src/db/migrations/20240830142938_native-slack-integration.ts new file mode 100644 index 000000000..01d88989b --- /dev/null +++ b/backend/src/db/migrations/20240830142938_native-slack-integration.ts @@ -0,0 +1,47 @@ +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.SlackIntegrations))) { + await knex.schema.createTable(TableName.SlackIntegrations, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("projectId").notNullable().unique(); + tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + tb.string("teamId").notNullable(); + tb.string("teamName").notNullable(); + tb.string("slackUserId").notNullable(); + tb.string("slackAppId").notNullable(); + tb.binary("encryptedBotAccessToken").notNullable(); + tb.string("slackBotId").notNullable(); + tb.string("slackBotUserId").notNullable(); + tb.boolean("isAccessRequestNotificationEnabled").defaultTo(false); + tb.string("accessRequestChannels"); + tb.boolean("isSecretRequestNotificationEnabled").defaultTo(false); + tb.string("secretRequestChannels"); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SlackIntegrations); + } + + if (!(await knex.schema.hasTable(TableName.AdminSlackConfig))) { + await knex.schema.createTable(TableName.AdminSlackConfig, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.binary("encryptedClientId").notNullable(); + tb.binary("encryptedClientSecret").notNullable(); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.AdminSlackConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SlackIntegrations); + await dropOnUpdateTrigger(knex, TableName.SlackIntegrations); + + await knex.schema.dropTableIfExists(TableName.AdminSlackConfig); + await dropOnUpdateTrigger(knex, TableName.AdminSlackConfig); +} diff --git a/backend/src/db/schemas/admin-slack-configs.ts b/backend/src/db/schemas/admin-slack-configs.ts new file mode 100644 index 000000000..987a1af3d --- /dev/null +++ b/backend/src/db/schemas/admin-slack-configs.ts @@ -0,0 +1,22 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AdminSlackConfigsSchema = z.object({ + id: z.string().uuid(), + encryptedClientId: zodBuffer, + encryptedClientSecret: zodBuffer, + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAdminSlackConfigs = z.infer; +export type TAdminSlackConfigsInsert = Omit, TImmutableDBKeys>; +export type TAdminSlackConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 2a089df2b..aaa4e02b8 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -2,6 +2,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 "./admin-slack-configs"; export * from "./api-keys"; export * from "./audit-log-streams"; export * from "./audit-logs"; @@ -101,6 +102,7 @@ export * from "./secret-versions-v2"; export * from "./secrets"; export * from "./secrets-v2"; export * from "./service-tokens"; +export * from "./slack-integrations"; export * from "./super-admin"; export * from "./trusted-ips"; export * from "./user-actions"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ba9dfcae1..fa2c80f7c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -114,7 +114,9 @@ export enum TableName { InternalKms = "internal_kms", InternalKmsKeyVersion = "internal_kms_key_version", // @depreciated - KmsKeyVersion = "kms_key_versions" + KmsKeyVersion = "kms_key_versions", + SlackIntegrations = "slack_integrations", + AdminSlackConfig = "admin_slack_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/db/schemas/slack-integrations.ts b/backend/src/db/schemas/slack-integrations.ts new file mode 100644 index 000000000..39e00c22b --- /dev/null +++ b/backend/src/db/schemas/slack-integrations.ts @@ -0,0 +1,32 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SlackIntegrationsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + teamId: z.string(), + teamName: z.string(), + slackUserId: z.string(), + slackAppId: z.string(), + encryptedBotAccessToken: zodBuffer, + slackBotId: z.string(), + slackBotUserId: z.string(), + isAccessRequestNotificationEnabled: z.boolean().nullable().optional(), + accessRequestChannels: z.string().nullable().optional(), + isSecretRequestNotificationEnabled: z.boolean().nullable().optional(), + secretRequestChannels: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSlackIntegrations = z.infer; +export type TSlackIntegrationsInsert = Omit, TImmutableDBKeys>; +export type TSlackIntegrationsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index f21312311..3edfb10f1 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -146,7 +146,9 @@ const envSchema = z PLAIN_API_KEY: zpStr(z.string().optional()), PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()), DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"), - SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert") + SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"), + SLACK_CLIENT_ID: zpStr(z.string()).optional(), + SLACK_CLIENT_SECRET: zpStr(z.string()).optional() }) .transform((data) => ({ ...data, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f33456bd5..8d740d1f0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -182,6 +182,8 @@ import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/ import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service"; +import { slackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal"; +import { slackServiceFactory } from "@app/services/slack/slack-service"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; @@ -322,6 +324,8 @@ export const registerRoutes = async ( const externalKmsDAL = externalKmsDALFactory(db); const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const slackIntegrationDAL = slackIntegrationDALFactory(db); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -1150,6 +1154,13 @@ export const registerRoutes = async ( userDAL }); + const slackService = slackServiceFactory({ + projectDAL, + permissionService, + kmsService, + slackIntegrationDAL + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -1231,7 +1242,8 @@ export const registerRoutes = async ( secretSharing: secretSharingService, userEngagement: userEngagementService, externalKms: externalKmsService, - orgAdmin: orgAdminService + orgAdmin: orgAdminService, + slack: slackService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index e5f35565f..05c6ae7e5 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -29,6 +29,7 @@ import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; +import { registerSlackRouter } from "./slack-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; import { registerUserEngagementRouter } from "./user-engagement-router"; @@ -60,6 +61,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); await server.register(registerSecretFolderRouter, { prefix: "/folders" }); + await server.register(registerSlackRouter, { prefix: "/slack" }); await server.register( async (projectRouter) => { diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts new file mode 100644 index 000000000..e5fc3e782 --- /dev/null +++ b/backend/src/server/routes/v1/slack-router.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSlackRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + server.route({ + method: "GET", + url: "/install", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.string() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.slack.getInstallUrl({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId + }); + } + }); + + server.route({ + method: "GET", + url: "/oauth_redirect", + config: { + rateLimit: readLimit + }, + handler: async (req, res) => { + const installer = await server.services.slack.getSlackInstaller(); + + return installer.handleCallback(req.raw, res.raw, { + failureAsync: async () => { + return res.redirect(appCfg.SITE_URL as string); + }, + successAsync: async (installation) => { + const metadata = JSON.parse(installation.metadata || "") as { + projectId: string; + }; + + return res.redirect(`${appCfg.SITE_URL}/project/${metadata.projectId}/settings`); + } + }); + } + }); +}; diff --git a/backend/src/services/slack/admin-slack-config-dal.ts b/backend/src/services/slack/admin-slack-config-dal.ts new file mode 100644 index 000000000..b741bf685 --- /dev/null +++ b/backend/src/services/slack/admin-slack-config-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 TAdminSlackConfigDALFactory = ReturnType; + +export const adminSlackConfigDALFactory = (db: TDbClient) => { + const adminSlackConfigOrm = ormify(db, TableName.AdminSlackConfig); + + return adminSlackConfigOrm; +}; diff --git a/backend/src/services/slack/slack-integration-dal.ts b/backend/src/services/slack/slack-integration-dal.ts new file mode 100644 index 000000000..8c9b781bf --- /dev/null +++ b/backend/src/services/slack/slack-integration-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 TSlackIntegrationDALFactory = ReturnType; + +export const slackIntegrationDALFactory = (db: TDbClient) => { + const slackIntegrationOrm = ormify(db, TableName.SlackIntegrations); + + return slackIntegrationOrm; +}; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts new file mode 100644 index 000000000..e98a28bdc --- /dev/null +++ b/backend/src/services/slack/slack-service.ts @@ -0,0 +1,152 @@ +import { ForbiddenError } from "@casl/ability"; +import { InstallProvider } from "@slack/oauth"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +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 { TCompleteSlackIntegrationDTO, TGetSlackInstallUrlDTO } from "./slack-types"; + +type TSlackServiceFactoryDep = { + slackIntegrationDAL: Pick; + permissionService: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TSlackServiceFactory = ReturnType; + +export const slackServiceFactory = ({ + projectDAL, + permissionService, + slackIntegrationDAL, + kmsService +}: TSlackServiceFactoryDep) => { + const completeSlackIntegration = async ({ + projectId, + teamId, + teamName, + slackUserId, + slackAppId, + botAccessToken, + slackBotId, + slackBotUserId + }: TCompleteSlackIntegrationDTO) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: "Project not found" + }); + } + + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: project.orgId + }); + + const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({ + plainText: Buffer.from(botAccessToken, "utf8") + }); + + await slackIntegrationDAL.create({ + projectId, + teamId, + teamName, + slackUserId, + slackAppId, + slackBotId, + slackBotUserId, + encryptedBotAccessToken + }); + }; + + const getSlackInstaller = async () => { + const appCfg = getConfig(); + + if (!appCfg.SLACK_CLIENT_ID || !appCfg.SLACK_CLIENT_SECRET) { + throw new BadRequestError({ + message: "Invalid slack configuration" + }); + } + + return new InstallProvider({ + clientId: appCfg.SLACK_CLIENT_ID, + clientSecret: appCfg.SLACK_CLIENT_SECRET, + stateSecret: appCfg.AUTH_SECRET, + legacyStateVerification: true, + installationStore: { + storeInstallation: async (installation) => { + if (installation.isEnterpriseInstall && installation.enterprise?.id) { + throw new BadRequestError({ + message: "Enterprise not yet supported" + }); + } + + const metadata = JSON.parse(installation.metadata || "") as { + projectId: string; + }; + + return completeSlackIntegration({ + projectId: metadata.projectId, + teamId: installation.team?.id || "", + teamName: installation.team?.name || "", + slackUserId: installation.user.id, + slackAppId: installation.appId || "", + botAccessToken: installation.bot?.token || "", + slackBotId: installation.bot?.id || "", + slackBotUserId: installation.bot?.userId || "" + }); + }, + fetchInstallation: () => { + return {} as never; + }, + deleteInstallation: () => { + return {} as never; + } + } + }); + }; + + const getInstallUrl = async ({ actorId, actor, actorOrgId, actorAuthMethod, projectId }: TGetSlackInstallUrlDTO) => { + const appCfg = getConfig(); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: "Project not found" + }); + } + + const installer = await getSlackInstaller(); + const url = await installer.generateInstallUrl({ + scopes: ["chat:write"], + metadata: JSON.stringify({ + projectId: project.id + }), + redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect` + }); + + // TODO: add audit log here + + return url; + }; + + return { + getInstallUrl, + completeSlackIntegration, + getSlackInstaller + }; +}; diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts new file mode 100644 index 000000000..571e30a74 --- /dev/null +++ b/backend/src/services/slack/slack-types.ts @@ -0,0 +1,14 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetSlackInstallUrlDTO = TProjectPermission; + +export type TCompleteSlackIntegrationDTO = { + projectId: string; + teamId: string; + teamName: string; + slackUserId: string; + slackAppId: string; + botAccessToken: string; + slackBotId: string; + slackBotUserId: string; +}; diff --git a/frontend/src/hooks/api/slack/queries.tsx b/frontend/src/hooks/api/slack/queries.tsx new file mode 100644 index 000000000..f2dbdc6be --- /dev/null +++ b/frontend/src/hooks/api/slack/queries.tsx @@ -0,0 +1,11 @@ +import { apiRequest } from "@app/config/request"; + +export const fetchSlackInstallUrl = async (workspaceId?: string) => { + const { data } = await apiRequest.get("/api/v1/slack/install", { + params: { + projectId: workspaceId + } + }); + + return data; +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 388814108..4c15e8504 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -6,6 +6,7 @@ import { useWorkspace } from "@app/context"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { EncryptionTab } from "./components/EncryptionTab"; +import { NotificationTab } from "./components/NotificationSection"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { WebhooksTab } from "./components/WebhooksTab"; @@ -19,6 +20,7 @@ export const ProjectSettingsPage = () => { key: "tab-project-encryption", isHidden: currentWorkspace?.version !== ProjectVersion.V3 }, + { name: "Notification", key: "tab-project-notification" }, { name: "Webhooks", key: "tab-project-webhooks" } ]; @@ -56,6 +58,9 @@ export const ProjectSettingsPage = () => { )} + + + diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx new file mode 100644 index 000000000..e18fd8281 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx @@ -0,0 +1,37 @@ +import { useRouter } from "next/router"; + +import { Button } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { fetchSlackInstallUrl } from "@app/hooks/api/slack/queries"; + +export const NotificationTab = () => { + const { currentWorkspace } = useWorkspace(); + const router = useRouter(); + const [isConnectToSlackLoading, setIsConnectToSlackLoading] = useToggle(false); + + return ( +
+
+

Slack Integration

+
+ +

+ This integration allows you send notifications to your Slack workspace in response to events + in your project. +

+ +
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx new file mode 100644 index 000000000..aafa62ff5 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx @@ -0,0 +1 @@ +export * from "./NotificationTab"; From d604ef2480a355d4df52389408fe1977ab66887a Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 2 Sep 2024 15:38:05 +0800 Subject: [PATCH 02/27] 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" +} From f6d3831d6d7af6cef608b864392976bd04256a3e Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 2 Sep 2024 20:13:01 +0800 Subject: [PATCH 03/27] feat: finished slack integration update --- ...20240830142938_native-slack-integration.ts | 4 +- backend/src/server/routes/v1/slack-router.ts | 91 +++++++- .../services/slack/slack-auth-validators.ts | 13 ++ backend/src/services/slack/slack-fns.ts | 4 +- backend/src/services/slack/slack-service.ts | 73 +++++- backend/src/services/slack/slack-types.ts | 10 + frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/slack/index.ts | 2 + frontend/src/hooks/api/slack/mutation.tsx | 21 ++ frontend/src/hooks/api/slack/queries.tsx | 28 +++ frontend/src/hooks/api/slack/types.ts | 17 ++ .../NotificationSection/NotificationTab.tsx | 212 ++++++++++++++++-- 12 files changed, 448 insertions(+), 28 deletions(-) create mode 100644 backend/src/services/slack/slack-auth-validators.ts create mode 100644 frontend/src/hooks/api/slack/index.ts create mode 100644 frontend/src/hooks/api/slack/mutation.tsx create mode 100644 frontend/src/hooks/api/slack/types.ts diff --git a/backend/src/db/migrations/20240830142938_native-slack-integration.ts b/backend/src/db/migrations/20240830142938_native-slack-integration.ts index 01d88989b..d4fb22241 100644 --- a/backend/src/db/migrations/20240830142938_native-slack-integration.ts +++ b/backend/src/db/migrations/20240830142938_native-slack-integration.ts @@ -17,9 +17,9 @@ export async function up(knex: Knex): Promise { tb.string("slackBotId").notNullable(); tb.string("slackBotUserId").notNullable(); tb.boolean("isAccessRequestNotificationEnabled").defaultTo(false); - tb.string("accessRequestChannels"); + tb.string("accessRequestChannels").defaultTo(""); tb.boolean("isSecretRequestNotificationEnabled").defaultTo(false); - tb.string("secretRequestChannels"); + tb.string("secretRequestChannels").defaultTo(""); tb.timestamps(true, true, true); }); diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index e5fc3e782..57fcd5ec3 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -1,9 +1,11 @@ import { z } from "zod"; +import { SlackIntegrationsSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { readLimit } from "@app/server/config/rateLimiter"; +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 { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; export const registerSlackRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -27,8 +29,9 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { 200: z.string() } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + // TODO: add audit logs return server.services.slack.getInstallUrl({ actor: req.permission.type, actorId: req.permission.id, @@ -39,6 +42,90 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + teamName: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.slack.getSlackIntegrationByProjectId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId + }); + } + }); + + server.route({ + method: "PATCH", + url: "/:slackIntegrationId", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + body: z.object({ + isAccessRequestNotificationEnabled: z.boolean().optional(), + accessRequestChannels: validateSlackChannelsField.optional(), + isSecretRequestNotificationEnabled: z.boolean().optional(), + secretRequestChannels: validateSlackChannelsField.optional() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + teamName: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + // TODO: add audit logs + return server.services.slack.updateSlackIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId, + ...req.body + }); + } + }); + server.route({ method: "GET", url: "/oauth_redirect", diff --git a/backend/src/services/slack/slack-auth-validators.ts b/backend/src/services/slack/slack-auth-validators.ts new file mode 100644 index 000000000..d81d12224 --- /dev/null +++ b/backend/src/services/slack/slack-auth-validators.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +export const validateSlackChannelsField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 3edb08f14..5c22186c6 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -34,12 +34,12 @@ export const triggerSlackNotification = async ({ let targetChannels: string[] = []; if (feature === SlackTriggerFeature.ACCESS_REQUEST) { - targetChannels = slackIntegration.accessRequestChannels?.split(",") || []; + targetChannels = slackIntegration.accessRequestChannels?.split(", ") || []; if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) { return; } } else if (feature === SlackTriggerFeature.SECRET_APPROVAL) { - targetChannels = slackIntegration.secretRequestChannels?.split(",") || []; + targetChannels = slackIntegration.secretRequestChannels?.split(", ") || []; if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) { return; } diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index b5627c3ba..347d2acd3 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -10,10 +10,15 @@ 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 { TCompleteSlackIntegrationDTO, TGetSlackInstallUrlDTO } from "./slack-types"; +import { + TCompleteSlackIntegrationDTO, + TGetSlackInstallUrlDTO, + TGetSlackIntegrationByProjectId, + TUpdateSlackIntegration +} from "./slack-types"; type TSlackServiceFactoryDep = { - slackIntegrationDAL: Pick; + slackIntegrationDAL: Pick; permissionService: Pick; projectDAL: Pick; kmsService: Pick; @@ -142,14 +147,72 @@ export const slackServiceFactory = ({ redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect` }); - // TODO: add audit log here - return url; }; + const getSlackIntegrationByProjectId = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetSlackIntegrationByProjectId) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + const slackIntegration = await slackIntegrationDAL.findOne({ + projectId + }); + + return slackIntegration; + }; + + const updateSlackIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + isAccessRequestNotificationEnabled, + accessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels + }: TUpdateSlackIntegration) => { + const slackIntegration = await slackIntegrationDAL.findById(id); + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found" + }); + } + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + slackIntegration.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + return slackIntegrationDAL.updateById(slackIntegration.id, { + isAccessRequestNotificationEnabled, + accessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels + }); + }; + return { getInstallUrl, + getSlackIntegrationByProjectId, completeSlackIntegration, - getSlackInstaller + getSlackInstaller, + updateSlackIntegration }; }; diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index b76ce1e82..c9f0127c0 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -2,6 +2,16 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetSlackInstallUrlDTO = TProjectPermission; +export type TGetSlackIntegrationByProjectId = TProjectPermission; + +export type TUpdateSlackIntegration = { + id: string; + isAccessRequestNotificationEnabled?: boolean; + accessRequestChannels?: string; + isSecretRequestNotificationEnabled?: boolean; + secretRequestChannels?: string; +} & Omit; + export type TCompleteSlackIntegrationDTO = { projectId: string; teamId: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index a9d24a5c7..57644b616 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -38,6 +38,7 @@ export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; +export * from "./slack"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/slack/index.ts b/frontend/src/hooks/api/slack/index.ts new file mode 100644 index 000000000..e015740d7 --- /dev/null +++ b/frontend/src/hooks/api/slack/index.ts @@ -0,0 +1,2 @@ +export { useUpdateSlackIntegration } from "./mutation"; +export { fetchSlackInstallUrl, useGetSlackIntegrationByProject } from "./queries"; diff --git a/frontend/src/hooks/api/slack/mutation.tsx b/frontend/src/hooks/api/slack/mutation.tsx new file mode 100644 index 000000000..338952aef --- /dev/null +++ b/frontend/src/hooks/api/slack/mutation.tsx @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { slackKeys } from "./queries"; +import { TUpdateSlackIntegrationDTO } from "./types"; + +export const useUpdateSlackIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSlackIntegrationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch(`/api/v1/slack/${dto.id}`, dto); + + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(slackKeys.getSlackIntegrationByProject(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/slack/queries.tsx b/frontend/src/hooks/api/slack/queries.tsx index f2dbdc6be..3b82793f9 100644 --- a/frontend/src/hooks/api/slack/queries.tsx +++ b/frontend/src/hooks/api/slack/queries.tsx @@ -1,5 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; + import { apiRequest } from "@app/config/request"; +import { ProjectSlackIntegration } from "./types"; + +export const slackKeys = { + getSlackIntegrationByProject: (workspaceId?: string) => [ + { workspaceId }, + "slack-integration-by-project" + ] +}; + export const fetchSlackInstallUrl = async (workspaceId?: string) => { const { data } = await apiRequest.get("/api/v1/slack/install", { params: { @@ -9,3 +20,20 @@ export const fetchSlackInstallUrl = async (workspaceId?: string) => { return data; }; + +export const fetchSlackIntegrationByProject = async (workspaceId?: string) => { + const { data } = await apiRequest.get("/api/v1/slack", { + params: { + projectId: workspaceId + } + }); + + return data; +}; + +export const useGetSlackIntegrationByProject = (workspaceId?: string) => + useQuery({ + queryKey: slackKeys.getSlackIntegrationByProject(workspaceId), + queryFn: () => fetchSlackIntegrationByProject(workspaceId), + enabled: Boolean(workspaceId) + }); diff --git a/frontend/src/hooks/api/slack/types.ts b/frontend/src/hooks/api/slack/types.ts new file mode 100644 index 000000000..9453c5d75 --- /dev/null +++ b/frontend/src/hooks/api/slack/types.ts @@ -0,0 +1,17 @@ +export type ProjectSlackIntegration = { + id: string; + teamName: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; +}; + +export type TUpdateSlackIntegrationDTO = { + id: string; + workspaceId: string; + isAccessRequestNotificationEnabled?: boolean; + accessRequestChannels?: string; + isSecretRequestNotificationEnabled?: boolean; + secretRequestChannels?: string; +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx index e18fd8281..da77b91b0 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx @@ -1,37 +1,215 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { useRouter } from "next/router"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; -import { Button } from "@app/components/v2"; +import { createNotification } from "@app/components/notifications"; +import { Button, ContentLoader, FormControl, Input, Switch } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; -import { fetchSlackInstallUrl } from "@app/hooks/api/slack/queries"; +import { + fetchSlackInstallUrl, + useGetSlackIntegrationByProject, + useUpdateSlackIntegration +} from "@app/hooks/api"; + +const formSchema = z.object({ + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.string(), + isAccessRequestNotificationEnabled: z.boolean(), + accessRequestChannels: z.string() +}); + +type TSlackIntegrationForm = z.infer; export const NotificationTab = () => { const { currentWorkspace } = useWorkspace(); + const { data: slackIntegration, isLoading: isSlackIntegrationLoading } = + useGetSlackIntegrationByProject(currentWorkspace?.id); + const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration(); + const { + control, + watch, + handleSubmit, + setValue, + formState: { isDirty, isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + isSecretRequestNotificationEnabled: slackIntegration?.isSecretRequestNotificationEnabled, + secretRequestChannels: slackIntegration?.secretRequestChannels || "", + isAccessRequestNotificationEnabled: slackIntegration?.isAccessRequestNotificationEnabled, + accessRequestChannels: slackIntegration?.accessRequestChannels || "" + } + }); + const router = useRouter(); const [isConnectToSlackLoading, setIsConnectToSlackLoading] = useToggle(false); + const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); + const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); + + const handleIntegrationSave = async (data: TSlackIntegrationForm) => { + if (!currentWorkspace || !slackIntegration) { + return; + } + await updateSlackIntegration({ + workspaceId: currentWorkspace?.id, + id: slackIntegration?.id, + ...data + }); + + createNotification({ + type: "success", + text: "Successfully updated slack integration" + }); + }; + + useEffect(() => { + if (slackIntegration) { + setValue( + "isSecretRequestNotificationEnabled", + slackIntegration.isSecretRequestNotificationEnabled + ); + setValue("secretRequestChannels", slackIntegration.secretRequestChannels); + setValue( + "isAccessRequestNotificationEnabled", + slackIntegration.isAccessRequestNotificationEnabled + ); + setValue("accessRequestChannels", slackIntegration.accessRequestChannels); + } + }, [slackIntegration]); + + if (isSlackIntegrationLoading) { + return ; + } return (

Slack Integration

-

- This integration allows you send notifications to your Slack workspace in response to events - in your project. + This integration allows you to send notifications to your Slack workspace in response to + events in your project.

- + {!slackIntegration && ( + + )} + {slackIntegration && ( +
+
Connected Slack workspace: {slackIntegration.teamName}
+
+ + +
+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotifState && ( + ( + + + + )} + /> + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotifState && ( + ( + + + + )} + /> + )} + + + )}
); }; From 07fd489982c60c7533af4b82cdbcecac8a803753 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 2 Sep 2024 20:34:13 +0800 Subject: [PATCH 04/27] feat: slack integration deletion --- backend/src/server/routes/v1/slack-router.ts | 40 +++ backend/src/services/slack/slack-service.ts | 43 ++- backend/src/services/slack/slack-types.ts | 8 +- frontend/src/hooks/api/slack/index.ts | 2 +- frontend/src/hooks/api/slack/mutation.tsx | 17 +- frontend/src/hooks/api/slack/types.ts | 5 + .../NotificationSection/NotificationTab.tsx | 296 ++++++++++-------- 7 files changed, 276 insertions(+), 135 deletions(-) diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 57fcd5ec3..ac4800ccb 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -126,6 +126,46 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/:slackIntegrationId", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + teamName: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + // TODO: add audit logs + + return server.services.slack.deleteSlackIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId + }); + } + }); + server.route({ method: "GET", url: "/oauth_redirect", diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index 347d2acd3..d4a2dd81b 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -12,13 +12,17 @@ import { TProjectDALFactory } from "../project/project-dal"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TCompleteSlackIntegrationDTO, + TDeleteSlackIntegrationDTO, TGetSlackInstallUrlDTO, - TGetSlackIntegrationByProjectId, - TUpdateSlackIntegration + TGetSlackIntegrationByProjectIdDTO, + TUpdateSlackIntegrationDTO } from "./slack-types"; type TSlackServiceFactoryDep = { - slackIntegrationDAL: Pick; + slackIntegrationDAL: Pick< + TSlackIntegrationDALFactory, + "create" | "findOne" | "findById" | "updateById" | "deleteById" + >; permissionService: Pick; projectDAL: Pick; kmsService: Pick; @@ -156,7 +160,7 @@ export const slackServiceFactory = ({ actorOrgId, actorAuthMethod, projectId - }: TGetSlackIntegrationByProjectId) => { + }: TGetSlackIntegrationByProjectIdDTO) => { const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -183,7 +187,7 @@ export const slackServiceFactory = ({ accessRequestChannels, isSecretRequestNotificationEnabled, secretRequestChannels - }: TUpdateSlackIntegration) => { + }: TUpdateSlackIntegrationDTO) => { const slackIntegration = await slackIntegrationDAL.findById(id); if (!slackIntegration) { throw new NotFoundError({ @@ -208,11 +212,38 @@ export const slackServiceFactory = ({ }); }; + const deleteSlackIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TDeleteSlackIntegrationDTO) => { + const slackIntegration = await slackIntegrationDAL.findById(id); + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found" + }); + } + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + slackIntegration.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + + return slackIntegrationDAL.deleteById(id); + }; + return { getInstallUrl, getSlackIntegrationByProjectId, completeSlackIntegration, getSlackInstaller, - updateSlackIntegration + updateSlackIntegration, + deleteSlackIntegration }; }; diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index c9f0127c0..55d836662 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -2,9 +2,9 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetSlackInstallUrlDTO = TProjectPermission; -export type TGetSlackIntegrationByProjectId = TProjectPermission; +export type TGetSlackIntegrationByProjectIdDTO = TProjectPermission; -export type TUpdateSlackIntegration = { +export type TUpdateSlackIntegrationDTO = { id: string; isAccessRequestNotificationEnabled?: boolean; accessRequestChannels?: string; @@ -12,6 +12,10 @@ export type TUpdateSlackIntegration = { secretRequestChannels?: string; } & Omit; +export type TDeleteSlackIntegrationDTO = { + id: string; +} & Omit; + export type TCompleteSlackIntegrationDTO = { projectId: string; teamId: string; diff --git a/frontend/src/hooks/api/slack/index.ts b/frontend/src/hooks/api/slack/index.ts index e015740d7..73df6a19c 100644 --- a/frontend/src/hooks/api/slack/index.ts +++ b/frontend/src/hooks/api/slack/index.ts @@ -1,2 +1,2 @@ -export { useUpdateSlackIntegration } from "./mutation"; +export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation"; export { fetchSlackInstallUrl, useGetSlackIntegrationByProject } from "./queries"; diff --git a/frontend/src/hooks/api/slack/mutation.tsx b/frontend/src/hooks/api/slack/mutation.tsx index 338952aef..67f34d9a4 100644 --- a/frontend/src/hooks/api/slack/mutation.tsx +++ b/frontend/src/hooks/api/slack/mutation.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { slackKeys } from "./queries"; -import { TUpdateSlackIntegrationDTO } from "./types"; +import { TDeleteSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./types"; export const useUpdateSlackIntegration = () => { const queryClient = useQueryClient(); @@ -19,3 +19,18 @@ export const useUpdateSlackIntegration = () => { } }); }; + +export const useDeleteSlackIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSlackIntegrationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete(`/api/v1/slack/${dto.id}`); + + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(slackKeys.getSlackIntegrationByProject(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/slack/types.ts b/frontend/src/hooks/api/slack/types.ts index 9453c5d75..9adad3f1b 100644 --- a/frontend/src/hooks/api/slack/types.ts +++ b/frontend/src/hooks/api/slack/types.ts @@ -15,3 +15,8 @@ export type TUpdateSlackIntegrationDTO = { isSecretRequestNotificationEnabled?: boolean; secretRequestChannels?: string; }; + +export type TDeleteSlackIntegrationDTO = { + id: string; + workspaceId: string; +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx index da77b91b0..9f0dadaaf 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx @@ -5,11 +5,19 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, ContentLoader, FormControl, Input, Switch } from "@app/components/v2"; +import { + Button, + ContentLoader, + DeleteActionModal, + FormControl, + Input, + Switch +} from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { useToggle } from "@app/hooks"; +import { usePopUp, useToggle } from "@app/hooks"; import { fetchSlackInstallUrl, + useDeleteSlackIntegration, useGetSlackIntegrationByProject, useUpdateSlackIntegration } from "@app/hooks/api"; @@ -28,6 +36,11 @@ export const NotificationTab = () => { const { data: slackIntegration, isLoading: isSlackIntegrationLoading } = useGetSlackIntegrationByProject(currentWorkspace?.id); const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration(); + const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration(); + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([ + "deleteSlackIntegration" + ] as const); + const { control, watch, @@ -65,6 +78,23 @@ export const NotificationTab = () => { }); }; + const handleIntegrationDelete = async () => { + if (!currentWorkspace || !slackIntegration) { + return; + } + await deleteSlackIntegration({ + workspaceId: currentWorkspace.id, + id: slackIntegration.id + }); + + handlePopUpToggle("deleteSlackIntegration", false); + + createNotification({ + type: "success", + text: "Successfully deleted slack integration" + }); + }; + useEffect(() => { if (slackIntegration) { setValue( @@ -85,131 +115,147 @@ export const NotificationTab = () => { } return ( -
-
-

Slack Integration

-
-

- This integration allows you to send notifications to your Slack workspace in response to - events in your project. -

- {!slackIntegration && ( - - )} - {slackIntegration && ( -
-
Connected Slack workspace: {slackIntegration.teamName}
-
- - -
- { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Secret Approval Requests

-
-
- ); - }} - /> - {secretRequestNotifState && ( - ( - - - - )} - /> - )} - { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Access Requests

-
-
- ); - }} - /> - {accessRequestNotifState && ( - ( - - - - )} - /> - )} + <> +
+
+

+ Slack Integration +

+
+

+ This integration allows you to send notifications to your Slack workspace in response to + events in your project. +

+ {!slackIntegration && ( - - )} -
+ )} + {slackIntegration && ( +
+
Connected Slack workspace: {slackIntegration.teamName}
+
+ + +
+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotifState && ( + ( + + + + )} + /> + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotifState && ( + ( + + + + )} + /> + )} + + + )} +
+ handlePopUpToggle("deleteSlackIntegration", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleIntegrationDelete} + /> + ); }; From f509464947e42d3626953e7e293969770f1d639e Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 2 Sep 2024 21:05:30 +0800 Subject: [PATCH 05/27] slack integration reinstall --- backend/src/services/slack/slack-service.ts | 48 +++++++++++++++---- .../NotificationSection/NotificationTab.tsx | 26 +++++++--- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index d4a2dd81b..aa72d0d82 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -21,7 +21,7 @@ import { type TSlackServiceFactoryDep = { slackIntegrationDAL: Pick< TSlackIntegrationDALFactory, - "create" | "findOne" | "findById" | "updateById" | "deleteById" + "create" | "findOne" | "findById" | "updateById" | "deleteById" | "transaction" >; permissionService: Pick; projectDAL: Pick; @@ -62,15 +62,43 @@ export const slackServiceFactory = ({ plainText: Buffer.from(botAccessToken, "utf8") }); - await slackIntegrationDAL.create({ - projectId, - teamId, - teamName, - slackUserId, - slackAppId, - slackBotId, - slackBotUserId, - encryptedBotAccessToken + await slackIntegrationDAL.transaction(async (tx) => { + const slackIntegration = await slackIntegrationDAL.findOne( + { + projectId + }, + tx + ); + + if (slackIntegration) { + return slackIntegrationDAL.updateById( + slackIntegration.id, + { + teamId, + teamName, + slackUserId, + slackAppId, + slackBotId, + slackBotUserId, + encryptedBotAccessToken + }, + tx + ); + } + + return slackIntegrationDAL.create( + { + projectId, + teamId, + teamName, + slackUserId, + slackAppId, + slackBotId, + slackBotUserId, + encryptedBotAccessToken + }, + tx + ); }); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx index 9f0dadaaf..3a6ae45fd 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx @@ -59,6 +59,7 @@ export const NotificationTab = () => { const router = useRouter(); const [isConnectToSlackLoading, setIsConnectToSlackLoading] = useToggle(false); + const [isReinstallLoading, setIsReinstallLoading] = useToggle(false); const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); @@ -95,6 +96,13 @@ export const NotificationTab = () => { }); }; + const triggerSlackInstall = async () => { + const slackInstallUrl = await fetchSlackInstallUrl(currentWorkspace?.id); + if (slackInstallUrl) { + router.push(slackInstallUrl); + } + }; + useEffect(() => { if (slackIntegration) { setValue( @@ -131,10 +139,7 @@ export const NotificationTab = () => { isLoading={isConnectToSlackLoading} onClick={async () => { setIsConnectToSlackLoading.on(); - const slackInstallUrl = await fetchSlackInstallUrl(currentWorkspace?.id); - if (slackInstallUrl) { - router.push(slackInstallUrl); - } + await triggerSlackInstall(); }} > Connect to Slack @@ -144,8 +149,16 @@ export const NotificationTab = () => {
Connected Slack workspace: {slackIntegration.teamName}
-
+

Events

Date: Mon, 2 Sep 2024 23:15:00 +0800 Subject: [PATCH 06/27] misc: audit logs for slack integration management --- ...20240830142938_native-slack-integration.ts | 8 +-- backend/src/db/schemas/slack-integrations.ts | 8 +-- .../ee/services/audit-log/audit-log-types.ts | 44 +++++++++++- backend/src/server/routes/v1/slack-router.ts | 67 ++++++++++++++++--- backend/src/services/slack/slack-service.ts | 2 +- .../src/hooks/api/auditLogs/constants.tsx | 6 +- frontend/src/hooks/api/auditLogs/enums.tsx | 6 +- frontend/src/hooks/api/auditLogs/types.tsx | 30 ++++++++- .../AuditLogsPage/components/LogsTableRow.tsx | 17 +++++ 9 files changed, 166 insertions(+), 22 deletions(-) diff --git a/backend/src/db/migrations/20240830142938_native-slack-integration.ts b/backend/src/db/migrations/20240830142938_native-slack-integration.ts index d4fb22241..5e80bdb77 100644 --- a/backend/src/db/migrations/20240830142938_native-slack-integration.ts +++ b/backend/src/db/migrations/20240830142938_native-slack-integration.ts @@ -16,10 +16,10 @@ export async function up(knex: Knex): Promise { tb.binary("encryptedBotAccessToken").notNullable(); tb.string("slackBotId").notNullable(); tb.string("slackBotUserId").notNullable(); - tb.boolean("isAccessRequestNotificationEnabled").defaultTo(false); - tb.string("accessRequestChannels").defaultTo(""); - tb.boolean("isSecretRequestNotificationEnabled").defaultTo(false); - tb.string("secretRequestChannels").defaultTo(""); + tb.boolean("isAccessRequestNotificationEnabled").notNullable().defaultTo(false); + tb.string("accessRequestChannels").notNullable().defaultTo(""); + tb.boolean("isSecretRequestNotificationEnabled").notNullable().defaultTo(false); + tb.string("secretRequestChannels").notNullable().defaultTo(""); tb.timestamps(true, true, true); }); diff --git a/backend/src/db/schemas/slack-integrations.ts b/backend/src/db/schemas/slack-integrations.ts index 39e00c22b..2f938d94b 100644 --- a/backend/src/db/schemas/slack-integrations.ts +++ b/backend/src/db/schemas/slack-integrations.ts @@ -19,10 +19,10 @@ export const SlackIntegrationsSchema = z.object({ encryptedBotAccessToken: zodBuffer, slackBotId: z.string(), slackBotUserId: z.string(), - isAccessRequestNotificationEnabled: z.boolean().nullable().optional(), - accessRequestChannels: z.string().nullable().optional(), - isSecretRequestNotificationEnabled: z.boolean().nullable().optional(), - secretRequestChannels: z.string().nullable().optional(), + isAccessRequestNotificationEnabled: z.boolean().default(false), + accessRequestChannels: z.string().default(""), + isSecretRequestNotificationEnabled: z.boolean().default(false), + secretRequestChannels: z.string().default(""), createdAt: z.date(), updatedAt: z.date() }); 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 981b3777e..3fa9260aa 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -169,7 +169,11 @@ export enum EventType { GET_CERTIFICATE_TEMPLATE = "get-certificate-template", CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", - GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config" + GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", + ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", + GET_SLACK_INTEGRATION = "get-slack-integration", + UPDATE_SLACK_INTEGRATION = "update-slack-integration", + DELETE_SLACK_INTEGRATION = "delete-slack-integration" } interface UserActorMetadata { @@ -1446,6 +1450,38 @@ interface GetCertificateTemplateEstConfig { }; } +interface AttemptCreateSlackIntegration { + type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; + metadata: { + projectId?: string; + }; // no metadata +} + +interface UpdateSlackIntegration { + type: EventType.UPDATE_SLACK_INTEGRATION; + metadata: { + id: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; + }; +} + +interface DeleteSlackIntegration { + type: EventType.DELETE_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + +interface GetSlackIntegration { + type: EventType.GET_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1576,4 +1612,8 @@ export type Event = | DeleteCertificateTemplate | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig - | GetCertificateTemplateEstConfig; + | GetCertificateTemplateEstConfig + | AttemptCreateSlackIntegration + | UpdateSlackIntegration + | DeleteSlackIntegration + | GetSlackIntegration; diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index ac4800ccb..a4787987c 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SlackIntegrationsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -31,14 +32,24 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - // TODO: add audit logs - return server.services.slack.getInstallUrl({ + const url = await server.services.slack.getInstallUrl({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, projectId: req.query.projectId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION, + metadata: {} + } + }); + + return url; } }); @@ -70,13 +81,26 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - return server.services.slack.getSlackIntegrationByProjectId({ + const slackIntegration = await server.services.slack.getSlackIntegrationByProjectId({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, projectId: req.query.projectId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_SLACK_INTEGRATION, + metadata: { + id: slackIntegration?.id + } + } + }); + + return slackIntegration; } }); @@ -114,8 +138,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - // TODO: add audit logs - return server.services.slack.updateSlackIntegration({ + const updatedSlackIntegration = await server.services.slack.updateSlackIntegration({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -123,6 +146,23 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { id: req.params.slackIntegrationId, ...req.body }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: updatedSlackIntegration.projectId, + event: { + type: EventType.UPDATE_SLACK_INTEGRATION, + metadata: { + id: updatedSlackIntegration.id, + isAccessRequestNotificationEnabled: updatedSlackIntegration.isAccessRequestNotificationEnabled, + accessRequestChannels: updatedSlackIntegration.accessRequestChannels, + isSecretRequestNotificationEnabled: updatedSlackIntegration.isSecretRequestNotificationEnabled, + secretRequestChannels: updatedSlackIntegration.secretRequestChannels + } + } + }); + + return updatedSlackIntegration; } }); @@ -154,15 +194,26 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - // TODO: add audit logs - - return server.services.slack.deleteSlackIntegration({ + const deletedSlackIntegration = await server.services.slack.deleteSlackIntegration({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, id: req.params.slackIntegrationId }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: deletedSlackIntegration.projectId, + event: { + type: EventType.DELETE_SLACK_INTEGRATION, + metadata: { + id: deletedSlackIntegration.id + } + } + }); + + return deletedSlackIntegration; } }); diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index aa72d0d82..c32c1e466 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -162,7 +162,7 @@ export const slackServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index b9ba07264..7e59e639f 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -77,7 +77,11 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: "Create certificate template EST configuration", [EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: - "Update certificate template EST configuration" + "Update certificate template EST configuration", + [EventType.UPDATE_SLACK_INTEGRATION]: "Update slack integration", + [EventType.DELETE_SLACK_INTEGRATION]: "Delete slack integration", + [EventType.GET_SLACK_INTEGRATION]: "Get slack integration", + [EventType.ATTEMPT_CREATE_SLACK_INTEGRATION]: "Initiate create slack integration flow" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 6df32aa0b..5d58b2be1 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -89,5 +89,9 @@ export enum EventType { GET_CERTIFICATE_TEMPLATE = "get-certificate-template", CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", - GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config" + GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", + ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", + GET_SLACK_INTEGRATION = "get-slack-integration", + UPDATE_SLACK_INTEGRATION = "update-slack-integration", + DELETE_SLACK_INTEGRATION = "delete-slack-integration" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index f9b53d037..959f9aaa3 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -742,6 +742,31 @@ interface GetCertificateTemplateEstConfig { }; } +interface UpdateSlackIntegration { + type: EventType.UPDATE_SLACK_INTEGRATION; + metadata: { + id: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; + }; +} + +interface DeleteSlackIntegration { + type: EventType.DELETE_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + +interface GetSlackIntegration { + type: EventType.GET_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -817,7 +842,10 @@ export type Event = | DeleteCertificateTemplate | UpdateCertificateTemplateEstConfig | CreateCertificateTemplateEstConfig - | GetCertificateTemplateEstConfig; + | GetCertificateTemplateEstConfig + | UpdateSlackIntegration + | DeleteSlackIntegration + | GetSlackIntegration; export type AuditLog = { id: string; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index c1652aaba..ec53eec3b 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -442,6 +442,23 @@ export const LogsTableRow = ({ auditLog }: Props) => {

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

); + case EventType.UPDATE_SLACK_INTEGRATION: + return ( + +

{`Slack integration ID: ${event.metadata.id}`}

+

{`Access Request Notification Status: ${event.metadata.isAccessRequestNotificationEnabled}`}

+

{`Access Request Channels: ${event.metadata.accessRequestChannels}`}

+

{`Secret Approval Request Notification Status: ${event.metadata.isSecretRequestNotificationEnabled}`}

+

{`Secret Request Channels: ${event.metadata.secretRequestChannels}`}

+ + ); + case EventType.DELETE_SLACK_INTEGRATION: + case EventType.GET_SLACK_INTEGRATION: + return ( + +

{`Slack integration ID: ${event.metadata.id}`}

+ + ); default: return ; } From a2b555dd810b5bec18c55a4cf831d6a8f73caab7 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 3 Sep 2024 22:08:31 +0800 Subject: [PATCH 07/27] feat: finished org-level integration management flow --- backend/src/@types/knex.d.ts | 8 + ...20240830142938_native-slack-integration.ts | 29 ++- backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../src/db/schemas/project-slack-configs.ts | 24 ++ backend/src/db/schemas/slack-integrations.ts | 8 +- .../ee/services/audit-log/audit-log-types.ts | 11 +- backend/src/server/routes/v1/index.ts | 8 +- backend/src/server/routes/v1/slack-router.ts | 225 ++++++++++-------- backend/src/services/slack/slack-service.ts | 188 ++++++++------- backend/src/services/slack/slack-types.ts | 28 ++- frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/slack/index.ts | 2 +- frontend/src/hooks/api/slack/queries.tsx | 10 - .../hooks/api/workflowIntegrations/index.ts | 6 + .../api/workflowIntegrations/mutation.tsx | 38 +++ .../api/workflowIntegrations/queries.tsx | 55 +++++ .../hooks/api/workflowIntegrations/types.ts | 22 ++ .../components/OrgTabGroup/OrgTabGroup.tsx | 5 + .../AddWorkflowIntegrationForm.tsx | 97 ++++++++ .../IntegrationFormDetails.tsx | 23 ++ .../OrgWorkflowIntegrationTab.tsx | 188 +++++++++++++++ .../SlackIntegrationForm.tsx | 121 ++++++++++ 23 files changed, 865 insertions(+), 234 deletions(-) create mode 100644 backend/src/db/schemas/project-slack-configs.ts create mode 100644 frontend/src/hooks/api/workflowIntegrations/index.ts create mode 100644 frontend/src/hooks/api/workflowIntegrations/mutation.tsx create mode 100644 frontend/src/hooks/api/workflowIntegrations/queries.tsx create mode 100644 frontend/src/hooks/api/workflowIntegrations/types.ts create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index dad478d0e..7c9d88a46 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -196,6 +196,9 @@ import { TProjectRolesUpdate, TProjects, TProjectsInsert, + TProjectSlackConfigs, + TProjectSlackConfigsInsert, + TProjectSlackConfigsUpdate, TProjectsUpdate, TProjectUserAdditionalPrivilege, TProjectUserAdditionalPrivilegeInsert, @@ -787,6 +790,11 @@ declare module "knex/types/tables" { TSlackIntegrationsInsert, TSlackIntegrationsUpdate >; + [TableName.ProjectSlackConfigs]: KnexOriginal.CompositeTableType< + TProjectSlackConfigs, + TProjectSlackConfigsInsert, + TProjectSlackConfigsUpdate + >; [TableName.AdminSlackConfig]: KnexOriginal.CompositeTableType< TAdminSlackConfigs, TAdminSlackConfigsInsert, diff --git a/backend/src/db/migrations/20240830142938_native-slack-integration.ts b/backend/src/db/migrations/20240830142938_native-slack-integration.ts index 5e80bdb77..15842af05 100644 --- a/backend/src/db/migrations/20240830142938_native-slack-integration.ts +++ b/backend/src/db/migrations/20240830142938_native-slack-integration.ts @@ -7,8 +7,10 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.SlackIntegrations))) { await knex.schema.createTable(TableName.SlackIntegrations, (tb) => { tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - tb.string("projectId").notNullable().unique(); - tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + tb.string("slug").notNullable(); + tb.uuid("orgId").notNullable(); + tb.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + tb.string("description"); tb.string("teamId").notNullable(); tb.string("teamName").notNullable(); tb.string("slackUserId").notNullable(); @@ -16,6 +18,20 @@ export async function up(knex: Knex): Promise { tb.binary("encryptedBotAccessToken").notNullable(); tb.string("slackBotId").notNullable(); tb.string("slackBotUserId").notNullable(); + tb.unique(["orgId", "slug"]); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SlackIntegrations); + } + + if (!(await knex.schema.hasTable(TableName.ProjectSlackConfigs))) { + await knex.schema.createTable(TableName.ProjectSlackConfigs, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("projectId").notNullable().unique(); + tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + tb.uuid("slackIntegrationId").notNullable(); + tb.foreign("slackIntegrationId").references("id").inTable(TableName.SlackIntegrations).onDelete("CASCADE"); tb.boolean("isAccessRequestNotificationEnabled").notNullable().defaultTo(false); tb.string("accessRequestChannels").notNullable().defaultTo(""); tb.boolean("isSecretRequestNotificationEnabled").notNullable().defaultTo(false); @@ -23,7 +39,7 @@ export async function up(knex: Knex): Promise { tb.timestamps(true, true, true); }); - await createOnUpdateTrigger(knex, TableName.SlackIntegrations); + await createOnUpdateTrigger(knex, TableName.ProjectSlackConfigs); } if (!(await knex.schema.hasTable(TableName.AdminSlackConfig))) { @@ -39,9 +55,12 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SlackIntegrations); - await dropOnUpdateTrigger(knex, TableName.SlackIntegrations); + await knex.schema.dropTableIfExists(TableName.ProjectSlackConfigs); + await dropOnUpdateTrigger(knex, TableName.ProjectSlackConfigs); await knex.schema.dropTableIfExists(TableName.AdminSlackConfig); await dropOnUpdateTrigger(knex, TableName.AdminSlackConfig); + + await knex.schema.dropTableIfExists(TableName.SlackIntegrations); + await dropOnUpdateTrigger(knex, TableName.SlackIntegrations); } diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index aaa4e02b8..6fb79982a 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -63,6 +63,7 @@ export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; +export * from "./project-slack-configs"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index fa2c80f7c..3e4f259ff 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -116,6 +116,7 @@ export enum TableName { // @depreciated KmsKeyVersion = "kms_key_versions", SlackIntegrations = "slack_integrations", + ProjectSlackConfigs = "project_slack_configs", AdminSlackConfig = "admin_slack_configs" } diff --git a/backend/src/db/schemas/project-slack-configs.ts b/backend/src/db/schemas/project-slack-configs.ts new file mode 100644 index 000000000..0a46e5aae --- /dev/null +++ b/backend/src/db/schemas/project-slack-configs.ts @@ -0,0 +1,24 @@ +// 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 ProjectSlackConfigsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + slackIntegrationId: z.string().uuid(), + isAccessRequestNotificationEnabled: z.boolean().default(false), + accessRequestChannels: z.string().default(""), + isSecretRequestNotificationEnabled: z.boolean().default(false), + secretRequestChannels: z.string().default(""), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectSlackConfigs = z.infer; +export type TProjectSlackConfigsInsert = Omit, TImmutableDBKeys>; +export type TProjectSlackConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/slack-integrations.ts b/backend/src/db/schemas/slack-integrations.ts index 2f938d94b..3cd33bed2 100644 --- a/backend/src/db/schemas/slack-integrations.ts +++ b/backend/src/db/schemas/slack-integrations.ts @@ -11,7 +11,9 @@ import { TImmutableDBKeys } from "./models"; export const SlackIntegrationsSchema = z.object({ id: z.string().uuid(), - projectId: z.string(), + slug: z.string(), + orgId: z.string().uuid(), + description: z.string().nullable().optional(), teamId: z.string(), teamName: z.string(), slackUserId: z.string(), @@ -19,10 +21,6 @@ export const SlackIntegrationsSchema = z.object({ encryptedBotAccessToken: zodBuffer, slackBotId: z.string(), slackBotUserId: z.string(), - isAccessRequestNotificationEnabled: z.boolean().default(false), - accessRequestChannels: z.string().default(""), - isSecretRequestNotificationEnabled: z.boolean().default(false), - secretRequestChannels: z.string().default(""), createdAt: z.date(), updatedAt: z.date() }); 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 3fa9260aa..29f5e99bb 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1453,18 +1453,17 @@ interface GetCertificateTemplateEstConfig { interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { - projectId?: string; - }; // no metadata + slug: string; + description?: string; + }; } interface UpdateSlackIntegration { type: EventType.UPDATE_SLACK_INTEGRATION; metadata: { id: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; + slug: string; + description?: string; }; } diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 05c6ae7e5..734352b58 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -61,7 +61,13 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); await server.register(registerSecretFolderRouter, { prefix: "/folders" }); - await server.register(registerSlackRouter, { prefix: "/slack" }); + + await server.register( + async (workflowIntegrationRouter) => { + await workflowIntegrationRouter.register(registerSlackRouter, { prefix: "/slack" }); + }, + { prefix: "/workflow-integrations" } + ); await server.register( async (projectRouter) => { diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index a4787987c..88ae200d9 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -6,7 +6,6 @@ import { getConfig } from "@app/lib/config/env"; 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 { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; export const registerSlackRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -24,7 +23,8 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } ], querystring: z.object({ - projectId: z.string() + slug: z.string(), + description: z.string().optional() }), response: { 200: z.string() @@ -37,15 +37,18 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.query.projectId + ...req.query }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.projectId, + orgId: req.permission.orgId, event: { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION, - metadata: {} + metadata: { + slug: req.query.slug, + description: req.query.description + } } }); @@ -65,104 +68,25 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { bearerAuth: [] } ], - querystring: z.object({ - projectId: z.string() - }), response: { 200: SlackIntegrationsSchema.pick({ id: true, - teamName: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true - }) + slug: true, + description: true, + teamName: true + }).array() } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const slackIntegration = await server.services.slack.getSlackIntegrationByProjectId({ + const slackIntegrations = await server.services.slack.getSlackIntegrationsByOrg({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.query.projectId + actorOrgId: req.permission.orgId }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.query.projectId, - event: { - type: EventType.GET_SLACK_INTEGRATION, - metadata: { - id: slackIntegration?.id - } - } - }); - - return slackIntegration; - } - }); - - server.route({ - method: "PATCH", - url: "/:slackIntegrationId", - config: { - rateLimit: writeLimit - }, - schema: { - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - slackIntegrationId: z.string() - }), - body: z.object({ - isAccessRequestNotificationEnabled: z.boolean().optional(), - accessRequestChannels: validateSlackChannelsField.optional(), - isSecretRequestNotificationEnabled: z.boolean().optional(), - secretRequestChannels: validateSlackChannelsField.optional() - }), - response: { - 200: SlackIntegrationsSchema.pick({ - id: true, - teamName: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const updatedSlackIntegration = await server.services.slack.updateSlackIntegration({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - id: req.params.slackIntegrationId, - ...req.body - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: updatedSlackIntegration.projectId, - event: { - type: EventType.UPDATE_SLACK_INTEGRATION, - metadata: { - id: updatedSlackIntegration.id, - isAccessRequestNotificationEnabled: updatedSlackIntegration.isAccessRequestNotificationEnabled, - accessRequestChannels: updatedSlackIntegration.accessRequestChannels, - isSecretRequestNotificationEnabled: updatedSlackIntegration.isSecretRequestNotificationEnabled, - secretRequestChannels: updatedSlackIntegration.secretRequestChannels - } - } - }); - - return updatedSlackIntegration; + return slackIntegrations; } }); @@ -184,11 +108,9 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { response: { 200: SlackIntegrationsSchema.pick({ id: true, - teamName: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + slug: true, + description: true, + teamName: true }) } }, @@ -204,7 +126,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: deletedSlackIntegration.projectId, + orgId: deletedSlackIntegration.orgId, event: { type: EventType.DELETE_SLACK_INTEGRATION, metadata: { @@ -217,6 +139,111 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:slackIntegrationId", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + slug: true, + description: true, + teamName: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackIntegration = await server.services.slack.getSlackIntegrationById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: slackIntegration.orgId, + event: { + type: EventType.GET_SLACK_INTEGRATION, + metadata: { + id: slackIntegration.id + } + } + }); + + return slackIntegration; + } + }); + + server.route({ + method: "PATCH", + url: "/:slackIntegrationId", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + body: z.object({ + slug: z.string().optional(), + description: z.string().optional() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + slug: true, + description: true, + teamName: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackIntegration = await server.services.slack.updateSlackIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: slackIntegration.orgId, + event: { + type: EventType.UPDATE_SLACK_INTEGRATION, + metadata: { + id: slackIntegration.id, + slug: slackIntegration.slug, + description: slackIntegration.description as string + } + } + }); + + return slackIntegration; + } + }); + server.route({ method: "GET", url: "/oauth_redirect", @@ -232,10 +259,10 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { }, successAsync: async (installation) => { const metadata = JSON.parse(installation.metadata || "") as { - projectId: string; + orgId: string; }; - return res.redirect(`${appCfg.SITE_URL}/project/${metadata.projectId}/settings`); + return res.redirect(`${appCfg.SITE_URL}/org/${metadata.orgId}/settings`); } }); } diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index c32c1e466..b6a60492d 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -1,43 +1,40 @@ import { ForbiddenError } from "@casl/ability"; import { InstallProvider } from "@slack/oauth"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; 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 { TCompleteSlackIntegrationDTO, TDeleteSlackIntegrationDTO, TGetSlackInstallUrlDTO, - TGetSlackIntegrationByProjectIdDTO, + TGetSlackIntegrationByIdDTO, + TGetSlackIntegrationByOrgDTO, TUpdateSlackIntegrationDTO } from "./slack-types"; type TSlackServiceFactoryDep = { - slackIntegrationDAL: Pick< - TSlackIntegrationDALFactory, - "create" | "findOne" | "findById" | "updateById" | "deleteById" | "transaction" - >; - permissionService: Pick; - projectDAL: Pick; + slackIntegrationDAL: Pick; + permissionService: Pick; kmsService: Pick; }; export type TSlackServiceFactory = ReturnType; export const slackServiceFactory = ({ - projectDAL, permissionService, slackIntegrationDAL, kmsService }: TSlackServiceFactoryDep) => { const completeSlackIntegration = async ({ - projectId, + orgId, + slug, + description, teamId, teamName, slackUserId, @@ -46,59 +43,26 @@ export const slackServiceFactory = ({ slackBotId, slackBotUserId }: TCompleteSlackIntegrationDTO) => { - const project = await projectDAL.findById(projectId); - if (!project) { - throw new NotFoundError({ - message: "Project not found" - }); - } - const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: project.orgId + orgId, + type: KmsDataKey.Organization }); const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({ plainText: Buffer.from(botAccessToken, "utf8") }); - await slackIntegrationDAL.transaction(async (tx) => { - const slackIntegration = await slackIntegrationDAL.findOne( - { - projectId - }, - tx - ); - - if (slackIntegration) { - return slackIntegrationDAL.updateById( - slackIntegration.id, - { - teamId, - teamName, - slackUserId, - slackAppId, - slackBotId, - slackBotUserId, - encryptedBotAccessToken - }, - tx - ); - } - - return slackIntegrationDAL.create( - { - projectId, - teamId, - teamName, - slackUserId, - slackAppId, - slackBotId, - slackBotUserId, - encryptedBotAccessToken - }, - tx - ); + await slackIntegrationDAL.create({ + orgId, + slug, + description, + teamId, + teamName, + slackUserId, + slackAppId, + slackBotId, + slackBotUserId, + encryptedBotAccessToken }); }; @@ -125,11 +89,15 @@ export const slackServiceFactory = ({ } const metadata = JSON.parse(installation.metadata || "") as { - projectId: string; + orgId: string; + slug: string; + description?: string; }; return completeSlackIntegration({ - projectId: metadata.projectId, + orgId: metadata.orgId, + slug: metadata.slug, + description: metadata.description, teamId: installation.team?.id || "", teamName: installation.team?.name || "", slackUserId: installation.user.id, @@ -152,56 +120,87 @@ export const slackServiceFactory = ({ }); }; - const getInstallUrl = async ({ actorId, actor, actorOrgId, actorAuthMethod, projectId }: TGetSlackInstallUrlDTO) => { + const getInstallUrl = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + slug, + description + }: TGetSlackInstallUrlDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getProjectPermission( + + const { permission } = await permissionService.getOrgPermission( actor, actorId, - projectId, + actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); - const project = await projectDAL.findById(projectId); - if (!project) { - throw new NotFoundError({ - message: "Project not found" - }); - } + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); const installer = await getSlackInstaller(); const url = await installer.generateInstallUrl({ scopes: ["chat:write.public", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"], metadata: JSON.stringify({ - projectId: project.id + slug, + description, + orgId: actorOrgId }), - redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect` + redirectUri: `${appCfg.SITE_URL}/api/v1/workflow-integrations/slack/oauth_redirect` }); return url; }; - const getSlackIntegrationByProjectId = async ({ + const getSlackIntegrationsByOrg = async ({ actorId, actor, actorOrgId, - actorAuthMethod, - projectId - }: TGetSlackIntegrationByProjectIdDTO) => { - const { permission } = await permissionService.getProjectPermission( + actorAuthMethod + }: TGetSlackIntegrationByOrgDTO) => { + const { permission } = await permissionService.getOrgPermission( actor, actorId, - projectId, + actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); - const slackIntegration = await slackIntegrationDAL.findOne({ - projectId + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const slackIntegrations = await slackIntegrationDAL.find({ + orgId: actorOrgId }); + return slackIntegrations; + }; + + const getSlackIntegrationById = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetSlackIntegrationByIdDTO) => { + const slackIntegration = await slackIntegrationDAL.findById(id); + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found." + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + slackIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + return slackIntegration; }; @@ -211,10 +210,8 @@ export const slackServiceFactory = ({ actorOrgId, actorAuthMethod, id, - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels + slug, + description }: TUpdateSlackIntegrationDTO) => { const slackIntegration = await slackIntegrationDAL.findById(id); if (!slackIntegration) { @@ -222,21 +219,20 @@ export const slackServiceFactory = ({ message: "Slack integration not found" }); } - const { permission } = await permissionService.getProjectPermission( + + const { permission } = await permissionService.getOrgPermission( actor, actorId, - slackIntegration.projectId, + slackIntegration.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); return slackIntegrationDAL.updateById(slackIntegration.id, { - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels + slug, + description }); }; @@ -253,22 +249,24 @@ export const slackServiceFactory = ({ message: "Slack integration not found" }); } - const { permission } = await permissionService.getProjectPermission( + + const { permission } = await permissionService.getOrgPermission( actor, actorId, - slackIntegration.projectId, + slackIntegration.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); return slackIntegrationDAL.deleteById(id); }; return { getInstallUrl, - getSlackIntegrationByProjectId, + getSlackIntegrationsByOrg, + getSlackIntegrationById, completeSlackIntegration, getSlackInstaller, updateSlackIntegration, diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index 55d836662..ab9035b09 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -1,23 +1,27 @@ -import { TProjectPermission } from "@app/lib/types"; +import { TOrgPermission } from "@app/lib/types"; -export type TGetSlackInstallUrlDTO = TProjectPermission; +export type TGetSlackInstallUrlDTO = { + slug: string; + description?: string; +} & Omit; -export type TGetSlackIntegrationByProjectIdDTO = TProjectPermission; +export type TGetSlackIntegrationByOrgDTO = Omit; -export type TUpdateSlackIntegrationDTO = { - id: string; - isAccessRequestNotificationEnabled?: boolean; - accessRequestChannels?: string; - isSecretRequestNotificationEnabled?: boolean; - secretRequestChannels?: string; -} & Omit; +export type TGetSlackIntegrationByIdDTO = { id: string } & Omit; + +export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit< + TOrgPermission, + "orgId" +>; export type TDeleteSlackIntegrationDTO = { id: string; -} & Omit; +} & Omit; export type TCompleteSlackIntegrationDTO = { - projectId: string; + orgId: string; + slug: string; + description?: string; teamId: string; teamName: string; slackUserId: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 57644b616..9607829f5 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -45,4 +45,5 @@ export * from "./tags"; export * from "./trustedIps"; export * from "./users"; export * from "./webhooks"; +export * from "./workflowIntegrations"; export * from "./workspace"; diff --git a/frontend/src/hooks/api/slack/index.ts b/frontend/src/hooks/api/slack/index.ts index 73df6a19c..e98ddce71 100644 --- a/frontend/src/hooks/api/slack/index.ts +++ b/frontend/src/hooks/api/slack/index.ts @@ -1,2 +1,2 @@ export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation"; -export { fetchSlackInstallUrl, useGetSlackIntegrationByProject } from "./queries"; +export { useGetSlackIntegrationByProject } from "./queries"; diff --git a/frontend/src/hooks/api/slack/queries.tsx b/frontend/src/hooks/api/slack/queries.tsx index 3b82793f9..c59cf64f6 100644 --- a/frontend/src/hooks/api/slack/queries.tsx +++ b/frontend/src/hooks/api/slack/queries.tsx @@ -11,16 +11,6 @@ export const slackKeys = { ] }; -export const fetchSlackInstallUrl = async (workspaceId?: string) => { - const { data } = await apiRequest.get("/api/v1/slack/install", { - params: { - projectId: workspaceId - } - }); - - return data; -}; - export const fetchSlackIntegrationByProject = async (workspaceId?: string) => { const { data } = await apiRequest.get("/api/v1/slack", { params: { diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts new file mode 100644 index 000000000..8d0529b2e --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -0,0 +1,6 @@ +export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation"; +export { + fetchSlackInstallUrl, + useGetSlackIntegrationById, + useGetSlackIntegrations +} from "./queries"; diff --git a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx new file mode 100644 index 000000000..0979e423c --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workflowIntegrationKeys } from "./queries"; +import { TDeleteSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./types"; + +export const useUpdateSlackIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSlackIntegrationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch(`/api/v1/workflow-integrations/slack/${dto.id}`, dto); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + } + }); +}; + +export const useDeleteSlackIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSlackIntegrationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete(`/api/v1/workflow-integrations/slack/${dto.id}`); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/workflowIntegrations/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx new file mode 100644 index 000000000..30e028828 --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { SlackIntegration } from "./types"; + +export const workflowIntegrationKeys = { + getSlackWorkflowIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], + getSlackWorkflowIntegration: (id?: string) => [{ id }, "slack-workflow-integration"] +}; + +export const fetchSlackInstallUrl = async ({ + slug, + description +}: { + slug: string; + description?: string; +}) => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack/install", { + params: { + slug, + description + } + }); + + return data; +}; + +export const fetchSlackIntegrations = async () => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack"); + + return data; +}; + +export const fetchSlackIntegrationById = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/slack/${id}` + ); + + return data; +}; + +export const useGetSlackIntegrations = (orgId?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId), + queryFn: () => fetchSlackIntegrations(), + enabled: Boolean(orgId) + }); + +export const useGetSlackIntegrationById = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getSlackWorkflowIntegration(id), + queryFn: () => fetchSlackIntegrationById(id), + enabled: Boolean(id) + }); diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts new file mode 100644 index 000000000..5fb1868e2 --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -0,0 +1,22 @@ +export enum WorkflowIntegrationPlatform { + SLACK = "slack" +} + +export type SlackIntegration = { + id: string; + slug: string; + description: string; + teamName: string; +}; + +export type TUpdateSlackIntegrationDTO = { + id: string; + orgId: string; + slug?: string; + description?: string; +}; + +export type TDeleteSlackIntegrationDTO = { + id: string; + orgId: string; +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 3894bb28f..029018914 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -5,11 +5,13 @@ import { AuditLogStreamsTab } from "../AuditLogStreamTab"; import { OrgAuthTab } from "../OrgAuthTab"; import { OrgEncryptionTab } from "../OrgEncryptionTab"; import { OrgGeneralTab } from "../OrgGeneralTab"; +import { OrgWorkflowIntegrationTab } from "../OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab"; const tabs = [ { name: "General", key: "tab-org-general" }, { name: "Security", key: "tab-org-security" }, { name: "Encryption", key: "tab-org-encryption" }, + { name: "Workflow Integrations", key: "workflow-integrations" }, { name: "Audit Log Streams", key: "tag-audit-log-streams" } ]; export const OrgTabGroup = () => { @@ -41,6 +43,9 @@ export const OrgTabGroup = () => { + + + diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx new file mode 100644 index 000000000..63182dda1 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { faSlack } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; +}; + +enum WizardSteps { + SelectPlatform = "select-platform", + PlatformInputs = "platform-inputs" +} + +const PLATFORM_LIST = [ + { + icon: faSlack, + platform: WorkflowIntegrationPlatform.SLACK, + title: "Slack" + } +]; + +export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => { + const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform); + const [selectedPlatform, setSelectedPlatform] = useState(null); + + const handleFormReset = (state: boolean = false) => { + onToggle(state); + setWizardStep(WizardSteps.SelectPlatform); + setSelectedPlatform(null); + }; + + return ( + handleFormReset(state)}> + + + {wizardStep === WizardSteps.SelectPlatform && ( + +
Select a platform
+
+ {PLATFORM_LIST.map(({ icon, platform, title }) => ( +
{ + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + } + }} + > + +
{title}
+
+ ))} +
+
+ )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.SLACK && ( + + onToggle(false)} /> + + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx new file mode 100644 index 000000000..fce1edb92 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx @@ -0,0 +1,23 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen: boolean; + id: string; + workflowPlatform: WorkflowIntegrationPlatform; + onOpenChange: (state: boolean) => void; +}; + +export const IntegrationFormDetails = ({ isOpen, id, onOpenChange, workflowPlatform }: Props) => { + return ( + + + {workflowPlatform === WorkflowIntegrationPlatform.SLACK && ( + onOpenChange(false)} /> + )} + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx new file mode 100644 index 000000000..9c2018168 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx @@ -0,0 +1,188 @@ +import { faSlack } from "@fortawesome/free-brands-svg-icons"; +import { faEllipsis, faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { useDeleteSlackIntegration, useGetSlackIntegrations } from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { AddWorkflowIntegrationForm } from "./AddWorkflowIntegrationForm"; +import { IntegrationFormDetails } from "./IntegrationFormDetails"; + +export const OrgWorkflowIntegrationTab = withPermission( + () => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "addWorkflowIntegration", + "integrationDetails", + "removeIntegration" + ] as const); + + const { currentOrg } = useOrganization(); + const { data: slackIntegrations, isLoading: isSlackIntegrationsLoading } = + useGetSlackIntegrations(currentOrg?.id); + const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration(); + + const handleRemoveIntegration = async () => { + if (!currentOrg) { + return; + } + + const { platform, id } = popUp.removeIntegration.data; + if (platform === WorkflowIntegrationPlatform.SLACK) { + await deleteSlackIntegration({ + id, + orgId: currentOrg?.id + }); + } + + handlePopUpClose("removeIntegration"); + createNotification({ + text: "Successfully deleted integration", + type: "success" + }); + }; + + const isIntegrationsLoading = isSlackIntegrationsLoading; + + return ( +
+
+

Workflow Integrations

+ + {(isAllowed) => ( + + )} + +
+

+ Connect Infisical to other platforms for notification and workflow integrations. +

+ + + + + + + + + + {isIntegrationsLoading && ( + + )} + {!isIntegrationsLoading && slackIntegrations && slackIntegrations.length === 0 && ( + + + + )} + {slackIntegrations?.map((slackIntegration) => ( + + + + + + ))} + +
ProviderAlias
+ +
+ +
SLACK
+
{slackIntegration.slug} + + +
+ +
+
+ + { + e.stopPropagation(); + + handlePopUpOpen("integrationDetails", { + id: slackIntegration.id, + platform: WorkflowIntegrationPlatform.SLACK + }); + }} + > + More details + + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("removeIntegration", { + id: slackIntegration.id, + slug: slackIntegration.slug, + platform: WorkflowIntegrationPlatform.SLACK + }); + }} + > + Delete + + )} + + +
+
+
+ handlePopUpToggle("addWorkflowIntegration", state)} + /> + handlePopUpToggle("integrationDetails", state)} + /> + handlePopUpToggle("removeIntegration", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveIntegration} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Settings } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx new file mode 100644 index 000000000..c72d8aae3 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx @@ -0,0 +1,121 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { + fetchSlackInstallUrl, + useGetSlackIntegrationById, + useUpdateSlackIntegration +} from "@app/hooks/api"; + +type Props = { + id?: string; + onClose: () => void; +}; + +const slackFormSchema = z.object({ + slug: z.string(), + description: z.string().optional() +}); + +type TSlackFormData = z.infer; + +export const SlackIntegrationForm = ({ id, onClose }: Props) => { + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(slackFormSchema) + }); + + const router = useRouter(); + const [isConnectLoading, setIsConnectLoading] = useToggle(false); + const { currentOrg } = useOrganization(); + const { data: slackIntegration } = useGetSlackIntegrationById(id); + const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration(); + + useEffect(() => { + if (slackIntegration) { + setValue("slug", slackIntegration.slug); + setValue("description", slackIntegration.description ?? ""); + } + }, [slackIntegration]); + + const triggerSlackInstall = async (slug: string, description?: string) => { + setIsConnectLoading.on(); + const slackInstallUrl = await fetchSlackInstallUrl({ + slug, + description + }); + if (slackInstallUrl) { + router.push(slackInstallUrl); + } + }; + + const handleSlackFormSubmit = async ({ slug, description }: TSlackFormData) => { + if (id && slackIntegration) { + if (!currentOrg) { + return; + } + + await updateSlackIntegration({ + id, + orgId: currentOrg?.id, + slug, + description + }); + + onClose(); + createNotification({ + text: "Successfully updated Slack integration", + type: "success" + }); + } else { + await triggerSlackInstall(slug, description); + } + }; + + return ( + + ( + + + + )} + /> + ( + + + + )} + /> + {slackIntegration && ( + + + + )} +
+ + +
+ + ); +}; From 604b0467f9369483726117004432b5ce7cfbca12 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Sep 2024 00:34:03 +0800 Subject: [PATCH 08/27] feat: finalized integration selection in project settings --- backend/src/server/routes/index.ts | 7 +- .../src/server/routes/v1/project-router.ts | 79 +++++++ .../src/services/project/project-service.ts | 121 +++++++++- backend/src/services/project/project-types.ts | 10 + .../slack/project-slack-config-dal.ts | 11 + frontend/src/hooks/api/index.tsx | 1 - frontend/src/hooks/api/slack/index.ts | 2 - frontend/src/hooks/api/slack/mutation.tsx | 36 --- frontend/src/hooks/api/slack/queries.tsx | 29 --- frontend/src/hooks/api/slack/types.ts | 22 -- .../hooks/api/workflowIntegrations/index.ts | 6 +- .../api/workflowIntegrations/mutation.tsx | 24 +- .../hooks/api/workflowIntegrations/types.ts | 18 ++ frontend/src/hooks/api/workspace/index.tsx | 1 + frontend/src/hooks/api/workspace/queries.tsx | 19 +- .../ProjectSettingsPage.tsx | 6 +- .../components/NotificationSection/index.tsx | 1 - .../WorkflowIntegrationTab.tsx} | 209 ++++++++---------- .../WorkflowIntegrationSection/index.tsx | 1 + 19 files changed, 383 insertions(+), 220 deletions(-) create mode 100644 backend/src/services/slack/project-slack-config-dal.ts delete mode 100644 frontend/src/hooks/api/slack/index.ts delete mode 100644 frontend/src/hooks/api/slack/mutation.tsx delete mode 100644 frontend/src/hooks/api/slack/queries.tsx delete mode 100644 frontend/src/hooks/api/slack/types.ts delete mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx rename frontend/src/views/Settings/ProjectSettingsPage/components/{NotificationSection/NotificationTab.tsx => WorkflowIntegrationSection/WorkflowIntegrationTab.tsx} (50%) create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/index.tsx diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8b9b8e329..ba69a5c51 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -182,6 +182,7 @@ import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/ import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service"; +import { projectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; import { slackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal"; import { slackServiceFactory } from "@app/services/slack/slack-service"; import { TSmtpService } from "@app/services/smtp/smtp-service"; @@ -325,6 +326,7 @@ export const registerRoutes = async ( const kmsRootConfigDAL = kmsRootConfigDALFactory(db); const slackIntegrationDAL = slackIntegrationDALFactory(db); + const projectSlackConfigDAL = projectSlackConfigDALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -725,7 +727,9 @@ export const registerRoutes = async ( keyStore, kmsService, projectBotDAL, - certificateTemplateDAL + certificateTemplateDAL, + projectSlackConfigDAL, + slackIntegrationDAL }); const projectEnvService = projectEnvServiceFactory({ @@ -1156,7 +1160,6 @@ export const registerRoutes = async ( }); const slackService = slackServiceFactory({ - projectDAL, permissionService, kmsService, slackIntegrationDAL diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index a380eb934..dae8182a0 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -4,6 +4,7 @@ import { IntegrationsSchema, ProjectMembershipsSchema, ProjectRolesSchema, + ProjectSlackConfigsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; @@ -542,4 +543,82 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { serviceTokenData }; } }); + + server.route({ + method: "GET", + url: "/:workspaceId/slack-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: ProjectSlackConfigsSchema.pick({ + id: true, + slackIntegrationId: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackConfig = await server.services.project.getProjectSlackConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + + return slackConfig; + } + }); + + server.route({ + method: "PUT", + url: "/:workspaceId/slack-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + slackIntegrationId: z.string(), + isAccessRequestNotificationEnabled: z.boolean(), + accessRequestChannels: z.string(), + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.string() + }), + response: { + 200: ProjectSlackConfigsSchema.pick({ + id: true, + slackIntegrationId: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackConfig = await server.services.project.updateProjectSlackConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.body + }); + + return slackConfig; + } + }); }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 3e8f7582e..5900ea351 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -34,6 +34,8 @@ import { TProjectUserMembershipRoleDALFactory } from "../project-membership/proj import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { getPredefinedRoles } from "../project-role/project-role-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; +import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; @@ -43,6 +45,7 @@ import { TDeleteProjectDTO, TGetProjectDTO, TGetProjectKmsKey, + TGetProjectSlackConfig, TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertificateTemplatesDTO, @@ -54,6 +57,7 @@ import { TUpdateProjectDTO, TUpdateProjectKmsDTO, TUpdateProjectNameDTO, + TUpdateProjectSlackConfig, TUpdateProjectVersionLimitDTO, TUpgradeProjectDTO } from "./project-types"; @@ -76,6 +80,8 @@ type TProjectServiceFactoryDep = { identityProjectMembershipRoleDAL: Pick; projectKeyDAL: Pick; projectMembershipDAL: Pick; + projectSlackConfigDAL: Pick; + slackIntegrationDAL: Pick; projectUserMembershipRoleDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; @@ -126,7 +132,9 @@ export const projectServiceFactory = ({ pkiAlertDAL, keyStore, kmsService, - projectBotDAL + projectBotDAL, + projectSlackConfigDAL, + slackIntegrationDAL }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -909,6 +917,113 @@ export const projectServiceFactory = ({ return { secretManagerKmsKey: kmsKey }; }; + const getProjectSlackConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetProjectSlackConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: "Project not found" + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + + return projectSlackConfigDAL.findOne({ + projectId: project.id + }); + }; + + const updateProjectSlackConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + slackIntegrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels + }: TUpdateProjectSlackConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: "Project not found" + }); + } + + const slackIntegration = await slackIntegrationDAL.findById(slackIntegrationId); + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found" + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + if (slackIntegration.orgId !== project.orgId) { + throw new BadRequestError({ + message: "Selected slack integration is not in the same organization" + }); + } + + return projectSlackConfigDAL.transaction(async (tx) => { + const slackConfig = await projectSlackConfigDAL.findOne( + { + projectId + }, + tx + ); + + if (slackConfig) { + return projectSlackConfigDAL.updateById( + slackConfig.id, + { + slackIntegrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels + }, + tx + ); + } + + return projectSlackConfigDAL.create( + { + projectId, + slackIntegrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels + }, + tx + ); + }); + }; + return { createProject, deleteProject, @@ -929,6 +1044,8 @@ export const projectServiceFactory = ({ updateProjectKmsKey, getProjectKmsBackup, loadProjectKmsBackup, - getProjectKmsKeys + getProjectKmsKeys, + getProjectSlackConfig, + updateProjectSlackConfig }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index c0ef2579e..fbbd32d2b 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -123,3 +123,13 @@ export type TLoadProjectKmsBackupDTO = { export type TGetProjectKmsKey = TProjectPermission; export type TListProjectCertificateTemplatesDTO = TProjectPermission; + +export type TGetProjectSlackConfig = TProjectPermission; + +export type TUpdateProjectSlackConfig = { + slackIntegrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; +} & TProjectPermission; diff --git a/backend/src/services/slack/project-slack-config-dal.ts b/backend/src/services/slack/project-slack-config-dal.ts new file mode 100644 index 000000000..2c08fdeb3 --- /dev/null +++ b/backend/src/services/slack/project-slack-config-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 TProjectSlackConfigDALFactory = ReturnType; + +export const projectSlackConfigDALFactory = (db: TDbClient) => { + const projectSlackConfigOrm = ormify(db, TableName.ProjectSlackConfigs); + + return projectSlackConfigOrm; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 9607829f5..551822f09 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -38,7 +38,6 @@ export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; -export * from "./slack"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/slack/index.ts b/frontend/src/hooks/api/slack/index.ts deleted file mode 100644 index e98ddce71..000000000 --- a/frontend/src/hooks/api/slack/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation"; -export { useGetSlackIntegrationByProject } from "./queries"; diff --git a/frontend/src/hooks/api/slack/mutation.tsx b/frontend/src/hooks/api/slack/mutation.tsx deleted file mode 100644 index 67f34d9a4..000000000 --- a/frontend/src/hooks/api/slack/mutation.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { apiRequest } from "@app/config/request"; - -import { slackKeys } from "./queries"; -import { TDeleteSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./types"; - -export const useUpdateSlackIntegration = () => { - const queryClient = useQueryClient(); - - return useMutation<{}, {}, TUpdateSlackIntegrationDTO>({ - mutationFn: async (dto) => { - const { data } = await apiRequest.patch(`/api/v1/slack/${dto.id}`, dto); - - return data; - }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(slackKeys.getSlackIntegrationByProject(workspaceId)); - } - }); -}; - -export const useDeleteSlackIntegration = () => { - const queryClient = useQueryClient(); - - return useMutation<{}, {}, TDeleteSlackIntegrationDTO>({ - mutationFn: async (dto) => { - const { data } = await apiRequest.delete(`/api/v1/slack/${dto.id}`); - - return data; - }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(slackKeys.getSlackIntegrationByProject(workspaceId)); - } - }); -}; diff --git a/frontend/src/hooks/api/slack/queries.tsx b/frontend/src/hooks/api/slack/queries.tsx deleted file mode 100644 index c59cf64f6..000000000 --- a/frontend/src/hooks/api/slack/queries.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { apiRequest } from "@app/config/request"; - -import { ProjectSlackIntegration } from "./types"; - -export const slackKeys = { - getSlackIntegrationByProject: (workspaceId?: string) => [ - { workspaceId }, - "slack-integration-by-project" - ] -}; - -export const fetchSlackIntegrationByProject = async (workspaceId?: string) => { - const { data } = await apiRequest.get("/api/v1/slack", { - params: { - projectId: workspaceId - } - }); - - return data; -}; - -export const useGetSlackIntegrationByProject = (workspaceId?: string) => - useQuery({ - queryKey: slackKeys.getSlackIntegrationByProject(workspaceId), - queryFn: () => fetchSlackIntegrationByProject(workspaceId), - enabled: Boolean(workspaceId) - }); diff --git a/frontend/src/hooks/api/slack/types.ts b/frontend/src/hooks/api/slack/types.ts deleted file mode 100644 index 9adad3f1b..000000000 --- a/frontend/src/hooks/api/slack/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type ProjectSlackIntegration = { - id: string; - teamName: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; -}; - -export type TUpdateSlackIntegrationDTO = { - id: string; - workspaceId: string; - isAccessRequestNotificationEnabled?: boolean; - accessRequestChannels?: string; - isSecretRequestNotificationEnabled?: boolean; - secretRequestChannels?: string; -}; - -export type TDeleteSlackIntegrationDTO = { - id: string; - workspaceId: string; -}; diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts index 8d0529b2e..9de1406fc 100644 --- a/frontend/src/hooks/api/workflowIntegrations/index.ts +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -1,4 +1,8 @@ -export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation"; +export { + useDeleteSlackIntegration, + useUpdateProjectSlackConfig, + useUpdateSlackIntegration +} from "./mutation"; export { fetchSlackInstallUrl, useGetSlackIntegrationById, diff --git a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx index 0979e423c..6d779154f 100644 --- a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -2,8 +2,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { workspaceKeys } from "../workspace/queries"; import { workflowIntegrationKeys } from "./queries"; -import { TDeleteSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./types"; +import { + TDeleteSlackIntegrationDTO, + TUpdateProjectSlackConfigDTO, + TUpdateSlackIntegrationDTO +} from "./types"; export const useUpdateSlackIntegration = () => { const queryClient = useQueryClient(); @@ -36,3 +41,20 @@ export const useDeleteSlackIntegration = () => { } }); }; + +export const useUpdateProjectSlackConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto: TUpdateProjectSlackConfigDTO) => { + const { data } = await apiRequest.put( + `/api/v1/workspace/${dto.workspaceId}/slack-config`, + dto + ); + + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSlackConfig(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index 5fb1868e2..b56fdb23d 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -20,3 +20,21 @@ export type TDeleteSlackIntegrationDTO = { id: string; orgId: string; }; + +export type ProjectSlackConfig = { + id: string; + slackIntegrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; +}; + +export type TUpdateProjectSlackConfigDTO = { + workspaceId: string; + slackIntegrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 3f320d0d9..e3c886258 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -22,6 +22,7 @@ export { useGetWorkspaceIndexStatus, useGetWorkspaceIntegrations, useGetWorkspaceSecrets, + useGetWorkspaceSlackConfig, useGetWorkspaceUsers, useListWorkspaceCas, useListWorkspaceCertificates, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 9ca3bbf34..0557e5f6f 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -16,6 +16,7 @@ import { TPkiCollection } from "../pkiCollections/types"; import { EncryptedSecret } from "../secrets/types"; import { userKeys } from "../users/queries"; import { TWorkspaceUser } from "../users/types"; +import { ProjectSlackConfig } from "../workflowIntegrations/types"; import { CreateEnvironmentDTO, CreateWorkspaceDTO, @@ -71,7 +72,9 @@ export const workspaceKeys = { getWorkspacePkiCollections: (workspaceId: string) => [{ workspaceId }, "workspace-pki-collections"] as const, getWorkspaceCertificateTemplates: (workspaceId: string) => - [{ workspaceId }, "workspace-certificate-templates"] as const + [{ workspaceId }, "workspace-certificate-templates"] as const, + getWorkspaceSlackConfig: (workspaceId: string) => + [{ workspaceId }, "workspace-slack-config"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -667,3 +670,17 @@ export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspac enabled: Boolean(workspaceId) }); }; + +export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/workspace/${workspaceId}/slack-config` + ); + + return data; + }, + enabled: Boolean(workspaceId) + }); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 4c15e8504..f24055278 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -6,9 +6,9 @@ import { useWorkspace } from "@app/context"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { EncryptionTab } from "./components/EncryptionTab"; -import { NotificationTab } from "./components/NotificationSection"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { WebhooksTab } from "./components/WebhooksTab"; +import { WorkflowIntegrationTab } from "./components/WorkflowIntegrationSection"; export const ProjectSettingsPage = () => { const { t } = useTranslation(); @@ -20,7 +20,7 @@ export const ProjectSettingsPage = () => { key: "tab-project-encryption", isHidden: currentWorkspace?.version !== ProjectVersion.V3 }, - { name: "Notification", key: "tab-project-notification" }, + { name: "Workflow Integrations", key: "tab-workflow-integrations" }, { name: "Webhooks", key: "tab-project-webhooks" } ]; @@ -59,7 +59,7 @@ export const ProjectSettingsPage = () => { )} - + diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx deleted file mode 100644 index aafa62ff5..000000000 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./NotificationTab"; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx similarity index 50% rename from frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx rename to frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx index 3a6ae45fd..1bb302bde 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx @@ -1,45 +1,45 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { useRouter } from "next/router"; +import Link from "next/link"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, ContentLoader, - DeleteActionModal, + EmptyState, FormControl, Input, + Select, + SelectItem, Switch } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { usePopUp, useToggle } from "@app/hooks"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { - fetchSlackInstallUrl, - useDeleteSlackIntegration, - useGetSlackIntegrationByProject, - useUpdateSlackIntegration + useGetSlackIntegrations, + useGetWorkspaceSlackConfig, + useUpdateProjectSlackConfig } from "@app/hooks/api"; const formSchema = z.object({ + slackIntegrationId: z.string(), isSecretRequestNotificationEnabled: z.boolean(), - secretRequestChannels: z.string(), + secretRequestChannels: z.string().default(""), isAccessRequestNotificationEnabled: z.boolean(), - accessRequestChannels: z.string() + accessRequestChannels: z.string().default("") }); -type TSlackIntegrationForm = z.infer; +type TSlackConfigForm = z.infer; -export const NotificationTab = () => { +export const WorkflowIntegrationTab = () => { const { currentWorkspace } = useWorkspace(); - const { data: slackIntegration, isLoading: isSlackIntegrationLoading } = - useGetSlackIntegrationByProject(currentWorkspace?.id); - const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration(); - const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration(); - const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([ - "deleteSlackIntegration" - ] as const); + const { data: slackConfig, isLoading: isSlackConfigLoading } = useGetWorkspaceSlackConfig({ + workspaceId: currentWorkspace?.id ?? "" + }); + const { data: slackIntegrations } = useGetSlackIntegrations(currentWorkspace?.orgId); + const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectSlackConfig(); const { control, @@ -47,29 +47,27 @@ export const NotificationTab = () => { handleSubmit, setValue, formState: { isDirty, isSubmitting } - } = useForm({ + } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - isSecretRequestNotificationEnabled: slackIntegration?.isSecretRequestNotificationEnabled, - secretRequestChannels: slackIntegration?.secretRequestChannels || "", - isAccessRequestNotificationEnabled: slackIntegration?.isAccessRequestNotificationEnabled, - accessRequestChannels: slackIntegration?.accessRequestChannels || "" + isAccessRequestNotificationEnabled: false, + accessRequestChannels: "", + isSecretRequestNotificationEnabled: false, + secretRequestChannels: "" } }); - const router = useRouter(); - const [isConnectToSlackLoading, setIsConnectToSlackLoading] = useToggle(false); - const [isReinstallLoading, setIsReinstallLoading] = useToggle(false); const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); + const selectedSlackIntegrationId = watch("slackIntegrationId"); const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); - const handleIntegrationSave = async (data: TSlackIntegrationForm) => { - if (!currentWorkspace || !slackIntegration) { + const handleIntegrationSave = async (data: TSlackConfigForm) => { + if (!currentWorkspace) { return; } - await updateSlackIntegration({ - workspaceId: currentWorkspace?.id, - id: slackIntegration?.id, + + await updateProjectSlackConfig({ + workspaceId: currentWorkspace.id, ...data }); @@ -79,96 +77,76 @@ export const NotificationTab = () => { }); }; - const handleIntegrationDelete = async () => { - if (!currentWorkspace || !slackIntegration) { - return; - } - await deleteSlackIntegration({ - workspaceId: currentWorkspace.id, - id: slackIntegration.id - }); - - handlePopUpToggle("deleteSlackIntegration", false); - - createNotification({ - type: "success", - text: "Successfully deleted slack integration" - }); - }; - - const triggerSlackInstall = async () => { - const slackInstallUrl = await fetchSlackInstallUrl(currentWorkspace?.id); - if (slackInstallUrl) { - router.push(slackInstallUrl); - } - }; - useEffect(() => { - if (slackIntegration) { + if (slackConfig) { + setValue("slackIntegrationId", slackConfig.slackIntegrationId); setValue( "isSecretRequestNotificationEnabled", - slackIntegration.isSecretRequestNotificationEnabled + slackConfig.isSecretRequestNotificationEnabled ); - setValue("secretRequestChannels", slackIntegration.secretRequestChannels); + setValue("secretRequestChannels", slackConfig.secretRequestChannels); setValue( "isAccessRequestNotificationEnabled", - slackIntegration.isAccessRequestNotificationEnabled + slackConfig.isAccessRequestNotificationEnabled ); - setValue("accessRequestChannels", slackIntegration.accessRequestChannels); + setValue("accessRequestChannels", slackConfig.accessRequestChannels); } - }, [slackIntegration]); + }, [slackConfig]); - if (isSlackIntegrationLoading) { + if (isSlackConfigLoading) { return ; } - return ( - <> -
-
-

- Slack Integration -

+ return !slackIntegrations?.length ? ( + + +
+ Create one now
-

- This integration allows you to send notifications to your Slack workspace in response to - events in your project. -

- {!slackIntegration && ( - - )} - {slackIntegration && ( -
-
Connected Slack workspace: {slackIntegration.teamName}
-
- - -
+ + + ) : ( +
+
+

Slack Integration

+
+

+ This integration allows you to send notifications to your Slack workspace in response to + events in your project. +

+ +
+ + {(isAllowed) => ( + ( + + + + )} + control={control} + name="slackIntegrationId" + /> + )} + +
+ {selectedSlackIntegrationId && ( + <>

Events

{ > Save - + )} -
- handlePopUpToggle("deleteSlackIntegration", isOpen)} - deleteKey="confirm" - onDeleteApproved={handleIntegrationDelete} - /> - + +
); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/index.tsx new file mode 100644 index 000000000..937daaed5 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/index.tsx @@ -0,0 +1 @@ +export * from "./WorkflowIntegrationTab"; From 8c03c160a9b3ec659950ecd5847392f867946def Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Sep 2024 01:48:08 +0800 Subject: [PATCH 09/27] misc: implemented secret approval request and project audit logs --- .../ee/services/audit-log/audit-log-types.ts | 27 ++++++++++++++++-- .../secret-approval-request-fns.ts | 8 +++--- .../secret-approval-request-service.ts | 16 +++++------ backend/src/server/routes/index.ts | 2 +- .../src/server/routes/v1/project-router.ts | 28 +++++++++++++++++++ .../slack/project-slack-config-dal.ts | 18 ++++++++++-- backend/src/services/slack/slack-fns.ts | 10 +++---- .../src/hooks/api/auditLogs/constants.tsx | 6 ++-- frontend/src/hooks/api/auditLogs/enums.tsx | 6 ++-- frontend/src/hooks/api/auditLogs/types.tsx | 21 +++++--------- .../AuditLogsPage/components/LogsTableRow.tsx | 18 ++++++------ 11 files changed, 106 insertions(+), 54 deletions(-) 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 29f5e99bb..b57505f60 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -173,7 +173,9 @@ export enum EventType { ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", GET_SLACK_INTEGRATION = "get-slack-integration", UPDATE_SLACK_INTEGRATION = "update-slack-integration", - DELETE_SLACK_INTEGRATION = "delete-slack-integration" + DELETE_SLACK_INTEGRATION = "delete-slack-integration", + GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config" } interface UserActorMetadata { @@ -1481,6 +1483,25 @@ interface GetSlackIntegration { }; } +interface UpdateProjectSlackConfig { + type: EventType.UPDATE_PROJECT_SLACK_CONFIG; + metadata: { + id: string; + slackIntegrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; + }; +} + +interface GetProjectSlackConfig { + type: EventType.GET_PROJECT_SLACK_CONFIG; + metadata: { + id: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1615,4 +1636,6 @@ export type Event = | AttemptCreateSlackIntegration | UpdateSlackIntegration | DeleteSlackIntegration - | GetSlackIntegration; + | GetSlackIntegration + | UpdateProjectSlackConfig + | GetProjectSlackConfig; 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 ddedceac1..466b4b94f 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 @@ -2,8 +2,8 @@ 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 { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-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"; @@ -23,9 +23,9 @@ type TTriggerSecretApprovalSlackNotif = { projectId: string; projectDAL: Pick; kmsService: Pick; + projectSlackConfigDAL: Pick; secretApprovalRequest: TSecretApprovalRequests; secretPath: string; - slackIntegrationDAL: Pick; userDAL: Pick; }; @@ -34,7 +34,7 @@ export const triggerSecretApprovalSlackNotif = async ({ projectDAL, kmsService, secretApprovalRequest, - slackIntegrationDAL, + projectSlackConfigDAL, userDAL, environment, secretPath @@ -74,8 +74,8 @@ export const triggerSecretApprovalSlackNotif = async ({ projectId, projectDAL, kmsService, - slackIntegrationDAL, payloadMessage: messageBody, + projectSlackConfigDAL, payloadBlocks, feature: SlackTriggerFeature.SECRET_APPROVAL }); 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 38ce76e08..cff973420 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,7 +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 { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -105,7 +105,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; secretApprovalPolicyDAL: Pick; - slackIntegrationDAL: Pick; + projectSlackConfigDAL: Pick; licenseService: Pick; }; @@ -134,8 +134,8 @@ export const secretApprovalRequestServiceFactory = ({ secretV2BridgeDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, - slackIntegrationDAL, - licenseService + licenseService, + projectSlackConfigDAL }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -1080,8 +1080,8 @@ export const secretApprovalRequestServiceFactory = ({ projectDAL, kmsService, secretApprovalRequest, - slackIntegrationDAL, - userDAL + userDAL, + projectSlackConfigDAL }); await sendApprovalEmailsFn({ @@ -1354,8 +1354,8 @@ export const secretApprovalRequestServiceFactory = ({ projectDAL, kmsService, secretApprovalRequest, - slackIntegrationDAL, - userDAL + userDAL, + projectSlackConfigDAL }); await sendApprovalEmailsFn({ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ba69a5c51..ede97b06d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -881,7 +881,7 @@ export const registerRoutes = async ( projectEnvDAL, userDAL, licenseService, - slackIntegrationDAL + projectSlackConfigDAL }); const secretService = secretServiceFactory({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index dae8182a0..d468da0bc 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -8,6 +8,7 @@ import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -575,6 +576,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.GET_PROJECT_SLACK_CONFIG, + metadata: { + id: slackConfig.id + } + } + }); + return slackConfig; } }); @@ -618,6 +630,22 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { ...req.body }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.UPDATE_PROJECT_SLACK_CONFIG, + metadata: { + id: slackConfig.id, + slackIntegrationId: slackConfig.slackIntegrationId, + isAccessRequestNotificationEnabled: slackConfig.isAccessRequestNotificationEnabled, + accessRequestChannels: slackConfig.accessRequestChannels, + isSecretRequestNotificationEnabled: slackConfig.isSecretRequestNotificationEnabled, + secretRequestChannels: slackConfig.secretRequestChannels + } + } + }); + return slackConfig; } }); diff --git a/backend/src/services/slack/project-slack-config-dal.ts b/backend/src/services/slack/project-slack-config-dal.ts index 2c08fdeb3..276442b1b 100644 --- a/backend/src/services/slack/project-slack-config-dal.ts +++ b/backend/src/services/slack/project-slack-config-dal.ts @@ -1,11 +1,25 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TProjectSlackConfigDALFactory = ReturnType; export const projectSlackConfigDALFactory = (db: TDbClient) => { const projectSlackConfigOrm = ormify(db, TableName.ProjectSlackConfigs); - return projectSlackConfigOrm; + const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => { + return (tx || db.replicaNode())(TableName.ProjectSlackConfigs) + .join( + TableName.SlackIntegrations, + `${TableName.ProjectSlackConfigs}.slackIntegrationId`, + `${TableName.SlackIntegrations}.id` + ) + .where("projectId", "=", projectId) + .select(selectAllTableCols(TableName.ProjectSlackConfigs), selectAllTableCols(TableName.SlackIntegrations)) + .first(); + }; + + return { ...projectSlackConfigOrm, getIntegrationDetailsByProject }; }; diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 5c22186c6..cf28a550f 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -3,14 +3,14 @@ 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 { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; import { SlackTriggerFeature } from "./slack-types"; export const triggerSlackNotification = async ({ projectId, payloadBlocks, payloadMessage, - slackIntegrationDAL, + projectSlackConfigDAL, projectDAL, kmsService, feature @@ -18,15 +18,13 @@ export const triggerSlackNotification = async ({ projectId: string; payloadBlocks: Block[]; payloadMessage: string; - slackIntegrationDAL: Pick; + projectSlackConfigDAL: Pick; projectDAL: Pick; kmsService: Pick; feature: SlackTriggerFeature; }) => { const project = await projectDAL.findById(projectId); - const slackIntegration = await slackIntegrationDAL.findOne({ - projectId - }); + const slackIntegration = await projectSlackConfigDAL.getIntegrationDetailsByProject(project.id); if (!slackIntegration) { return; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 7e59e639f..cc72cfa86 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -78,10 +78,8 @@ export const eventToNameMap: { [K in EventType]: string } = { "Create certificate template EST configuration", [EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: "Update certificate template EST configuration", - [EventType.UPDATE_SLACK_INTEGRATION]: "Update slack integration", - [EventType.DELETE_SLACK_INTEGRATION]: "Delete slack integration", - [EventType.GET_SLACK_INTEGRATION]: "Get slack integration", - [EventType.ATTEMPT_CREATE_SLACK_INTEGRATION]: "Initiate create slack integration flow" + [EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update project slack configuration", + [EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 5d58b2be1..b110e330b 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -90,8 +90,6 @@ export enum EventType { CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", - ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", - GET_SLACK_INTEGRATION = "get-slack-integration", - UPDATE_SLACK_INTEGRATION = "update-slack-integration", - DELETE_SLACK_INTEGRATION = "delete-slack-integration" + UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + GET_PROJECT_SLACK_CONFIG = "get-project-slack-config" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 959f9aaa3..85f4c2f73 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -742,10 +742,11 @@ interface GetCertificateTemplateEstConfig { }; } -interface UpdateSlackIntegration { - type: EventType.UPDATE_SLACK_INTEGRATION; +interface UpdateProjectSlackConfig { + type: EventType.UPDATE_PROJECT_SLACK_CONFIG; metadata: { id: string; + slackIntegrationId: string; isAccessRequestNotificationEnabled: boolean; accessRequestChannels: string; isSecretRequestNotificationEnabled: boolean; @@ -753,15 +754,8 @@ interface UpdateSlackIntegration { }; } -interface DeleteSlackIntegration { - type: EventType.DELETE_SLACK_INTEGRATION; - metadata: { - id: string; - }; -} - -interface GetSlackIntegration { - type: EventType.GET_SLACK_INTEGRATION; +interface GetProjectSlackConfig { + type: EventType.GET_PROJECT_SLACK_CONFIG; metadata: { id: string; }; @@ -843,9 +837,8 @@ export type Event = | UpdateCertificateTemplateEstConfig | CreateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig - | UpdateSlackIntegration - | DeleteSlackIntegration - | GetSlackIntegration; + | UpdateProjectSlackConfig + | GetProjectSlackConfig; export type AuditLog = { id: string; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index ec53eec3b..bc81e9160 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -442,23 +442,23 @@ export const LogsTableRow = ({ auditLog }: Props) => {

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

); - case EventType.UPDATE_SLACK_INTEGRATION: + case EventType.GET_PROJECT_SLACK_CONFIG: return ( -

{`Slack integration ID: ${event.metadata.id}`}

+

{`Project Slack Config ID: ${event.metadata.id}`}

+ + ); + case EventType.UPDATE_PROJECT_SLACK_CONFIG: + return ( + +

{`Project Slack Config ID: ${event.metadata.id}`}

+

{`Slack integration ID: ${event.metadata.slackIntegrationId}`}

{`Access Request Notification Status: ${event.metadata.isAccessRequestNotificationEnabled}`}

{`Access Request Channels: ${event.metadata.accessRequestChannels}`}

{`Secret Approval Request Notification Status: ${event.metadata.isSecretRequestNotificationEnabled}`}

{`Secret Request Channels: ${event.metadata.secretRequestChannels}`}

); - case EventType.DELETE_SLACK_INTEGRATION: - case EventType.GET_SLACK_INTEGRATION: - return ( - -

{`Slack integration ID: ${event.metadata.id}`}

- - ); default: return ; } From 5343c7af00bbb36721ea852be422c603f6ec8529 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Sep 2024 02:22:53 +0800 Subject: [PATCH 10/27] misc: added auto redirect to workflow settings tab --- backend/src/server/routes/v1/slack-router.ts | 2 +- .../components/OrgTabGroup/OrgTabGroup.tsx | 18 ++++++++++++++++-- .../WorkflowIntegrationTab.tsx | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 88ae200d9..1033b6d51 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -262,7 +262,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { orgId: string; }; - return res.redirect(`${appCfg.SITE_URL}/org/${metadata.orgId}/settings`); + return res.redirect(`${appCfg.SITE_URL}/org/${metadata.orgId}/settings?selectedTab=workflow-integrations`); } }); } diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 029018914..090fd63e0 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -1,4 +1,5 @@ -import { Fragment } from "react"; +import { Fragment, useEffect, useState } from "react"; +import { useRouter } from "next/router"; import { Tab } from "@headlessui/react"; import { AuditLogStreamsTab } from "../AuditLogStreamTab"; @@ -15,8 +16,21 @@ const tabs = [ { name: "Audit Log Streams", key: "tag-audit-log-streams" } ]; export const OrgTabGroup = () => { + const { query } = useRouter(); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const selectedTab = query.selectedTab as string; + + useEffect(() => { + if (selectedTab) { + const index = tabs.findIndex((tab) => tab.key === selectedTab); + if (index !== -1) { + setSelectedTabIndex(index); + } + } + }, [selectedTab]); + return ( - + {tabs.map((tab) => ( diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx index 1bb302bde..5cde9a561 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx @@ -99,7 +99,7 @@ export const WorkflowIntegrationTab = () => { return !slackIntegrations?.length ? ( - +
Create one now
From 15c5fe40951f34fbf9255e096edf4cb0e19ffe8e Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Sep 2024 15:44:58 +0800 Subject: [PATCH 11/27] misc: slack integration reinstall --- .../ee/services/audit-log/audit-log-types.ts | 9 ++ backend/src/server/routes/v1/slack-router.ts | 44 ++++++++++ backend/src/services/slack/slack-service.ts | 82 +++++++++++++++++++ backend/src/services/slack/slack-types.ts | 15 ++++ .../hooks/api/workflowIntegrations/index.ts | 1 + .../api/workflowIntegrations/queries.tsx | 14 ++++ .../OrgWorkflowIntegrationTab.tsx | 43 +++++++++- 7 files changed, 207 insertions(+), 1 deletion(-) 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 b57505f60..b70f9dfea 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -171,6 +171,7 @@ export enum EventType { UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", + ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_SLACK_INTEGRATION = "get-slack-integration", UPDATE_SLACK_INTEGRATION = "update-slack-integration", DELETE_SLACK_INTEGRATION = "delete-slack-integration", @@ -1460,6 +1461,13 @@ interface AttemptCreateSlackIntegration { }; } +interface AttemptReinstallSlackIntegration { + type: EventType.ATTEMPT_REINSTALL_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + interface UpdateSlackIntegration { type: EventType.UPDATE_SLACK_INTEGRATION; metadata: { @@ -1634,6 +1642,7 @@ export type Event = | UpdateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig | AttemptCreateSlackIntegration + | AttemptReinstallSlackIntegration | UpdateSlackIntegration | DeleteSlackIntegration | GetSlackIntegration diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 1033b6d51..5a9bc67e5 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -56,6 +56,50 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/reinstall", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + slackIntegrationId: z.string() + }), + response: { + 200: z.string() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const url = await server.services.slack.getReinstallUrl({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.query.slackIntegrationId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ATTEMPT_REINSTALL_SLACK_INTEGRATION, + metadata: { + id: req.query.slackIntegrationId + } + } + }); + + return url; + } + }); + server.route({ method: "GET", url: "/", diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index b6a60492d..f3da106c9 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -12,9 +12,11 @@ import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TCompleteSlackIntegrationDTO, TDeleteSlackIntegrationDTO, + TGetReinstallUrlDTO, TGetSlackInstallUrlDTO, TGetSlackIntegrationByIdDTO, TGetSlackIntegrationByOrgDTO, + TReinstallSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./slack-types"; @@ -66,6 +68,38 @@ export const slackServiceFactory = ({ }); }; + const reinstallSlackIntegration = async ({ + id, + teamId, + teamName, + slackUserId, + slackAppId, + botAccessToken, + slackBotId, + slackBotUserId + }: TReinstallSlackIntegrationDTO) => { + const slackIntegration = await slackIntegrationDAL.findById(id); + + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + orgId: slackIntegration.orgId, + type: KmsDataKey.Organization + }); + + const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({ + plainText: Buffer.from(botAccessToken, "utf8") + }); + + await slackIntegrationDAL.updateById(id, { + teamId, + teamName, + slackUserId, + slackAppId, + slackBotId, + slackBotUserId, + encryptedBotAccessToken + }); + }; + const getSlackInstaller = async () => { const appCfg = getConfig(); @@ -89,11 +123,25 @@ export const slackServiceFactory = ({ } const metadata = JSON.parse(installation.metadata || "") as { + id?: string; orgId: string; slug: string; description?: string; }; + if (metadata.id) { + return reinstallSlackIntegration({ + id: metadata.id, + teamId: installation.team?.id || "", + teamName: installation.team?.name || "", + slackUserId: installation.user.id, + slackAppId: installation.appId || "", + botAccessToken: installation.bot?.token || "", + slackBotId: installation.bot?.id || "", + slackBotUserId: installation.bot?.userId || "" + }); + } + return completeSlackIntegration({ orgId: metadata.orgId, slug: metadata.slug, @@ -154,6 +202,39 @@ export const slackServiceFactory = ({ return url; }; + const getReinstallUrl = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TGetReinstallUrlDTO) => { + const appCfg = getConfig(); + const slackIntegration = await slackIntegrationDAL.findById(id); + + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + slackIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const installer = await getSlackInstaller(); + const url = await installer.generateInstallUrl({ + scopes: ["chat:write.public", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"], + metadata: JSON.stringify({ + id, + orgId: slackIntegration.orgId + }), + redirectUri: `${appCfg.SITE_URL}/api/v1/workflow-integrations/slack/oauth_redirect` + }); + + return url; + }; + const getSlackIntegrationsByOrg = async ({ actorId, actor, @@ -265,6 +346,7 @@ export const slackServiceFactory = ({ return { getInstallUrl, + getReinstallUrl, getSlackIntegrationsByOrg, getSlackIntegrationById, completeSlackIntegration, diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index ab9035b09..b1b64ecba 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -5,6 +5,10 @@ export type TGetSlackInstallUrlDTO = { description?: string; } & Omit; +export type TGetReinstallUrlDTO = { + id: string; +} & Omit; + export type TGetSlackIntegrationByOrgDTO = Omit; export type TGetSlackIntegrationByIdDTO = { id: string } & Omit; @@ -31,6 +35,17 @@ export type TCompleteSlackIntegrationDTO = { slackBotUserId: string; }; +export type TReinstallSlackIntegrationDTO = { + id: string; + teamId: string; + teamName: string; + slackUserId: string; + slackAppId: string; + botAccessToken: string; + slackBotId: string; + slackBotUserId: string; +}; + export enum SlackTriggerFeature { SECRET_APPROVAL = "secret-approval", ACCESS_REQUEST = "access-request" diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts index 9de1406fc..5ad8f9105 100644 --- a/frontend/src/hooks/api/workflowIntegrations/index.ts +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -5,6 +5,7 @@ export { } from "./mutation"; export { fetchSlackInstallUrl, + fetchSlackReinstallUrl, useGetSlackIntegrationById, useGetSlackIntegrations } from "./queries"; diff --git a/frontend/src/hooks/api/workflowIntegrations/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx index 30e028828..7f4c96e62 100644 --- a/frontend/src/hooks/api/workflowIntegrations/queries.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -26,6 +26,20 @@ export const fetchSlackInstallUrl = async ({ return data; }; +export const fetchSlackReinstallUrl = async ({ + slackIntegrationId +}: { + slackIntegrationId: string; +}) => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack/reinstall", { + params: { + slackIntegrationId + } + }); + + return data; +}; + export const fetchSlackIntegrations = async () => { const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack"); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx index 9c2018168..ee37f5685 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; import { faSlack } from "@fortawesome/free-brands-svg-icons"; import { faEllipsis, faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -24,7 +25,11 @@ import { import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; -import { useDeleteSlackIntegration, useGetSlackIntegrations } from "@app/hooks/api"; +import { + fetchSlackReinstallUrl, + useDeleteSlackIntegration, + useGetSlackIntegrations +} from "@app/hooks/api"; import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; import { AddWorkflowIntegrationForm } from "./AddWorkflowIntegrationForm"; @@ -39,6 +44,7 @@ export const OrgWorkflowIntegrationTab = withPermission( ] as const); const { currentOrg } = useOrganization(); + const router = useRouter(); const { data: slackIntegrations, isLoading: isSlackIntegrationsLoading } = useGetSlackIntegrations(currentOrg?.id); const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration(); @@ -63,6 +69,18 @@ export const OrgWorkflowIntegrationTab = withPermission( }); }; + const triggerReinstall = async (platform: WorkflowIntegrationPlatform, id: string) => { + if (platform === WorkflowIntegrationPlatform.SLACK) { + const slackReinstallUrl = await fetchSlackReinstallUrl({ + slackIntegrationId: id + }); + + if (slackReinstallUrl) { + router.push(slackReinstallUrl); + } + } + }; + const isIntegrationsLoading = isSlackIntegrationsLoading; return ( @@ -132,6 +150,29 @@ export const OrgWorkflowIntegrationTab = withPermission( > More details + + {(isAllowed) => ( + { + e.stopPropagation(); + + triggerReinstall( + WorkflowIntegrationPlatform.SLACK, + slackIntegration.id + ); + }} + > + Reinstall + + )} + Date: Wed, 4 Sep 2024 18:03:07 +0800 Subject: [PATCH 12/27] feat: slack channel suggestions --- .../src/server/routes/v1/project-router.ts | 20 +-- backend/src/server/routes/v1/slack-router.ts | 38 +++++ backend/src/services/slack/slack-fns.ts | 78 ++++++---- backend/src/services/slack/slack-service.ts | 41 ++++- backend/src/services/slack/slack-types.ts | 2 + .../hooks/api/workflowIntegrations/index.ts | 1 + .../api/workflowIntegrations/mutation.tsx | 8 +- .../api/workflowIntegrations/queries.tsx | 26 +++- .../hooks/api/workflowIntegrations/types.ts | 5 + .../WorkflowIntegrationTab.tsx | 143 ++++++++++++++---- 10 files changed, 284 insertions(+), 78 deletions(-) diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index d468da0bc..c72a6f167 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -576,16 +576,18 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.GET_PROJECT_SLACK_CONFIG, - metadata: { - id: slackConfig.id + if (slackConfig) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.GET_PROJECT_SLACK_CONFIG, + metadata: { + id: slackConfig.id + } } - } - }); + }); + } return slackConfig; } diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 5a9bc67e5..f5b6c8f06 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -232,6 +232,44 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:slackIntegrationId/channels", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + response: { + 200: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackChannels = await server.services.slack.getSlackIntegrationChannels({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId + }); + + return slackChannels; + } + }); + server.route({ method: "PATCH", url: "/:slackIntegrationId", diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index cf28a550f..f12e72851 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -1,11 +1,44 @@ import { Block, WebClient } from "@slack/web-api"; +import { logger } from "@app/lib/logger"; + import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; import { SlackTriggerFeature } from "./slack-types"; +export const fetchSlackChannels = async (botKey: string) => { + const slackChannels: { + name: string; + id: string; + }[] = []; + + const slackWebClient = new WebClient(botKey); + 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) => + slackChannels.push({ + name: channel.name_normalized as string, + id: 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 + + return slackChannels; +}; + export const triggerSlackNotification = async ({ projectId, payloadBlocks, @@ -30,15 +63,15 @@ export const triggerSlackNotification = async ({ return; } - let targetChannels: string[] = []; + let targetChannelIds: string[] = []; if (feature === SlackTriggerFeature.ACCESS_REQUEST) { - targetChannels = slackIntegration.accessRequestChannels?.split(", ") || []; - if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) { + targetChannelIds = slackIntegration.accessRequestChannels?.split(", ") || []; + if (!targetChannelIds.length || !slackIntegration.isAccessRequestNotificationEnabled) { return; } } else if (feature === SlackTriggerFeature.SECRET_APPROVAL) { - targetChannels = slackIntegration.secretRequestChannels?.split(", ") || []; - if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) { + targetChannelIds = slackIntegration.secretRequestChannels?.split(", ") || []; + if (!targetChannelIds.length || !slackIntegration.isSecretRequestNotificationEnabled) { return; } } @@ -52,35 +85,16 @@ export const triggerSlackNotification = async ({ 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) { + 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: payloadBlocks - }); + await slackWebClient.chat + .postMessage({ + channel: conversationId, + text: payloadMessage, + blocks: payloadBlocks + }) + .catch((err) => void logger.error(err)); } }; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index f3da106c9..f6ccf3c84 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { fetchSlackChannels } from "./slack-fns"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TCompleteSlackIntegrationDTO, @@ -16,6 +17,7 @@ import { TGetSlackInstallUrlDTO, TGetSlackIntegrationByIdDTO, TGetSlackIntegrationByOrgDTO, + TGetSlackIntegrationChannelsDTO, TReinstallSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./slack-types"; @@ -285,6 +287,42 @@ export const slackServiceFactory = ({ return slackIntegration; }; + const getSlackIntegrationChannels = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetSlackIntegrationChannelsDTO) => { + const slackIntegration = await slackIntegrationDAL.findById(id); + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found." + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + slackIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + orgId: slackIntegration.orgId, + type: KmsDataKey.Organization + }); + + const botKey = orgDataKeyDecryptor({ + cipherTextBlob: slackIntegration.encryptedBotAccessToken + }).toString("utf8"); + + return fetchSlackChannels(botKey); + }; + const updateSlackIntegration = async ({ actorId, actor, @@ -352,6 +390,7 @@ export const slackServiceFactory = ({ completeSlackIntegration, getSlackInstaller, updateSlackIntegration, - deleteSlackIntegration + deleteSlackIntegration, + getSlackIntegrationChannels }; }; diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index b1b64ecba..01090c065 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -13,6 +13,8 @@ export type TGetSlackIntegrationByOrgDTO = Omit; export type TGetSlackIntegrationByIdDTO = { id: string } & Omit; +export type TGetSlackIntegrationChannelsDTO = { id: string } & Omit; + export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit< TOrgPermission, "orgId" diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts index 5ad8f9105..b120f4fa4 100644 --- a/frontend/src/hooks/api/workflowIntegrations/index.ts +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -7,5 +7,6 @@ export { fetchSlackInstallUrl, fetchSlackReinstallUrl, useGetSlackIntegrationById, + useGetSlackIntegrationChannels, useGetSlackIntegrations } from "./queries"; diff --git a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx index 6d779154f..aa060ba17 100644 --- a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -20,8 +20,8 @@ export const useUpdateSlackIntegration = () => { return data; }, onSuccess: (_, { orgId, id }) => { - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegrations(orgId)); } }); }; @@ -36,8 +36,8 @@ export const useDeleteSlackIntegration = () => { return data; }, onSuccess: (_, { orgId, id }) => { - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegrations(orgId)); } }); }; diff --git a/frontend/src/hooks/api/workflowIntegrations/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx index 7f4c96e62..15b987281 100644 --- a/frontend/src/hooks/api/workflowIntegrations/queries.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -2,11 +2,12 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { SlackIntegration } from "./types"; +import { SlackIntegration, SlackIntegrationChannel } from "./types"; export const workflowIntegrationKeys = { - getSlackWorkflowIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], - getSlackWorkflowIntegration: (id?: string) => [{ id }, "slack-workflow-integration"] + getSlackIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], + getSlackIntegration: (id?: string) => [{ id }, "slack-workflow-integration"], + getSlackIntegrationChannels: (id?: string) => [{ id }, "slack-workflow-integration-channels"] }; export const fetchSlackInstallUrl = async ({ @@ -54,16 +55,31 @@ export const fetchSlackIntegrationById = async (id?: string) => { return data; }; +export const fetchSlackIntegrationChannels = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/slack/${id}/channels` + ); + + return data; +}; + export const useGetSlackIntegrations = (orgId?: string) => useQuery({ - queryKey: workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId), + queryKey: workflowIntegrationKeys.getSlackIntegrations(orgId), queryFn: () => fetchSlackIntegrations(), enabled: Boolean(orgId) }); export const useGetSlackIntegrationById = (id?: string) => useQuery({ - queryKey: workflowIntegrationKeys.getSlackWorkflowIntegration(id), + queryKey: workflowIntegrationKeys.getSlackIntegration(id), queryFn: () => fetchSlackIntegrationById(id), enabled: Boolean(id) }); + +export const useGetSlackIntegrationChannels = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getSlackIntegrationChannels(id), + queryFn: () => fetchSlackIntegrationChannels(id), + enabled: Boolean(id) + }); diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index b56fdb23d..9342af9f7 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -9,6 +9,11 @@ export type SlackIntegration = { teamName: string; }; +export type SlackIntegrationChannel = { + id: string; + name: string; +}; + export type TUpdateSlackIntegrationDTO = { id: string; orgId: string; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx index 5cde9a561..78111af24 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx @@ -1,6 +1,8 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import Link from "next/link"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -9,6 +11,10 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, ContentLoader, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, FormControl, Input, @@ -18,6 +24,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { + useGetSlackIntegrationChannels, useGetSlackIntegrations, useGetWorkspaceSlackConfig, useUpdateProjectSlackConfig @@ -26,9 +33,9 @@ import { const formSchema = z.object({ slackIntegrationId: z.string(), isSecretRequestNotificationEnabled: z.boolean(), - secretRequestChannels: z.string().default(""), + secretRequestChannels: z.string().array(), isAccessRequestNotificationEnabled: z.boolean(), - accessRequestChannels: z.string().default("") + accessRequestChannels: z.string().array() }); type TSlackConfigForm = z.infer; @@ -51,9 +58,9 @@ export const WorkflowIntegrationTab = () => { resolver: zodResolver(formSchema), defaultValues: { isAccessRequestNotificationEnabled: false, - accessRequestChannels: "", + accessRequestChannels: [], isSecretRequestNotificationEnabled: false, - secretRequestChannels: "" + secretRequestChannels: [] } }); @@ -61,6 +68,14 @@ export const WorkflowIntegrationTab = () => { const selectedSlackIntegrationId = watch("slackIntegrationId"); const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); + const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId); + const slackChannelIdToName = Object.fromEntries( + (slackChannels || []).map((channel) => [channel.id, channel.name]) + ); + const sortedSlackChannels = slackChannels?.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ); + const handleIntegrationSave = async (data: TSlackConfigForm) => { if (!currentWorkspace) { return; @@ -68,7 +83,9 @@ export const WorkflowIntegrationTab = () => { await updateProjectSlackConfig({ workspaceId: currentWorkspace.id, - ...data + ...data, + accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), + secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ") }); createNotification({ @@ -84,14 +101,27 @@ export const WorkflowIntegrationTab = () => { "isSecretRequestNotificationEnabled", slackConfig.isSecretRequestNotificationEnabled ); - setValue("secretRequestChannels", slackConfig.secretRequestChannels); setValue( "isAccessRequestNotificationEnabled", slackConfig.isAccessRequestNotificationEnabled ); - setValue("accessRequestChannels", slackConfig.accessRequestChannels); + + if (slackChannels) { + setValue( + "secretRequestChannels", + slackConfig.secretRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + setValue( + "accessRequestChannels", + slackConfig.accessRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + } } - }, [slackConfig]); + }, [slackConfig, slackChannels]); if (isSlackConfigLoading) { return ; @@ -173,19 +203,49 @@ export const WorkflowIntegrationTab = () => { ( + render={({ field: { value, onChange }, fieldState: { error } }) => ( - + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`secret-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + )} /> @@ -211,20 +271,49 @@ export const WorkflowIntegrationTab = () => { ( + render={({ field: { value, onChange }, fieldState: { error } }) => ( - + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`access-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + )} /> From 956719f797d60f6cfd23b1a4a2a13d129ad165df Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Sep 2024 23:06:30 +0800 Subject: [PATCH 13/27] feat: admin slack configuration --- backend/src/server/routes/index.ts | 10 +- backend/src/server/routes/v1/admin-router.ts | 81 ++++++++++++ backend/src/services/kms/kms-service.ts | 19 +++ backend/src/services/slack/slack-fns.ts | 70 +++++++++++ backend/src/services/slack/slack-service.ts | 30 ++++- .../super-admin/super-admin-service.ts | 63 +++++++++- .../services/super-admin/super-admin-types.ts | 5 + frontend/src/hooks/api/admin/index.ts | 14 ++- frontend/src/hooks/api/admin/mutation.ts | 24 +++- frontend/src/hooks/api/admin/queries.ts | 30 ++++- frontend/src/hooks/api/admin/types.ts | 10 ++ .../admin/DashboardPage/DashboardPage.tsx | 6 + .../admin/DashboardPage/IntegrationPanel.tsx | 118 ++++++++++++++++++ 13 files changed, 465 insertions(+), 15 deletions(-) create mode 100644 frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ede97b06d..f4c59ccdb 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -182,6 +182,7 @@ import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/ import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service"; +import { adminSlackConfigDALFactory } from "@app/services/slack/admin-slack-config-dal"; import { projectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; import { slackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal"; import { slackServiceFactory } from "@app/services/slack/slack-service"; @@ -327,6 +328,7 @@ export const registerRoutes = async ( const slackIntegrationDAL = slackIntegrationDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db); + const adminSlackConfigDAL = adminSlackConfigDALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -526,8 +528,11 @@ export const registerRoutes = async ( serverCfgDAL: superAdminDAL, orgService, keyStore, - licenseService + licenseService, + adminSlackConfigDAL, + kmsService }); + const orgAdminService = orgAdminServiceFactory({ projectDAL, permissionService, @@ -1162,7 +1167,8 @@ export const registerRoutes = async ( const slackService = slackServiceFactory({ permissionService, kmsService, - slackIntegrationDAL + slackIntegrationDAL, + adminSlackConfigDAL }); await superAdminService.initServerCfg(); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6e41df946..bbfe228be 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -123,6 +123,87 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/integrations/slack/bot-creation-url", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.string() + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const url = await server.services.superAdmin.getCustomSlackBotCreationUrl(); + + return url; + } + }); + + server.route({ + method: "GET", + url: "/integrations/slack/config", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + clientId: z.string(), + clientSecret: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const adminSlackConfig = await server.services.superAdmin.getAdminSlackConfig(); + + return adminSlackConfig; + } + }); + + server.route({ + method: "PUT", + url: "/integrations/slack/config", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + clientId: z.string(), + clientSecret: z.string() + }), + response: { + 200: z.object({ + clientId: z.string(), + clientSecret: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const adminSlackConfig = await server.services.superAdmin.updateAdminSlackConfig({ + ...req.body + }); + + return adminSlackConfig; + } + }); + server.route({ method: "DELETE", url: "/user-management/users/:userId", diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 351098f9d..b987f2e3b 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -208,6 +208,23 @@ export const kmsServiceFactory = ({ return org.kmsDefaultKeyId; }; + const encryptWithRootKey = async () => { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + return ({ plainText }: { plainText: Buffer }) => { + const encryptedPlainTextBlob = cipher.encrypt(plainText, ROOT_ENCRYPTION_KEY); + + return Promise.resolve({ cipherTextBlob: encryptedPlainTextBlob }); + }; + }; + + const decryptWithRootKey = async () => { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + return ({ cipherTextBlob }: { cipherTextBlob: Buffer }) => { + const decryptedBlob = cipher.decrypt(cipherTextBlob, ROOT_ENCRYPTION_KEY); + return Promise.resolve(decryptedBlob); + }; + }; + const decryptWithKmsKey = async ({ kmsId, depth = 0 @@ -808,6 +825,8 @@ export const kmsServiceFactory = ({ decryptWithKmsKey, encryptWithInputKey, decryptWithInputKey, + encryptWithRootKey, + decryptWithRootKey, getOrgKmsKeyId, getProjectSecretManagerKmsKeyId, updateProjectSecretManagerKmsKey, diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index f12e72851..6eab272d2 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -1,13 +1,57 @@ import { Block, WebClient } from "@slack/web-api"; +import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; +import { TAdminSlackConfigDALFactory } from "./admin-slack-config-dal"; import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; import { SlackTriggerFeature } from "./slack-types"; +const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; + +export const getCustomSlackBotManifest = () => { + const appCfg = getConfig(); + + return { + display_information: { + name: "Infisical", + description: "Get real-time Infisical updates in Slack", + background_color: "#c2d62b", + long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack. + - Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice. + - Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent. + - Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects. + + By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.` + }, + features: { + app_home: { + home_tab_enabled: false, + messages_tab_enabled: false, + messages_tab_read_only_enabled: true + }, + bot_user: { + display_name: "Infisical", + always_online: true + } + }, + oauth_config: { + redirect_urls: [`${appCfg.SITE_URL}/api/v1/workflow-integrations/slack/oauth_redirect`], + scopes: { + bot: ["chat:write.public", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"] + } + }, + settings: { + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false + } + }; +}; + export const fetchSlackChannels = async (botKey: string) => { const slackChannels: { name: string; @@ -98,3 +142,29 @@ export const triggerSlackNotification = async ({ .catch((err) => void logger.error(err)); } }; + +export const getAdminSlackCredentials = async ({ + adminSlackConfigDAL, + kmsService +}: { + adminSlackConfigDAL: Pick; + kmsService: Pick; +}) => { + const adminSlackConfig = await adminSlackConfigDAL.findById(ADMIN_CONFIG_DB_UUID); + let clientId = ""; + let clientSecret = ""; + const decrypt = await kmsService.decryptWithRootKey(); + + if (adminSlackConfig.encryptedClientId) { + clientId = (await decrypt({ cipherTextBlob: adminSlackConfig.encryptedClientId })).toString(); + } + + if (adminSlackConfig.encryptedClientSecret) { + clientSecret = (await decrypt({ cipherTextBlob: adminSlackConfig.encryptedClientSecret })).toString(); + } + + return { + clientId, + clientSecret + }; +}; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index f6ccf3c84..91433f5fc 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -8,7 +8,8 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; -import { fetchSlackChannels } from "./slack-fns"; +import { TAdminSlackConfigDALFactory } from "./admin-slack-config-dal"; +import { fetchSlackChannels, getAdminSlackCredentials } from "./slack-fns"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TCompleteSlackIntegrationDTO, @@ -25,7 +26,8 @@ import { type TSlackServiceFactoryDep = { slackIntegrationDAL: Pick; permissionService: Pick; - kmsService: Pick; + kmsService: Pick; + adminSlackConfigDAL: Pick; }; export type TSlackServiceFactory = ReturnType; @@ -33,7 +35,8 @@ export type TSlackServiceFactory = ReturnType; export const slackServiceFactory = ({ permissionService, slackIntegrationDAL, - kmsService + kmsService, + adminSlackConfigDAL }: TSlackServiceFactoryDep) => { const completeSlackIntegration = async ({ orgId, @@ -104,16 +107,31 @@ export const slackServiceFactory = ({ const getSlackInstaller = async () => { const appCfg = getConfig(); + const adminSlackCredentials = await getAdminSlackCredentials({ + kmsService, + adminSlackConfigDAL + }); - if (!appCfg.SLACK_CLIENT_ID || !appCfg.SLACK_CLIENT_SECRET) { + let slackClientId = ""; + let slackClientSecret = ""; + + if (adminSlackCredentials.clientId && adminSlackCredentials.clientSecret) { + slackClientId = adminSlackCredentials.clientId; + slackClientSecret = adminSlackCredentials.clientSecret; + } else { + slackClientId = appCfg.SLACK_CLIENT_ID as string; + slackClientSecret = appCfg.SLACK_CLIENT_SECRET as string; + } + + if (!slackClientId || !slackClientSecret) { throw new BadRequestError({ message: "Invalid slack configuration" }); } return new InstallProvider({ - clientId: appCfg.SLACK_CLIENT_ID, - clientSecret: appCfg.SLACK_CLIENT_SECRET, + clientId: slackClientId, + clientSecret: slackClientSecret, stateSecret: appCfg.AUTH_SECRET, legacyStateVerification: true, installationStore: { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index e7798500f..2ede9d415 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -10,15 +10,20 @@ import { BadRequestError } from "@app/lib/errors"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { AuthMethod } from "../auth/auth-type"; +import { TKmsServiceFactory } from "../kms/kms-service"; import { TOrgServiceFactory } from "../org/org-service"; +import { TAdminSlackConfigDALFactory } from "../slack/admin-slack-config-dal"; +import { getAdminSlackCredentials, getCustomSlackBotManifest } from "../slack/slack-fns"; import { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; +import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO, TUpdateAdminSlackConfigDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; + adminSlackConfigDAL: Pick; authService: Pick; + kmsService: Pick; orgService: Pick; keyStore: Pick; licenseService: Pick; @@ -38,7 +43,9 @@ export const superAdminServiceFactory = ({ userDAL, authService, orgService, + adminSlackConfigDAL, keyStore, + kmsService, licenseService }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { @@ -232,11 +239,63 @@ export const superAdminServiceFactory = ({ return user; }; + const getCustomSlackBotCreationUrl = async () => { + return `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent( + JSON.stringify(getCustomSlackBotManifest()) + )}`; + }; + + const updateAdminSlackConfig = async ({ clientId, clientSecret }: TUpdateAdminSlackConfigDTO) => { + return adminSlackConfigDAL.transaction(async (tx) => { + const adminSlackConfig = await adminSlackConfigDAL.findById(ADMIN_CONFIG_DB_UUID, tx); + const encrypt = await kmsService.encryptWithRootKey(); + + const { cipherTextBlob: encryptedClientId } = await encrypt({ plainText: Buffer.from(clientId) }); + const { cipherTextBlob: encryptedClientSecret } = await encrypt({ plainText: Buffer.from(clientSecret) }); + + if (adminSlackConfig) { + await adminSlackConfigDAL.updateById( + ADMIN_CONFIG_DB_UUID, + { + encryptedClientId, + encryptedClientSecret + }, + tx + ); + } else { + await adminSlackConfigDAL.create( + { + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + id: ADMIN_CONFIG_DB_UUID, + encryptedClientId, + encryptedClientSecret + }, + tx + ); + } + + return { + clientId, + clientSecret + }; + }); + }; + + const getAdminSlackConfig = async () => { + return getAdminSlackCredentials({ + kmsService, + adminSlackConfigDAL + }); + }; + return { initServerCfg, updateServerCfg, adminSignUp, getUsers, - deleteUser + deleteUser, + getCustomSlackBotCreationUrl, + updateAdminSlackConfig, + getAdminSlackConfig }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 2d10941b4..b194869ae 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -31,3 +31,8 @@ export enum LoginMethod { LDAP = "ldap", OIDC = "oidc" } + +export type TUpdateAdminSlackConfigDTO = { + clientId: string; + clientSecret: string; +}; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index fc9fb2c24..6bd588d99 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,2 +1,12 @@ -export { useAdminDeleteUser, useCreateAdminUser, useUpdateServerConfig } from "./mutation"; -export { useAdminGetUsers, useGetServerConfig } from "./queries"; +export { + useAdminDeleteUser, + useCreateAdminUser, + useUpdateAdminSlackConfig, + useUpdateServerConfig +} from "./mutation"; +export { + useAdminGetUsers, + useGetAdminSlackConfig, + useGetCustomSlackAppCreationUrl, + useGetServerConfig +} from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b927a92b9..bd3f5fc70 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -5,7 +5,12 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; import { adminQueryKeys, adminStandaloneKeys } from "./queries"; -import { TCreateAdminUserDTO, TServerConfig } from "./types"; +import { + AdminSlackConfig, + TCreateAdminUserDTO, + TServerConfig, + TUpdateAdminSlackConfigDTO +} from "./types"; export const useCreateAdminUser = () => { const queryClient = useQueryClient(); @@ -59,3 +64,20 @@ export const useAdminDeleteUser = () => { } }); }; + +export const useUpdateAdminSlackConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.put( + "/api/v1/admin/integrations/slack/config", + dto + ); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(adminQueryKeys.getAdminSlackConfig()); + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 91368fb9e..fbcc6e946 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -3,7 +3,7 @@ import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-que import { apiRequest } from "@app/config/request"; import { User } from "../types"; -import { AdminGetUsersFilters, TServerConfig } from "./types"; +import { AdminGetUsersFilters, AdminSlackConfig, TServerConfig } from "./types"; export const adminStandaloneKeys = { getUsers: "get-users" @@ -11,7 +11,9 @@ export const adminStandaloneKeys = { export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, - getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const + getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, + getCustomSlackAppCreationUrl: () => ["custom-slack-app-creation-url"] as const, + getAdminSlackConfig: () => ["admin-slack-config"] as const }; const fetchServerConfig = async () => { @@ -59,3 +61,27 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { lastPage.length !== 0 ? pages.length * filters.limit : undefined }); }; + +export const useGetCustomSlackAppCreationUrl = () => + useQuery({ + queryKey: adminQueryKeys.getCustomSlackAppCreationUrl(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/admin/integrations/slack/bot-creation-url" + ); + + return data; + } + }); + +export const useGetAdminSlackConfig = () => + useQuery({ + queryKey: adminQueryKeys.getAdminSlackConfig(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/admin/integrations/slack/config" + ); + + return data; + } + }); diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index bfa2e3e36..cec0b614d 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -38,7 +38,17 @@ export type TCreateAdminUserDTO = { salt: string; }; +export type TUpdateAdminSlackConfigDTO = { + clientId: string; + clientSecret: string; +}; + export type AdminGetUsersFilters = { limit: number; searchTerm: string; }; + +export type AdminSlackConfig = { + clientId: string; + clientSecret: string; +}; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 715bb2e47..2bd0856f0 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -25,6 +25,7 @@ import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api"; import { AuthPanel } from "./AuthPanel"; +import { IntegrationPanel } from "./IntegrationPanel"; import { RateLimitPanel } from "./RateLimitPanel"; import { UserPanel } from "./UserPanel"; @@ -32,6 +33,7 @@ enum TabSections { Settings = "settings", Auth = "auth", RateLimit = "rate-limit", + Integrations = "integrations", Users = "users" } @@ -137,6 +139,7 @@ export const AdminDashboardPage = () => { General Authentication Rate Limit + Integrations Users
@@ -323,6 +326,9 @@ export const AdminDashboardPage = () => { + + + diff --git a/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx b/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx new file mode 100644 index 000000000..4531c1c4b --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx @@ -0,0 +1,118 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { + useGetAdminSlackConfig, + useGetCustomSlackAppCreationUrl, + useUpdateAdminSlackConfig +} from "@app/hooks/api"; + +const slackFormSchema = z.object({ + clientId: z.string(), + clientSecret: z.string() +}); + +type TSlackForm = z.infer; + +export const IntegrationPanel = () => { + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(slackFormSchema) + }); + + const { data: customSlackAppCreationUrl } = useGetCustomSlackAppCreationUrl(); + const { data: adminSlackConfig } = useGetAdminSlackConfig(); + + const { mutateAsync: updateAdminSlackConfig } = useUpdateAdminSlackConfig(); + + useEffect(() => { + if (adminSlackConfig) { + setValue("clientId", adminSlackConfig.clientId); + setValue("clientSecret", adminSlackConfig.clientSecret); + } + }, [adminSlackConfig]); + + const onSlackFormSubmit = async (data: TSlackForm) => { + await updateAdminSlackConfig(data); + + createNotification({ + text: "Updated admin slack configuration", + type: "success" + }); + }; + + return ( +
+
+
Slack Integration
+
+ Step 1: Create your Infisical Slack App +
+
+ +
+
+ Step 2: Configure your instance-wide settings to enable integration with Slack. Copy the + values from the App Credentials page of your custom Slack App. +
+ ( + + field.onChange(e.target.value)} + /> + + )} + /> + ( + + field.onChange(e.target.value)} + /> + + )} + /> +
+ +
+ ); +}; From 09e621539ea2941e1f94c5d8045bfa4b85824f63 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Sep 2024 23:54:19 +0800 Subject: [PATCH 14/27] misc: finalized labels --- .../secret-approval-request/secret-approval-request-fns.ts | 1 - frontend/src/hooks/api/workflowIntegrations/types.ts | 2 +- .../AddWorkflowIntegrationForm.tsx | 6 +----- .../OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx | 2 +- .../OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx | 6 +++--- 5 files changed, 6 insertions(+), 11 deletions(-) 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 466b4b94f..5a0c64b20 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 @@ -39,7 +39,6 @@ export const triggerSecretApprovalSlackNotif = async ({ environment, secretPath }: TTriggerSecretApprovalSlackNotif) => { - // construct message here const appCfg = getConfig(); const project = await projectDAL.findProjectWithOrg(projectId); const user = await userDAL.findById(secretApprovalRequest.committerUserId); diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index 9342af9f7..6d8820f2e 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -1,5 +1,5 @@ export enum WorkflowIntegrationPlatform { - SLACK = "slack" + SLACK = "Slack" } export type SlackIntegration = { diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx index 63182dda1..e31147599 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx @@ -38,11 +38,7 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => { return ( handleFormReset(state)}> - + {wizardStep === WizardSteps.SelectPlatform && ( { return ( - + {workflowPlatform === WorkflowIntegrationPlatform.SLACK && ( onOpenChange(false)} /> )} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx index c72d8aae3..2c17fae59 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx @@ -89,7 +89,7 @@ export const SlackIntegrationForm = ({ id, onClose }: Props) => { control={control} name="slug" render={({ field, fieldState: { error } }) => ( - + )} @@ -104,13 +104,13 @@ export const SlackIntegrationForm = ({ id, onClose }: Props) => { )} /> {slackIntegration && ( - + )}