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";