diff --git a/.env.example b/.env.example index be7e0a621..6489a1cc2 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,6 @@ PLAIN_API_KEY= PLAIN_WISH_LABEL_IDS= SSL_CLIENT_CERTIFICATE_HEADER_KEY= + +WORKFLOW_SLACK_CLIENT_ID= +WORKFLOW_SLACK_CLIENT_SECRET= diff --git a/backend/package-lock.json b/backend/package-lock.json index 19f85472a..0fc41d3e3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -34,6 +34,8 @@ "@peculiar/x509": "^1.12.1", "@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", @@ -5981,6 +5983,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", @@ -7186,6 +7260,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", @@ -10385,6 +10464,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", @@ -12178,6 +12262,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", @@ -14131,6 +14220,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", @@ -14169,6 +14266,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", @@ -14180,6 +14309,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", @@ -15530,6 +15670,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 f89843a00..c7aafcbf9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -131,6 +131,8 @@ "@peculiar/x509": "^1.12.1", "@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/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 49a11691c..1b487ac1f 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -70,12 +70,14 @@ 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"; import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; +import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; declare module "fastify" { interface FastifyRequest { @@ -177,6 +179,8 @@ declare module "fastify" { userEngagement: TUserEngagementServiceFactory; externalKms: TExternalKmsServiceFactory; orgAdmin: TOrgAdminServiceFactory; + slack: TSlackServiceFactory; + workflowIntegration: TWorkflowIntegrationServiceFactory; }; // 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..7cf86032f 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -193,6 +193,9 @@ import { TProjectRolesUpdate, TProjects, TProjectsInsert, + TProjectSlackConfigs, + TProjectSlackConfigsInsert, + TProjectSlackConfigsUpdate, TProjectsUpdate, TProjectUserAdditionalPrivilege, TProjectUserAdditionalPrivilegeInsert, @@ -299,6 +302,9 @@ import { TServiceTokens, TServiceTokensInsert, TServiceTokensUpdate, + TSlackIntegrations, + TSlackIntegrationsInsert, + TSlackIntegrationsUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -322,7 +328,10 @@ import { TUsersUpdate, TWebhooks, TWebhooksInsert, - TWebhooksUpdate + TWebhooksUpdate, + TWorkflowIntegrations, + TWorkflowIntegrationsInsert, + TWorkflowIntegrationsUpdate } from "@app/db/schemas"; import { TSecretV2TagJunction, @@ -776,5 +785,20 @@ declare module "knex/types/tables" { TKmsKeyVersionsInsert, TKmsKeyVersionsUpdate >; + [TableName.SlackIntegrations]: KnexOriginal.CompositeTableType< + TSlackIntegrations, + TSlackIntegrationsInsert, + TSlackIntegrationsUpdate + >; + [TableName.ProjectSlackConfigs]: KnexOriginal.CompositeTableType< + TProjectSlackConfigs, + TProjectSlackConfigsInsert, + TProjectSlackConfigsUpdate + >; + [TableName.WorkflowIntegrations]: KnexOriginal.CompositeTableType< + TWorkflowIntegrations, + TWorkflowIntegrationsInsert, + TWorkflowIntegrationsUpdate + >; } } 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..e06c06105 --- /dev/null +++ b/backend/src/db/migrations/20240830142938_native-slack-integration.ts @@ -0,0 +1,96 @@ +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.WorkflowIntegrations))) { + await knex.schema.createTable(TableName.WorkflowIntegrations, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("integration").notNullable(); + tb.string("slug").notNullable(); + tb.uuid("orgId").notNullable(); + tb.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + tb.string("description"); + tb.unique(["orgId", "slug"]); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.WorkflowIntegrations); + } + + if (!(await knex.schema.hasTable(TableName.SlackIntegrations))) { + await knex.schema.createTable(TableName.SlackIntegrations, (tb) => { + tb.uuid("id", { primaryKey: true }).notNullable(); + tb.foreign("id").references("id").inTable(TableName.WorkflowIntegrations).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.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); + tb.string("secretRequestChannels").notNullable().defaultTo(""); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.ProjectSlackConfigs); + } + + const doesSuperAdminHaveSlackClientId = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedSlackClientId"); + const doesSuperAdminHaveSlackClientSecret = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedSlackClientSecret" + ); + + await knex.schema.alterTable(TableName.SuperAdmin, (tb) => { + if (!doesSuperAdminHaveSlackClientId) { + tb.binary("encryptedSlackClientId"); + } + if (!doesSuperAdminHaveSlackClientSecret) { + tb.binary("encryptedSlackClientSecret"); + } + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProjectSlackConfigs); + await dropOnUpdateTrigger(knex, TableName.ProjectSlackConfigs); + + await knex.schema.dropTableIfExists(TableName.SlackIntegrations); + await dropOnUpdateTrigger(knex, TableName.SlackIntegrations); + + await knex.schema.dropTableIfExists(TableName.WorkflowIntegrations); + await dropOnUpdateTrigger(knex, TableName.WorkflowIntegrations); + + const doesSuperAdminHaveSlackClientId = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedSlackClientId"); + const doesSuperAdminHaveSlackClientSecret = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedSlackClientSecret" + ); + + await knex.schema.alterTable(TableName.SuperAdmin, (tb) => { + if (doesSuperAdminHaveSlackClientId) { + tb.dropColumn("encryptedSlackClientId"); + } + if (doesSuperAdminHaveSlackClientSecret) { + tb.dropColumn("encryptedSlackClientSecret"); + } + }); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 2a089df2b..d856cab49 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -62,6 +62,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"; @@ -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"; @@ -109,3 +111,4 @@ export * from "./user-encryption-keys"; export * from "./user-group-membership"; export * from "./users"; export * from "./webhooks"; +export * from "./workflow-integrations"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ba9dfcae1..068ef74ad 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -114,7 +114,10 @@ export enum TableName { InternalKms = "internal_kms", InternalKmsKeyVersion = "internal_kms_key_version", // @depreciated - KmsKeyVersion = "kms_key_versions" + KmsKeyVersion = "kms_key_versions", + WorkflowIntegrations = "workflow_integrations", + SlackIntegrations = "slack_integrations", + ProjectSlackConfigs = "project_slack_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; 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 new file mode 100644 index 000000000..c989d4224 --- /dev/null +++ b/backend/src/db/schemas/slack-integrations.ts @@ -0,0 +1,27 @@ +// 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(), + teamId: z.string(), + teamName: z.string(), + slackUserId: z.string(), + slackAppId: z.string(), + encryptedBotAccessToken: zodBuffer, + slackBotId: z.string(), + slackBotUserId: z.string(), + 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/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index b676b81a8..edab3a0e9 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const SuperAdminSchema = z.object({ @@ -19,7 +21,9 @@ export const SuperAdminSchema = z.object({ trustLdapEmails: z.boolean().default(false).nullable().optional(), trustOidcEmails: z.boolean().default(false).nullable().optional(), defaultAuthOrgId: z.string().uuid().nullable().optional(), - enabledLoginMethods: z.string().array().nullable().optional() + enabledLoginMethods: z.string().array().nullable().optional(), + encryptedSlackClientId: zodBuffer.nullable().optional(), + encryptedSlackClientSecret: zodBuffer.nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/db/schemas/workflow-integrations.ts b/backend/src/db/schemas/workflow-integrations.ts new file mode 100644 index 000000000..ae1ae9a25 --- /dev/null +++ b/backend/src/db/schemas/workflow-integrations.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 { TImmutableDBKeys } from "./models"; + +export const WorkflowIntegrationsSchema = z.object({ + id: z.string().uuid(), + integration: z.string(), + slug: z.string(), + orgId: z.string().uuid(), + description: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TWorkflowIntegrations = z.infer; +export type TWorkflowIntegrationsInsert = Omit, TImmutableDBKeys>; +export type TWorkflowIntegrationsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 544229609..2a7953ead 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -5,9 +5,13 @@ import { ProjectMembershipRole } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; +import { triggerSlackNotification } from "@app/services/slack/slack-fns"; +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"; @@ -33,7 +37,10 @@ type TSecretApprovalRequestServiceFactoryDep = { permissionService: Pick; accessApprovalPolicyApproverDAL: Pick; projectEnvDAL: Pick; - projectDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "checkProjectUpgradeStatus" | "findProjectBySlug" | "findProjectWithOrg" | "findById" + >; accessApprovalRequestDAL: Pick< TAccessApprovalRequestDALFactory, | "create" @@ -56,6 +63,8 @@ type TSecretApprovalRequestServiceFactoryDep = { TUserDALFactory, "findUserByProjectMembershipId" | "findUsersByProjectMembershipIds" | "find" | "findById" >; + kmsService: Pick; + projectSlackConfigDAL: Pick; }; export type TAccessApprovalRequestServiceFactory = ReturnType; @@ -71,7 +80,9 @@ export const accessApprovalRequestServiceFactory = ({ accessApprovalPolicyApproverDAL, additionalPrivilegeDAL, smtpService, - userDAL + userDAL, + kmsService, + projectSlackConfigDAL }: TSecretApprovalRequestServiceFactoryDep) => { const createAccessApprovalRequest = async ({ isTemporary, @@ -166,13 +177,36 @@ export const accessApprovalRequestServiceFactory = ({ tx ); + const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; + const approvalUrl = `${cfg.SITE_URL}/project/${project.id}/approval`; + + await triggerSlackNotification({ + projectId: project.id, + projectSlackConfigDAL, + projectDAL, + kmsService, + notification: { + type: SlackTriggerFeature.ACCESS_REQUEST, + payload: { + projectName: project.name, + requesterFullName, + isTemporary, + requesterEmail: requestedByUser.email as string, + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl + } + } + }); + await smtpService.sendMail({ recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), subjectLine: "Access Approval Request", substitutions: { projectName: project.name, - requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`, + requesterFullName, requesterEmail: requestedByUser.email, isTemporary, ...(isTemporary && { @@ -181,7 +215,7 @@ export const accessApprovalRequestServiceFactory = ({ secretPath, environment: envSlug, permissions: accessTypes, - approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval` + approvalUrl }, template: SmtpTemplates.AccessApprovalRequest }); 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 0496cf984..10f97a163 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -170,7 +170,14 @@ 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", + 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", + GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config" } interface UserActorMetadata { @@ -1455,6 +1462,63 @@ interface GetCertificateTemplateEstConfig { }; } +interface AttemptCreateSlackIntegration { + type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; + metadata: { + slug: string; + description?: string; + }; +} + +interface AttemptReinstallSlackIntegration { + type: EventType.ATTEMPT_REINSTALL_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + +interface UpdateSlackIntegration { + type: EventType.UPDATE_SLACK_INTEGRATION; + metadata: { + id: string; + slug: string; + description?: string; + }; +} + +interface DeleteSlackIntegration { + type: EventType.DELETE_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + +interface GetSlackIntegration { + type: EventType.GET_SLACK_INTEGRATION; + metadata: { + id: string; + }; +} + +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 @@ -1586,4 +1650,11 @@ export type Event = | DeleteCertificateTemplate | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig - | GetCertificateTemplateEstConfig; + | GetCertificateTemplateEstConfig + | AttemptCreateSlackIntegration + | AttemptReinstallSlackIntegration + | UpdateSlackIntegration + | DeleteSlackIntegration + | GetSlackIntegration + | UpdateProjectSlackConfig + | GetProjectSlackConfig; 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..1b606f617 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,9 @@ 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 { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; +import { triggerSlackNotification } from "@app/services/slack/slack-fns"; +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"; @@ -89,7 +92,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretVersionDAL: Pick; secretVersionTagDAL: Pick; smtpService: Pick; - userDAL: Pick; + userDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick< TProjectDALFactory, @@ -104,6 +107,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; secretApprovalPolicyDAL: Pick; + projectSlackConfigDAL: Pick; licenseService: Pick; }; @@ -132,7 +136,8 @@ export const secretApprovalRequestServiceFactory = ({ secretV2BridgeDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, - 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" }); @@ -1069,6 +1074,25 @@ export const secretApprovalRequestServiceFactory = ({ return { ...doc, commits: approvalCommits }; }); + const env = await projectEnvDAL.findOne({ id: policy.envId }); + const user = await userDAL.findById(secretApprovalRequest.committerUserId); + await triggerSlackNotification({ + projectId, + projectDAL, + kmsService, + projectSlackConfigDAL, + notification: { + type: SlackTriggerFeature.SECRET_APPROVAL, + payload: { + userEmail: user.email as string, + environment: env.name, + secretPath, + projectId, + requestId: secretApprovalRequest.id + } + } + }); + await sendApprovalEmailsFn({ projectDAL, secretApprovalPolicyDAL, @@ -1331,6 +1355,25 @@ export const secretApprovalRequestServiceFactory = ({ return { ...doc, commits: approvalCommits }; }); + const user = await userDAL.findById(secretApprovalRequest.committerUserId); + const env = await projectEnvDAL.findOne({ id: policy.envId }); + await triggerSlackNotification({ + projectId, + projectDAL, + kmsService, + projectSlackConfigDAL, + notification: { + type: SlackTriggerFeature.SECRET_APPROVAL, + payload: { + userEmail: user.email as string, + environment: env.name, + secretPath, + projectId, + requestId: secretApprovalRequest.id + } + } + }); + await sendApprovalEmailsFn({ projectDAL, secretApprovalPolicyDAL, diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index f21312311..73a246433 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"), + WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string()).optional(), + WORKFLOW_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 d6ab112c3..8f6224c51 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -182,6 +182,9 @@ 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"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; @@ -194,6 +197,8 @@ import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; +import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal"; +import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; import { injectAuditLogInfo } from "../plugins/audit-log"; import { injectIdentity } from "../plugins/auth/inject-identity"; @@ -322,6 +327,10 @@ export const registerRoutes = async ( const externalKmsDAL = externalKmsDALFactory(db); const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const slackIntegrationDAL = slackIntegrationDALFactory(db); + const projectSlackConfigDAL = projectSlackConfigDALFactory(db); + const workflowIntegrationDAL = workflowIntegrationDALFactory(db); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -521,8 +530,10 @@ export const registerRoutes = async ( serverCfgDAL: superAdminDAL, orgService, keyStore, - licenseService + licenseService, + kmsService }); + const orgAdminService = orgAdminServiceFactory({ projectDAL, permissionService, @@ -722,7 +733,9 @@ export const registerRoutes = async ( keyStore, kmsService, projectBotDAL, - certificateTemplateDAL + certificateTemplateDAL, + projectSlackConfigDAL, + slackIntegrationDAL }); const projectEnvService = projectEnvServiceFactory({ @@ -873,7 +886,8 @@ export const registerRoutes = async ( smtpService, projectEnvDAL, userDAL, - licenseService + licenseService, + projectSlackConfigDAL }); const secretService = secretServiceFactory({ @@ -923,7 +937,9 @@ export const registerRoutes = async ( projectEnvDAL, userDAL, smtpService, - accessApprovalPolicyApproverDAL + accessApprovalPolicyApproverDAL, + projectSlackConfigDAL, + kmsService }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1151,6 +1167,18 @@ export const registerRoutes = async ( userDAL }); + const slackService = slackServiceFactory({ + permissionService, + kmsService, + slackIntegrationDAL, + workflowIntegrationDAL + }); + + const workflowIntegrationService = workflowIntegrationServiceFactory({ + permissionService, + workflowIntegrationDAL + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -1232,7 +1260,9 @@ export const registerRoutes = async ( secretSharing: secretSharingService, userEngagement: userEngagementService, externalKms: externalKmsService, - orgAdmin: orgAdminService + orgAdmin: orgAdminService, + slack: slackService, + workflowIntegration: workflowIntegrationService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6e41df946..e476a53f9 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -21,7 +21,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { schema: { response: { 200: z.object({ - config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }).extend({ + config: SuperAdminSchema.omit({ + createdAt: true, + updatedAt: true, + encryptedSlackClientId: true, + encryptedSlackClientSecret: true + }).extend({ isMigrationModeOn: z.boolean(), defaultAuthOrgSlug: z.string().nullable(), isSecretScanningDisabled: z.boolean() @@ -62,7 +67,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { .optional() .refine((methods) => !methods || methods.length > 0, { message: "At least one login method should be enabled." - }) + }), + slackClientId: z.string().optional(), + slackClientSecret: z.string().optional() }), response: { 200: z.object({ @@ -123,6 +130,32 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + 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: "DELETE", url: "/user-management/users/:userId", diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index e5f35565f..d13d0db80 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -29,11 +29,13 @@ 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"; import { registerUserRouter } from "./user-router"; import { registerWebhookRouter } from "./webhook-router"; +import { registerWorkflowIntegrationRouter } from "./workflow-integration-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); @@ -61,6 +63,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); await server.register(registerSecretFolderRouter, { prefix: "/folders" }); + await server.register( + async (workflowIntegrationRouter) => { + await workflowIntegrationRouter.register(registerWorkflowIntegrationRouter); + await workflowIntegrationRouter.register(registerSlackRouter, { prefix: "/slack" }); + }, + { prefix: "/workflow-integrations" } + ); + await server.register( async (projectRouter) => { await projectRouter.register(registerProjectRouter); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index a380eb934..e5e2f636c 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -4,14 +4,17 @@ import { IntegrationsSchema, ProjectMembershipsSchema, ProjectRolesSchema, + ProjectSlackConfigsSchema, 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"; import { AuthMode } from "@app/services/auth/auth-type"; import { ProjectFilterType } from "@app/services/project/project-types"; +import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; import { sanitizedServiceTokenSchema } from "../v2/service-token-router"; @@ -542,4 +545,111 @@ 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 + }); + + 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; + } + }); + + 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: validateSlackChannelsField, + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: validateSlackChannelsField + }), + 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 + }); + + 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/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts new file mode 100644 index 000000000..0601e2d1f --- /dev/null +++ b/backend/src/server/routes/v1/slack-router.ts @@ -0,0 +1,355 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +import { SlackIntegrationsSchema, WorkflowIntegrationsSchema } 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"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const sanitizedSlackIntegrationSchema = WorkflowIntegrationsSchema.pick({ + id: true, + description: true, + slug: true, + integration: true +}).merge( + SlackIntegrationsSchema.pick({ + teamName: true + }) +); + +export const registerSlackRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + server.route({ + method: "GET", + url: "/install", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + slug: z + .string() + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), + description: z.string().optional() + }), + response: { + 200: z.string() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const url = await server.services.slack.getInstallUrl({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION, + metadata: { + slug: req.query.slug, + description: req.query.description + } + } + }); + + return url; + } + }); + + server.route({ + method: "GET", + url: "/reinstall", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + id: 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.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ATTEMPT_REINSTALL_SLACK_INTEGRATION, + metadata: { + id: req.query.id + } + } + }); + + return url; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: sanitizedSlackIntegrationSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackIntegrations = await server.services.slack.getSlackIntegrationsByOrg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return slackIntegrations; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + response: { + 200: sanitizedSlackIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + 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.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: deletedSlackIntegration.orgId, + event: { + type: EventType.DELETE_SLACK_INTEGRATION, + metadata: { + id: deletedSlackIntegration.id + } + } + }); + + return deletedSlackIntegration; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + response: { + 200: sanitizedSlackIntegrationSchema + } + }, + 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.id + }); + + 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: "GET", + url: "/:id/channels", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: 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.id + }); + + return slackChannels; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + body: z.object({ + slug: z + .string() + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional(), + description: z.string().optional() + }), + response: { + 200: sanitizedSlackIntegrationSchema + } + }, + 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.id, + ...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", + 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 { + orgId: string; + }; + + return res.redirect(`${appCfg.SITE_URL}/org/${metadata.orgId}/settings?selectedTab=workflow-integrations`); + } + }); + } + }); +}; diff --git a/backend/src/server/routes/v1/workflow-integration-router.ts b/backend/src/server/routes/v1/workflow-integration-router.ts new file mode 100644 index 000000000..839d7b056 --- /dev/null +++ b/backend/src/server/routes/v1/workflow-integration-router.ts @@ -0,0 +1,42 @@ +import { WorkflowIntegrationsSchema } from "@app/db/schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const sanitizedWorkflowIntegrationSchema = WorkflowIntegrationsSchema.pick({ + id: true, + description: true, + slug: true, + integration: true +}); + +export const registerWorkflowIntegrationRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: sanitizedWorkflowIntegrationSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workflowIntegrations = await server.services.workflowIntegration.getIntegrationsByOrg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return workflowIntegrations; + } + }); +}; 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/project/project-service.ts b/backend/src/services/project/project-service.ts index 3e8f7582e..f51c561d7 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.findByIdWithWorkflowIntegrationDetails(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..276442b1b --- /dev/null +++ b/backend/src/services/slack/project-slack-config-dal.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TProjectSlackConfigDALFactory = ReturnType; + +export const projectSlackConfigDALFactory = (db: TDbClient) => { + const projectSlackConfigOrm = ormify(db, TableName.ProjectSlackConfigs); + + 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-auth-validators.ts b/backend/src/services/slack/slack-auth-validators.ts new file mode 100644 index 000000000..c88ba49ea --- /dev/null +++ b/backend/src/services/slack/slack-auth-validators.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +export const validateSlackChannelsField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }) + .refine((data) => data.split(",").length <= 20, { + message: "You can only select up to 20 slack channels" + }); diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts new file mode 100644 index 000000000..919f9de05 --- /dev/null +++ b/backend/src/services/slack/slack-fns.ts @@ -0,0 +1,177 @@ +import { WebClient } from "@slack/web-api"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +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, TSlackNotification } 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; +}; + +const buildSlackPayload = (notification: TSlackNotification) => { + const appCfg = getConfig(); + + switch (notification.type) { + case SlackTriggerFeature.SECRET_APPROVAL: { + const { payload } = notification; + const messageBody = `A secret approval request has been opened by ${payload.userEmail}. +*Environment*: ${payload.environment} +*Secret path*: ${payload.secretPath || "/"} + +View the complete details <${appCfg.SITE_URL}/project/${payload.projectId}/approval?requestId=${ + payload.requestId + }|here>.`; + + const payloadBlocks = [ + { + type: "header", + text: { + type: "plain_text", + text: "Secret approval request", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: messageBody + } + } + ]; + + return { + payloadMessage: messageBody, + payloadBlocks + }; + } + case SlackTriggerFeature.ACCESS_REQUEST: { + const { payload } = notification; + const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ + payload.isTemporary ? "temporary" : "permanent" + } access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. + +The following permissions are requested: ${payload.permissions.join(", ")} + +View the request and approve or deny it <${payload.approvalUrl}|here>.`; + + const payloadBlocks = [ + { + type: "header", + text: { + type: "plain_text", + text: "New access approval request pending for review", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: messageBody + } + } + ]; + + return { + payloadMessage: messageBody, + payloadBlocks + }; + } + default: { + throw new BadRequestError({ + message: "Slack notification type not supported." + }); + } + } +}; + +export const triggerSlackNotification = async ({ + projectId, + notification, + projectSlackConfigDAL, + projectDAL, + kmsService +}: { + projectId: string; + notification: TSlackNotification; + projectSlackConfigDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}) => { + const { payloadMessage, payloadBlocks } = buildSlackPayload(notification); + const project = await projectDAL.findById(projectId); + const slackIntegration = await projectSlackConfigDAL.getIntegrationDetailsByProject(project.id); + + if (!slackIntegration) { + return; + } + + let targetChannelIds: string[] = []; + if (notification.type === SlackTriggerFeature.ACCESS_REQUEST) { + targetChannelIds = slackIntegration.accessRequestChannels?.split(", ") || []; + if (!targetChannelIds.length || !slackIntegration.isAccessRequestNotificationEnabled) { + return; + } + } else if (notification.type === SlackTriggerFeature.SECRET_APPROVAL) { + targetChannelIds = slackIntegration.secretRequestChannels?.split(", ") || []; + if (!targetChannelIds.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 slackWebClient = new WebClient(botKey); + + 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 + }) + .catch((err) => logger.error(err)); + } +}; 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..d52c09961 --- /dev/null +++ b/backend/src/services/slack/slack-integration-dal.ts @@ -0,0 +1,56 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TSlackIntegrations, TWorkflowIntegrations } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSlackIntegrationDALFactory = ReturnType; + +export const slackIntegrationDALFactory = (db: TDbClient) => { + const slackIntegrationOrm = ormify(db, TableName.SlackIntegrations); + + const findByIdWithWorkflowIntegrationDetails = async (id: string, tx?: Knex) => { + try { + return await (tx || db.replicaNode())(TableName.SlackIntegrations) + .join( + TableName.WorkflowIntegrations, + `${TableName.SlackIntegrations}.id`, + `${TableName.WorkflowIntegrations}.id` + ) + .select(selectAllTableCols(TableName.SlackIntegrations)) + .select(db.ref("orgId").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("description").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("integration").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("slug").withSchema(TableName.WorkflowIntegrations)) + .where(`${TableName.WorkflowIntegrations}.id`, "=", id) + .first(); + } catch (error) { + throw new DatabaseError({ error, name: "Find by ID with Workflow integration details" }); + } + }; + + const findWithWorkflowIntegrationDetails = async ( + filter: Partial & Partial, + tx?: Knex + ) => { + try { + return await (tx || db.replicaNode())(TableName.SlackIntegrations) + .join( + TableName.WorkflowIntegrations, + `${TableName.SlackIntegrations}.id`, + `${TableName.WorkflowIntegrations}.id` + ) + .select(selectAllTableCols(TableName.SlackIntegrations)) + .select(db.ref("orgId").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("description").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("integration").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("slug").withSchema(TableName.WorkflowIntegrations)) + .where(filter); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Workflow integration details" }); + } + }; + + return { ...slackIntegrationOrm, findByIdWithWorkflowIntegrationDetails, findWithWorkflowIntegrationDetails }; +}; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts new file mode 100644 index 000000000..d4a3ebe4d --- /dev/null +++ b/backend/src/services/slack/slack-service.ts @@ -0,0 +1,463 @@ +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 { 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 { getServerCfg } from "../super-admin/super-admin-service"; +import { TWorkflowIntegrationDALFactory } from "../workflow-integration/workflow-integration-dal"; +import { WorkflowIntegration } from "../workflow-integration/workflow-integration-types"; +import { fetchSlackChannels } from "./slack-fns"; +import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; +import { + TCompleteSlackIntegrationDTO, + TDeleteSlackIntegrationDTO, + TGetReinstallUrlDTO, + TGetSlackInstallUrlDTO, + TGetSlackIntegrationByIdDTO, + TGetSlackIntegrationByOrgDTO, + TGetSlackIntegrationChannelsDTO, + TReinstallSlackIntegrationDTO, + TUpdateSlackIntegrationDTO +} from "./slack-types"; + +type TSlackServiceFactoryDep = { + slackIntegrationDAL: Pick< + TSlackIntegrationDALFactory, + | "deleteById" + | "updateById" + | "create" + | "findByIdWithWorkflowIntegrationDetails" + | "findWithWorkflowIntegrationDetails" + >; + permissionService: Pick; + kmsService: Pick; + workflowIntegrationDAL: Pick; +}; + +export type TSlackServiceFactory = ReturnType; + +export const slackServiceFactory = ({ + permissionService, + slackIntegrationDAL, + kmsService, + workflowIntegrationDAL +}: TSlackServiceFactoryDep) => { + const completeSlackIntegration = async ({ + orgId, + slug, + description, + teamId, + teamName, + slackUserId, + slackAppId, + botAccessToken, + slackBotId, + slackBotUserId + }: TCompleteSlackIntegrationDTO) => { + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + orgId, + type: KmsDataKey.Organization + }); + + const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({ + plainText: Buffer.from(botAccessToken, "utf8") + }); + + await workflowIntegrationDAL.transaction(async (tx) => { + const workflowIntegration = await workflowIntegrationDAL.create( + { + description, + orgId, + slug, + integration: WorkflowIntegration.SLACK + }, + tx + ); + + await slackIntegrationDAL.create( + { + // @ts-expect-error id is kept as fixed because it is always equal to the workflow integration ID + id: workflowIntegration.id, + teamId, + teamName, + slackUserId, + slackAppId, + slackBotId, + slackBotUserId, + encryptedBotAccessToken + }, + tx + ); + }); + }; + + const reinstallSlackIntegration = async ({ + id, + teamId, + teamName, + slackUserId, + slackAppId, + botAccessToken, + slackBotId, + slackBotUserId + }: TReinstallSlackIntegrationDTO) => { + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(id); + + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found" + }); + } + + 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(); + const serverCfg = await getServerCfg(); + + let slackClientId = appCfg.WORKFLOW_SLACK_CLIENT_ID as string; + let slackClientSecret = appCfg.WORKFLOW_SLACK_CLIENT_SECRET as string; + + const decrypt = await kmsService.decryptWithRootKey(); + + if (serverCfg.encryptedSlackClientId) { + slackClientId = (await decrypt({ cipherTextBlob: Buffer.from(serverCfg.encryptedSlackClientId) })).toString(); + } + + if (serverCfg.encryptedSlackClientSecret) { + slackClientSecret = ( + await decrypt({ cipherTextBlob: Buffer.from(serverCfg.encryptedSlackClientSecret) }) + ).toString(); + } + + if (!slackClientId || !slackClientSecret) { + throw new BadRequestError({ + message: `Invalid Slack configuration. ${ + appCfg.isCloud + ? "Please contact the Infisical team." + : "Contact your instance admin to setup Slack integration in the Admin settings. Your configuration is missing Slack client ID and secret." + }` + }); + } + + return new InstallProvider({ + clientId: slackClientId, + clientSecret: slackClientSecret, + 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 { + 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, + description: metadata.description, + 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 || "" + }); + }, + // 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; + } + } + }); + }; + + const getInstallUrl = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + slug, + description + }: TGetSlackInstallUrlDTO) => { + const appCfg = getConfig(); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + 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"], + metadata: JSON.stringify({ + slug, + description, + orgId: actorOrgId + }), + redirectUri: `${appCfg.SITE_URL}/api/v1/workflow-integrations/slack/oauth_redirect` + }); + + return url; + }; + + const getReinstallUrl = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TGetReinstallUrlDTO) => { + const appCfg = getConfig(); + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(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"], + 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, + actorOrgId, + actorAuthMethod + }: TGetSlackIntegrationByOrgDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const slackIntegrations = await slackIntegrationDAL.findWithWorkflowIntegrationDetails({ + orgId: actorOrgId + }); + + return slackIntegrations; + }; + + const getSlackIntegrationById = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetSlackIntegrationByIdDTO) => { + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(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; + }; + + const getSlackIntegrationChannels = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetSlackIntegrationChannelsDTO) => { + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(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, + actorOrgId, + actorAuthMethod, + id, + slug, + description + }: TUpdateSlackIntegrationDTO) => { + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(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.Edit, OrgPermissionSubjects.Settings); + + return workflowIntegrationDAL.transaction(async (tx) => { + await workflowIntegrationDAL.updateById( + slackIntegration.id, + { + slug, + description + }, + tx + ); + + const updatedIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails( + slackIntegration.id, + tx + ); + + return updatedIntegration!; + }); + }; + + const deleteSlackIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TDeleteSlackIntegrationDTO) => { + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(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.Delete, OrgPermissionSubjects.Settings); + + await workflowIntegrationDAL.deleteById(id); + + return slackIntegration; + }; + + return { + getInstallUrl, + getReinstallUrl, + getSlackIntegrationsByOrg, + getSlackIntegrationById, + completeSlackIntegration, + getSlackInstaller, + updateSlackIntegration, + deleteSlackIntegration, + getSlackIntegrationChannels + }; +}; diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts new file mode 100644 index 000000000..a1914eee2 --- /dev/null +++ b/backend/src/services/slack/slack-types.ts @@ -0,0 +1,79 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TGetSlackInstallUrlDTO = { + slug: string; + description?: string; +} & Omit; + +export type TGetReinstallUrlDTO = { + id: string; +} & Omit; + +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" +>; + +export type TDeleteSlackIntegrationDTO = { + id: string; +} & Omit; + +export type TCompleteSlackIntegrationDTO = { + orgId: string; + slug: string; + description?: string; + teamId: string; + teamName: string; + slackUserId: string; + slackAppId: string; + botAccessToken: string; + slackBotId: string; + 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" +} + +export type TSlackNotification = + | { + type: SlackTriggerFeature.SECRET_APPROVAL; + payload: { + userEmail: string; + environment: string; + secretPath: string; + requestId: string; + projectId: string; + }; + } + | { + type: SlackTriggerFeature.ACCESS_REQUEST; + payload: { + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + projectName: string; + permissions: string[]; + approvalUrl: string; + }; + }; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index e7798500f..9fe8d7501 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -6,10 +6,11 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } 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 { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; @@ -19,6 +20,7 @@ type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; authService: Pick; + kmsService: Pick; orgService: Pick; keyStore: Pick; licenseService: Pick; @@ -39,6 +41,7 @@ export const superAdminServiceFactory = ({ authService, orgService, keyStore, + kmsService, licenseService }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { @@ -82,7 +85,12 @@ export const superAdminServiceFactory = ({ return newCfg; }; - const updateServerCfg = async (data: TSuperAdminUpdate, userId: string) => { + const updateServerCfg = async ( + data: TSuperAdminUpdate & { slackClientId?: string; slackClientSecret?: string }, + userId: string + ) => { + const updatedData = data; + if (data.enabledLoginMethods) { const superAdminUser = await userDAL.findById(userId); const loginMethodToAuthMethod = { @@ -113,7 +121,27 @@ export const superAdminServiceFactory = ({ }); } } - const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, data); + + const encryptWithRoot = await kmsService.encryptWithRootKey(); + if (data.slackClientId) { + const { cipherTextBlob: encryptedClientId } = await encryptWithRoot({ + plainText: Buffer.from(data.slackClientId) + }); + + updatedData.encryptedSlackClientId = encryptedClientId; + updatedData.slackClientId = undefined; + } + + if (data.slackClientSecret) { + const { cipherTextBlob: encryptedClientSecret } = await encryptWithRoot({ + plainText: Buffer.from(data.slackClientSecret) + }); + + updatedData.encryptedSlackClientSecret = encryptedClientSecret; + updatedData.slackClientSecret = undefined; + } + + const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData); await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); @@ -232,11 +260,38 @@ export const superAdminServiceFactory = ({ return user; }; + const getAdminSlackConfig = async () => { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + + if (!serverCfg) { + throw new NotFoundError({ name: "Admin config", message: "Admin config not found" }); + } + + let clientId = ""; + let clientSecret = ""; + + const decrypt = await kmsService.decryptWithRootKey(); + + if (serverCfg.encryptedSlackClientId) { + clientId = (await decrypt({ cipherTextBlob: serverCfg.encryptedSlackClientId })).toString(); + } + + if (serverCfg.encryptedSlackClientSecret) { + clientSecret = (await decrypt({ cipherTextBlob: serverCfg.encryptedSlackClientSecret })).toString(); + } + + return { + clientId, + clientSecret + }; + }; + return { initServerCfg, updateServerCfg, adminSignUp, getUsers, - deleteUser + deleteUser, + getAdminSlackConfig }; }; diff --git a/backend/src/services/workflow-integration/workflow-integration-dal.ts b/backend/src/services/workflow-integration/workflow-integration-dal.ts new file mode 100644 index 000000000..44fb44167 --- /dev/null +++ b/backend/src/services/workflow-integration/workflow-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 TWorkflowIntegrationDALFactory = ReturnType; + +export const workflowIntegrationDALFactory = (db: TDbClient) => { + const workflowIntegrationOrm = ormify(db, TableName.WorkflowIntegrations); + + return workflowIntegrationOrm; +}; diff --git a/backend/src/services/workflow-integration/workflow-integration-service.ts b/backend/src/services/workflow-integration/workflow-integration-service.ts new file mode 100644 index 000000000..41419061b --- /dev/null +++ b/backend/src/services/workflow-integration/workflow-integration-service.ts @@ -0,0 +1,43 @@ +import { ForbiddenError } from "@casl/ability"; + +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; + +import { TWorkflowIntegrationDALFactory } from "./workflow-integration-dal"; +import { TGetWorkflowIntegrationsByOrg } from "./workflow-integration-types"; + +type TWorkflowIntegrationServiceFactoryDep = { + workflowIntegrationDAL: Pick; + permissionService: Pick; +}; + +export type TWorkflowIntegrationServiceFactory = ReturnType; + +export const workflowIntegrationServiceFactory = ({ + workflowIntegrationDAL, + permissionService +}: TWorkflowIntegrationServiceFactoryDep) => { + const getIntegrationsByOrg = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TGetWorkflowIntegrationsByOrg) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + return workflowIntegrationDAL.find({ + orgId: actorOrgId + }); + }; + return { + getIntegrationsByOrg + }; +}; diff --git a/backend/src/services/workflow-integration/workflow-integration-types.ts b/backend/src/services/workflow-integration/workflow-integration-types.ts new file mode 100644 index 000000000..9ae56b840 --- /dev/null +++ b/backend/src/services/workflow-integration/workflow-integration-types.ts @@ -0,0 +1,7 @@ +import { TOrgPermission } from "@app/lib/types"; + +export enum WorkflowIntegration { + SLACK = "slack" +} + +export type TGetWorkflowIntegrationsByOrg = Omit; diff --git a/docs/documentation/platform/workflow-integrations/slack-integration.mdx b/docs/documentation/platform/workflow-integrations/slack-integration.mdx new file mode 100644 index 000000000..e08ce66c2 --- /dev/null +++ b/docs/documentation/platform/workflow-integrations/slack-integration.mdx @@ -0,0 +1,92 @@ +--- +title: "Slack integration" +description: "Learn how to setup Slack integration" +--- + +This guide will provide step by step instructions on how to configure Slack integration for your Infisical projects. + + + + ## Configure admin settings + Note that this step only has to be done once for the entire instance. + + + + Before anything else, you need to setup the Slack app to be used by + your Infisical instance. Because you're self-hosting, you will need to + create this Slack application as demonstrated in the preceding step. + ![admin-settings-slack-overview](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png) + + + Click the "Create Slack app" button. This will open up a new window with the + custom app creation flow on Slack. + ![admin-slack-create-app](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png) + + Select the Slack workspace you want to integrate with Infisical. + + ![admin-slack-app-workspace-select](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png) + + The configuration values of your custom Slack app will be pre-filled for you. You can view or edit the app manifest by clicking **Edit Configurations**. + ![admin-slack-app-summary](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png) + + Once everything's confirmed, press Create. + + + + Copy the Client ID and Client Secret values from your newly created custom Slack app and add them to Infisical. + ![admin-slack-app-credentials](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png) + ![admin-slack-app-credentials-form](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png) + Complete the admin setup by pressing Save. + + + + +## Create Slack workflow integration + + + + In order to use Slack integration in your projects, you will first have to + configure a Slack workflow integration in your organization. + ![org-slack-overview](/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png) + + + Press "Add" and select "Slack" as the platform. + ![org-slack-initial-add](/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png) + + Give your Slack integration a descriptive alias. You will use this to select the Slack integration for your project. + ![org-slack-add-form](/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png) + + Press **Connect Slack**. This opens up the Slack app installation flow. Select the Slack workspace you want to install the custom Slack app to and press **Allow**. + ![org-slack-authenticate](/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png) + + Your Slack bot will then be added to your selected Slack workspace. This completes the workflow integration creation flow. Your projects in the organization can now use this Slack integration to send real-time updates to your Slack workspace. + ![org-slack-workspace](/images/platform/workflow-integrations/slack-integration/org-slack-integration-workspace.png) + ![org-slack-created](/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png) + + + + + +## Configure project to use Slack workflow integration + + + + ![project-slack-overview](/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png) + + + Your project will send notifications to the connected Slack workspace of the + selected Slack integration when the configured events are triggered. + ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png) + + + ![project-slack-select](/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png) + + To enable notifications in private Slack channels, you need to invite your Slack bot to join those channels. + + You now have a working native integration with Slack! + + + + + + diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png new file mode 100644 index 000000000..0b97be58a Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png new file mode 100644 index 000000000..ddf245eff Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png new file mode 100644 index 000000000..95e9fe4ba Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png new file mode 100644 index 000000000..0c047ab1a Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png new file mode 100644 index 000000000..502dbde0a Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png new file mode 100644 index 000000000..54ad6ca6e Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png new file mode 100644 index 000000000..97b38d6e4 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png new file mode 100644 index 000000000..166e5849f Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png new file mode 100644 index 000000000..f46f61d89 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png new file mode 100644 index 000000000..5bd66d932 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png new file mode 100644 index 000000000..1f7386559 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-workspace.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-workspace.png new file mode 100644 index 000000000..042ad1b77 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-workspace.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png new file mode 100644 index 000000000..c6dd70ad7 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png new file mode 100644 index 000000000..944db9cca Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png new file mode 100644 index 000000000..073d3fd93 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png differ diff --git a/docs/mint.json b/docs/mint.json index 3fb414e0c..8060e5f17 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -178,6 +178,12 @@ "documentation/platform/kms/aws-hsm" ] }, + { + "group": "Workflow Integrations", + "pages": [ + "documentation/platform/workflow-integrations/slack-integration" + ] + }, "documentation/platform/secret-sharing" ] }, diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index fc9fb2c24..658feeaa3 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,2 +1,7 @@ -export { useAdminDeleteUser, useCreateAdminUser, useUpdateServerConfig } from "./mutation"; -export { useAdminGetUsers, useGetServerConfig } from "./queries"; +export { + useAdminDeleteUser, + useCreateAdminUser, + useUpdateAdminSlackConfig, + useUpdateServerConfig +} from "./mutation"; +export { useAdminGetUsers, useGetAdminSlackConfig, useGetServerConfig } from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b927a92b9..9ab51d250 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(); @@ -28,7 +33,11 @@ export const useCreateAdminUser = () => { export const useUpdateServerConfig = () => { const queryClient = useQueryClient(); - return useMutation>({ + return useMutation< + TServerConfig, + {}, + Partial + >({ mutationFn: async (opt) => { const { data } = await apiRequest.patch<{ config: TServerConfig }>( "/api/v1/admin/config", @@ -59,3 +68,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..653b124d7 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,8 @@ 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, + getAdminSlackConfig: () => ["admin-slack-config"] as const }; const fetchServerConfig = async () => { @@ -59,3 +60,15 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { lastPage.length !== 0 ? pages.length * filters.limit : undefined }); }; + +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/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index b9ba07264..cc72cfa86 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -77,7 +77,9 @@ 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_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 6df32aa0b..b110e330b 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -89,5 +89,7 @@ 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", + 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 f9b53d037..85f4c2f73 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -742,6 +742,25 @@ interface GetCertificateTemplateEstConfig { }; } +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 @@ -817,7 +836,9 @@ export type Event = | DeleteCertificateTemplate | UpdateCertificateTemplateEstConfig | CreateCertificateTemplateEstConfig - | GetCertificateTemplateEstConfig; + | GetCertificateTemplateEstConfig + | UpdateProjectSlackConfig + | GetProjectSlackConfig; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index a9d24a5c7..551822f09 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -44,4 +44,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/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts new file mode 100644 index 000000000..e4730bd7d --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -0,0 +1,13 @@ +export { + useDeleteSlackIntegration, + useUpdateProjectSlackConfig, + useUpdateSlackIntegration +} from "./mutation"; +export { + fetchSlackInstallUrl, + fetchSlackReinstallUrl, + useGetSlackIntegrationById, + useGetSlackIntegrationChannels, + useGetSlackIntegrations, + useGetWorkflowIntegrations +} 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..18e3f4656 --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -0,0 +1,60 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { workflowIntegrationKeys } from "./queries"; +import { + TDeleteSlackIntegrationDTO, + TUpdateProjectSlackConfigDTO, + 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.getSlackIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegrations(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.getSlackIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getIntegrations(orgId)); + } + }); +}; + +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/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx new file mode 100644 index 000000000..bcf092c73 --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -0,0 +1,95 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { SlackIntegration, SlackIntegrationChannel, WorkflowIntegration } from "./types"; + +export const workflowIntegrationKeys = { + getIntegrations: (orgId?: string) => [{ orgId }, "workflow-integrations"], + 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 ({ + slug, + description +}: { + slug: string; + description?: string; +}) => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack/install", { + params: { + slug, + description + } + }); + + return data; +}; + +export const fetchSlackReinstallUrl = async ({ id }: { id: string }) => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack/reinstall", { + params: { + id + } + }); + + 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 fetchSlackIntegrationChannels = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/slack/${id}/channels` + ); + + return data; +}; + +export const fetchWorkflowIntegrations = async () => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations"); + + return data; +}; + +export const useGetSlackIntegrations = (orgId?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getSlackIntegrations(orgId), + queryFn: () => fetchSlackIntegrations(), + enabled: Boolean(orgId) + }); + +export const useGetSlackIntegrationById = (id?: string) => + useQuery({ + 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) + }); + +export const useGetWorkflowIntegrations = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getIntegrations(id), + queryFn: () => fetchWorkflowIntegrations(), + 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..e30193776 --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -0,0 +1,52 @@ +export enum WorkflowIntegrationPlatform { + SLACK = "slack" +} + +export type WorkflowIntegration = { + id: string; + slug: string; + description: string; + integration: WorkflowIntegrationPlatform; +}; + +export type SlackIntegration = { + id: string; + slug: string; + description: string; + teamName: string; +}; + +export type SlackIntegrationChannel = { + id: string; + name: string; +}; + +export type TUpdateSlackIntegrationDTO = { + id: string; + orgId: string; + slug?: string; + description?: string; +}; + +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/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index c1652aaba..bc81e9160 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.GET_PROJECT_SLACK_CONFIG: + return ( + +

{`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}`}

+ + ); default: return ; } diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 3894bb28f..090fd63e0 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -1,20 +1,36 @@ -import { Fragment } from "react"; +import { Fragment, useEffect, useState } from "react"; +import { useRouter } from "next/router"; import { Tab } from "@headlessui/react"; 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 = () => { + 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) => ( @@ -41,6 +57,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..e31147599 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx @@ -0,0 +1,93 @@ +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..a9af20247 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx @@ -0,0 +1,26 @@ +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) => { + const modalTitle = + workflowPlatform === WorkflowIntegrationPlatform.SLACK ? "Slack integration" : "Integration"; + + 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..2697a657e --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx @@ -0,0 +1,240 @@ +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"; +import axios from "axios"; +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 { + fetchSlackReinstallUrl, + useDeleteSlackIntegration, + useGetWorkflowIntegrations +} 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 router = useRouter(); + const { data: workflowIntegrations, isLoading: isWorkflowIntegrationsLoading } = + useGetWorkflowIntegrations(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 triggerReinstall = async (platform: WorkflowIntegrationPlatform, id: string) => { + if (platform === WorkflowIntegrationPlatform.SLACK) { + try { + const slackReinstallUrl = await fetchSlackReinstallUrl({ + id + }); + + if (slackReinstallUrl) { + router.push(slackReinstallUrl); + } + } catch (err) { + if (axios.isAxiosError(err)) { + createNotification({ + text: (err.response?.data as { message: string })?.message, + type: "error" + }); + } + } + } + }; + + return ( +
+
+

Workflow Integrations

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

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

+ + + + + + + + + + {isWorkflowIntegrationsLoading && ( + + )} + {!isWorkflowIntegrationsLoading && + workflowIntegrations && + workflowIntegrations.length === 0 && ( + + + + )} + {workflowIntegrations?.map((workflowIntegration) => ( + + + + + + ))} + +
ProviderAlias
+ +
+ +
{workflowIntegration.integration.toUpperCase()}
+
{workflowIntegration.slug} + + +
+ +
+
+ + { + e.stopPropagation(); + + handlePopUpOpen("integrationDetails", { + id: workflowIntegration.id, + platform: workflowIntegration.integration + }); + }} + > + More details + + + {(isAllowed) => ( + { + e.stopPropagation(); + + triggerReinstall( + workflowIntegration.integration, + workflowIntegration.id + ); + }} + > + Reinstall + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("removeIntegration", { + id: workflowIntegration.id, + slug: workflowIntegration.slug, + platform: workflowIntegration.integration + }); + }} + > + 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..281061db4 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx @@ -0,0 +1,144 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { zodResolver } from "@hookform/resolvers/zod"; +import slugify from "@sindresorhus/slugify"; +import axios from "axios"; +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() + .trim() + .min(1) + .refine((v) => slugify(v) === v, { + message: "Alias must be a valid slug" + }), + 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(); + try { + const slackInstallUrl = await fetchSlackInstallUrl({ + slug, + description + }); + if (slackInstallUrl) { + router.push(slackInstallUrl); + } + } catch (err) { + if (axios.isAxiosError(err)) { + createNotification({ + text: (err.response?.data as { message: string })?.message, + type: "error" + }); + } + } finally { + setIsConnectLoading.off(); + } + }; + + 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 && ( + + + + )} +
+ + +
+ + ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 388814108..f24055278 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -8,6 +8,7 @@ import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { EncryptionTab } from "./components/EncryptionTab"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { WebhooksTab } from "./components/WebhooksTab"; +import { WorkflowIntegrationTab } from "./components/WorkflowIntegrationSection"; export const ProjectSettingsPage = () => { const { t } = useTranslation(); @@ -19,6 +20,7 @@ export const ProjectSettingsPage = () => { key: "tab-project-encryption", isHidden: currentWorkspace?.version !== ProjectVersion.V3 }, + { name: "Workflow Integrations", key: "tab-workflow-integrations" }, { name: "Webhooks", key: "tab-project-webhooks" } ]; @@ -56,6 +58,9 @@ export const ProjectSettingsPage = () => { )} + + + diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx new file mode 100644 index 000000000..748e9f92e --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx @@ -0,0 +1,336 @@ +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"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + ContentLoader, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + FormControl, + Input, + Select, + SelectItem, + Switch +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + useGetSlackIntegrationChannels, + useGetSlackIntegrations, + useGetWorkspaceSlackConfig, + useUpdateProjectSlackConfig +} from "@app/hooks/api"; + +const formSchema = z.object({ + slackIntegrationId: z.string(), + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.string().array(), + isAccessRequestNotificationEnabled: z.boolean(), + accessRequestChannels: z.string().array() +}); + +type TSlackConfigForm = z.infer; + +export const WorkflowIntegrationTab = () => { + const { currentWorkspace } = useWorkspace(); + const { data: slackConfig, isLoading: isSlackConfigLoading } = useGetWorkspaceSlackConfig({ + workspaceId: currentWorkspace?.id ?? "" + }); + const { data: slackIntegrations } = useGetSlackIntegrations(currentWorkspace?.orgId); + const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectSlackConfig(); + + const { + control, + watch, + handleSubmit, + setValue, + formState: { isDirty, isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + isAccessRequestNotificationEnabled: false, + accessRequestChannels: [], + isSecretRequestNotificationEnabled: false, + secretRequestChannels: [] + } + }); + + const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); + 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; + } + + await updateProjectSlackConfig({ + workspaceId: currentWorkspace.id, + ...data, + accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), + secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ") + }); + + createNotification({ + type: "success", + text: "Successfully updated slack integration" + }); + }; + + useEffect(() => { + if (slackConfig) { + setValue("slackIntegrationId", slackConfig.slackIntegrationId); + setValue( + "isSecretRequestNotificationEnabled", + slackConfig.isSecretRequestNotificationEnabled + ); + setValue( + "isAccessRequestNotificationEnabled", + slackConfig.isAccessRequestNotificationEnabled + ); + + if (slackChannels) { + setValue( + "secretRequestChannels", + slackConfig.secretRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + setValue( + "accessRequestChannels", + slackConfig.accessRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + } + } + }, [slackConfig, slackChannels]); + + if (isSlackConfigLoading) { + return ; + } + + return !slackIntegrations?.length ? ( + + +
+ Create one now +
+ +
+ ) : ( +
+
+

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

+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotifState && ( + ( + + + + 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} + + ); + })} + + + + )} + /> + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotifState && ( + ( + + + + 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} + + ); + })} + + + + )} + /> + )} + + + )} + +
+ ); +}; 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"; 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..b07dde77e --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx @@ -0,0 +1,157 @@ +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, useUpdateServerConfig } from "@app/hooks/api"; + +const slackFormSchema = z.object({ + clientId: z.string(), + clientSecret: z.string() +}); + +type TSlackForm = z.infer; + +const getCustomSlackAppCreationUrl = () => + `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent( + JSON.stringify({ + 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: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`], + scopes: { + bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"] + } + }, + settings: { + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false + } + }) + )}`; + +export const IntegrationPanel = () => { + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(slackFormSchema) + }); + + const { data: adminSlackConfig } = useGetAdminSlackConfig(); + const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); + + useEffect(() => { + if (adminSlackConfig) { + setValue("clientId", adminSlackConfig.clientId); + setValue("clientSecret", adminSlackConfig.clientSecret); + } + }, [adminSlackConfig]); + + const onSlackFormSubmit = async (data: TSlackForm) => { + await updateAdminServerConfig({ + slackClientId: data.clientId, + slackClientSecret: data.clientSecret + }); + + 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)} + /> + + )} + /> +
+ +
+ ); +};