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 da5298b64..3b3a5b107 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/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts index cadbbdc36..be654b338 100644 --- a/backend/src/ee/services/group/user-group-membership-dal.ts +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -41,10 +41,9 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { }; // special query - const findUserGroupMembershipsInProject = async (usernames: string[], projectId: string) => { + const findUserGroupMembershipsInProject = async (usernames: string[], projectId: string, tx?: Knex) => { try { - const usernameDocs: string[] = await db - .replicaNode()(TableName.UserGroupMembership) + const usernameDocs: string[] = await (tx || db.replicaNode())(TableName.UserGroupMembership) .join( TableName.GroupProjectMembership, `${TableName.UserGroupMembership}.groupId`, 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/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index 29f716306..e6afd0f99 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -5,6 +5,7 @@ import nacl from "tweetnacl"; import tweetnacl from "tweetnacl-util"; import { TUserEncryptionKeys } from "@app/db/schemas"; +import { UserEncryption } from "@app/services/user/user-types"; import { decryptSymmetric128BitHexKeyUTF8, encryptAsymmetric, encryptSymmetric } from "./encryption"; @@ -36,12 +37,16 @@ export const srpCheckClientProof = async ( // Ghost user related: // This functionality is intended for ghost user logic. This happens on the frontend when a user is being created. // We replicate the same functionality on the backend when creating a ghost user. -export const generateUserSrpKeys = async (email: string, password: string) => { +export const generateUserSrpKeys = async ( + email: string, + password: string, + customKeys?: { publicKey: string; privateKey: string } +) => { const pair = nacl.box.keyPair(); const secretKeyUint8Array = pair.secretKey; const publicKeyUint8Array = pair.publicKey; - const privateKey = tweetnacl.encodeBase64(secretKeyUint8Array); - const publicKey = tweetnacl.encodeBase64(publicKeyUint8Array); + const privateKey = customKeys?.privateKey || tweetnacl.encodeBase64(secretKeyUint8Array); + const publicKey = customKeys?.publicKey || tweetnacl.encodeBase64(publicKeyUint8Array); // eslint-disable-next-line const client = new jsrp.client(); @@ -111,7 +116,7 @@ export const getUserPrivateKey = async ( | "encryptionVersion" > ) => { - if (user.encryptionVersion === 1) { + if (user.encryptionVersion === UserEncryption.V1) { return decryptSymmetric128BitHexKeyUTF8({ ciphertext: user.encryptedPrivateKey, iv: user.iv, @@ -119,7 +124,12 @@ export const getUserPrivateKey = async ( key: password.slice(0, 32).padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0") }); } - if (user.encryptionVersion === 2 && user.protectedKey && user.protectedKeyIV && user.protectedKeyTag) { + if ( + user.encryptionVersion === UserEncryption.V2 && + user.protectedKey && + user.protectedKeyIV && + user.protectedKeyTag + ) { const derivedKey = await argon2.hash(password, { salt: Buffer.from(user.salt), memoryCost: 65536, diff --git a/backend/src/lib/validator/validate-email.ts b/backend/src/lib/validator/validate-email.ts index ec3ef3976..c0c50155b 100644 --- a/backend/src/lib/validator/validate-email.ts +++ b/backend/src/lib/validator/validate-email.ts @@ -1,10 +1,16 @@ import fs from "fs/promises"; import path from "path"; -export const isDisposableEmail = async (email: string) => { - const emailDomain = email.split("@")[1]; +export const isDisposableEmail = async (emails: string | string[]) => { const disposableEmails = await fs.readFile(path.join(__dirname, "disposable_emails.txt"), "utf8"); + if (Array.isArray(emails)) { + return emails.some((email) => { + const emailDomain = email.split("@")[1]; + return disposableEmails.split("\n").includes(emailDomain); + }); + } + const emailDomain = emails.split("@")[1]; if (disposableEmails.split("\n").includes(emailDomain)) return true; return false; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dcac9366f..af0aa3d8a 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, @@ -497,7 +506,8 @@ export const registerRoutes = async ( smtpService, userDAL, groupDAL, - orgBotDAL + orgBotDAL, + projectRoleDAL }); const signupService = authSignupServiceFactory({ tokenService, @@ -522,8 +532,10 @@ export const registerRoutes = async ( serverCfgDAL: superAdminDAL, orgService, keyStore, - licenseService + licenseService, + kmsService }); + const orgAdminService = orgAdminServiceFactory({ projectDAL, permissionService, @@ -723,7 +735,9 @@ export const registerRoutes = async ( keyStore, kmsService, projectBotDAL, - certificateTemplateDAL + certificateTemplateDAL, + projectSlackConfigDAL, + slackIntegrationDAL }); const projectEnvService = projectEnvServiceFactory({ @@ -874,7 +888,8 @@ export const registerRoutes = async ( smtpService, projectEnvDAL, userDAL, - licenseService + licenseService, + projectSlackConfigDAL }); const secretService = secretServiceFactory({ @@ -924,7 +939,9 @@ export const registerRoutes = async ( projectEnvDAL, userDAL, smtpService, - accessApprovalPolicyApproverDAL + accessApprovalPolicyApproverDAL, + projectSlackConfigDAL, + kmsService }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1152,6 +1169,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 @@ -1233,7 +1262,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/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index c2907d3ca..9991f6032 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -18,9 +18,14 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { body: z.object({ inviteeEmails: z.array(z.string().trim().email()), organizationId: z.string().trim(), - projectIds: z.array(z.string().trim()).optional(), - projectRoleSlug: z.nativeEnum(ProjectMembershipRole).optional(), - organizationRoleSlug: z.nativeEnum(OrgMembershipRole) + projects: z + .object({ + id: z.string(), + projectRoleSlug: z.string().array().default([ProjectMembershipRole.Member]) + }) + .array() + .optional(), + organizationRoleSlug: z.string().default(OrgMembershipRole.Member) }), response: { 200: z.object({ @@ -40,12 +45,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const completeInviteLinks = await server.services.org.inviteUserToOrganization({ + const { signupTokens: completeInviteLinks } = await server.services.org.inviteUserToOrganization({ orgId: req.body.organizationId, - userId: req.permission.id, + actor: req.permission.type, + actorId: req.permission.id, inviteeEmails: req.body.inviteeEmails, - projectIds: req.body.projectIds, - projectRoleSlug: req.body.projectRoleSlug, + projects: req.body.projects, organizationRoleSlug: req.body.organizationRoleSlug, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId 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/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index a9592faab..6ab574d64 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { ProjectMembershipsSchema } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECT_USERS } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -36,14 +36,21 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const memberships = await server.services.projectMembership.addUsersToProjectNonE2EE({ - projectId: req.params.projectId, + const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; + const { projectMemberships: memberships } = await server.services.org.inviteUserToOrganization({ actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, actorOrgId: req.permission.orgId, actor: req.permission.type, - emails: req.body.emails, - usernames: req.body.usernames + inviteeEmails: usernamesAndEmails, + orgId: req.permission.orgId, + organizationRoleSlug: OrgMembershipRole.NoAccess, + projects: [ + { + id: req.params.projectId, + projectRoleSlug: [ProjectMembershipRole.Member] + } + ] }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 7cca07110..11c4bc61e 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,15 +1,15 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; -import { OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { OrgMembershipStatus, SecretKeyEncoding, TableName } from "@app/db/schemas"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; -import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { getUserPrivateKey } from "@app/lib/crypto/srp"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; +import { BadRequestError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -17,14 +17,14 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; +import { TokenType } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { addMembersToProject } from "../project-membership/project-membership-fns"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; +import { UserEncryption } from "../user/user-types"; import { TAuthDALFactory } from "./auth-dal"; import { validateProviderAuthToken, validateSignUpAuthorization } from "./auth-fns"; import { TCompleteAccountInviteDTO, TCompleteAccountSignupDTO } from "./auth-signup-type"; @@ -67,8 +67,6 @@ export const authSignupServiceFactory = ({ smtpService, orgService, orgDAL, - projectMembershipDAL, - projectUserMembershipRoleDAL, licenseService }: TAuthSignupDep) => { // first step of signup. create user and send email @@ -177,32 +175,88 @@ export const authSignupServiceFactory = ({ encryptedPrivateKey, iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag, - encryptionVersion: 2 + encryptionVersion: UserEncryption.V2 }); const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(privateKey); const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); - const userEncKey = await userDAL.upsertUserEncryptionKey( - us.id, - { - salt, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); + const systemGeneratedUserEncryptionKey = await userDAL.findUserEncKeyByUserId(us.id, tx); + let userEncKey; + + // below condition is true means this is system generated credentials + // the private key is actually system generated password + // thus we will re-encrypt the system generated private key with the new password + // akhilmhdh: you may find this like why? The reason is simple we are moving away from e2ee and these are pieces of it + // without a dummy key in place some things will break and backward compatiability too. 2025 we will be removing all these things + if ( + systemGeneratedUserEncryptionKey && + !systemGeneratedUserEncryptionKey.hashedPassword && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding + ) { + // get server generated password + const serverGeneratedPassword = infisicalSymmetricDecrypt({ + iv: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV, + tag: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag, + ciphertext: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey, + keyEncoding: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding + }); + const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { + ...systemGeneratedUserEncryptionKey + }); + const encKeys = await generateUserSrpKeys(email, password, { + publicKey: systemGeneratedUserEncryptionKey.publicKey, + privateKey: serverGeneratedPrivateKey + }); + // now reencrypt server generated key with user provided password + userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: UserEncryption.V2, + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + publicKey: encKeys.publicKey, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, + salt: encKeys.salt, + verifier: encKeys.verifier, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } else { + userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: UserEncryption.V2, + salt, + verifier, + publicKey, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } + // If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it if ( (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod as AuthMethod)) && @@ -312,8 +366,7 @@ export const authSignupServiceFactory = ({ encryptedPrivateKey, encryptedPrivateKeyIV, encryptedPrivateKeyTag, - authorization, - tokenMetadata + authorization }: TCompleteAccountInviteDTO) => { const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { @@ -348,65 +401,76 @@ export const authSignupServiceFactory = ({ const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); - const userEncKey = await userDAL.upsertUserEncryptionKey( - us.id, - { - salt, - encryptionVersion: 2, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); - - if (tokenMetadata) { - const metadataObj = jwt.verify(tokenMetadata, appCfg.AUTH_SECRET) as TTokenMetadata; - - if ( - metadataObj?.payload?.userId !== user.id || - metadataObj?.payload?.orgId !== orgMembership.orgId || - metadataObj?.type !== TokenMetadataType.InviteToProjects - ) { - throw new UnauthorizedError({ - message: "Malformed or invalid metadata token" - }); - } - - for await (const projectId of metadataObj.payload.projectIds) { - await addMembersToProject({ - orgDAL, - projectDAL, - projectMembershipDAL, - projectKeyDAL, - userGroupMembershipDAL, - projectBotDAL, - projectUserMembershipRoleDAL, - smtpService - }).addMembersToNonE2EEProject( - { - emails: [user.email!], - usernames: [], - projectId, - projectMembershipRole: metadataObj.payload.projectRoleSlug, - sendEmails: false - }, - { - tx, - throwOnProjectNotFound: false - } - ); - } + const systemGeneratedUserEncryptionKey = await userDAL.findUserEncKeyByUserId(us.id, tx); + let userEncKey; + // this means this is system generated credentials + // now replace the private key + if ( + systemGeneratedUserEncryptionKey && + !systemGeneratedUserEncryptionKey.hashedPassword && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV && + systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding + ) { + // get server generated password + const serverGeneratedPassword = infisicalSymmetricDecrypt({ + iv: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV, + tag: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag, + ciphertext: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey, + keyEncoding: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding + }); + const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { + ...systemGeneratedUserEncryptionKey + }); + const encKeys = await generateUserSrpKeys(email, password, { + publicKey: systemGeneratedUserEncryptionKey.publicKey, + privateKey: serverGeneratedPrivateKey + }); + // now reencrypt server generated key with user provided password + userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: 2, + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + publicKey: encKeys.publicKey, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, + salt: encKeys.salt, + verifier: encKeys.verifier, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } else { + userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: UserEncryption.V2, + salt, + verifier, + publicKey, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); } const updatedMembersips = await orgDAL.updateMembership( 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/org/org-service.ts b/backend/src/services/org/org-service.ts index 6696a5323..9dad7086e 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -9,7 +9,10 @@ import { OrgMembershipStatus, ProjectMembershipRole, ProjectVersion, + SecretKeyEncoding, TableName, + TProjectMemberships, + TProjectUserMembershipRolesInsert, TUsers } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; @@ -18,13 +21,15 @@ import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-grou import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; -import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateSymmetricKey, infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; @@ -32,14 +37,14 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; +import { TokenType } from "../auth-token/auth-token-types"; import { TProjectDALFactory } from "../project/project-dal"; -import { verifyProjectVersions } from "../project/project-fns"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { addMembersToProject } from "../project-membership/project-membership-fns"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; @@ -84,6 +89,7 @@ type TOrgServiceFactoryDep = { "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" >; projectUserAdditionalPrivilegeDAL: Pick; + projectRoleDAL: Pick; userGroupMembershipDAL: Pick; projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; @@ -111,7 +117,8 @@ export const orgServiceFactory = ({ samlConfigDAL, userGroupMembershipDAL, projectBotDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + projectRoleDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -440,17 +447,17 @@ export const orgServiceFactory = ({ */ const inviteUserToOrganization = async ({ orgId, - userId, + actorId, + actor, inviteeEmails, organizationRoleSlug, - projectRoleSlug, - projectIds, + projects: invitedProjects, actorAuthMethod, actorOrgId }: TInviteUserToOrgDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); const org = await orgDAL.findOrgById(orgId); @@ -461,6 +468,13 @@ export const orgServiceFactory = ({ }); } + const isEmailInvalid = await isDisposableEmail(inviteeEmails); + if (isEmailInvalid) { + throw new BadRequestError({ + message: "Provided a disposable email", + name: "Org invite" + }); + } const plan = await licenseService.getPlan(orgId); if (plan?.memberLimit && plan.membersUsed >= plan.memberLimit) { // limit imposed on number of members allowed / number of members used exceeds the number of members allowed @@ -475,205 +489,331 @@ export const orgServiceFactory = ({ message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." }); } - - if (projectIds?.length) { - const projects = await projectDAL.find({ - orgId, - $in: { - id: projectIds - } - }); - - // if its not v3, throw an error - if (!verifyProjectVersions(projects, ProjectVersion.V3)) { + const isCustomOrgRole = !Object.values(OrgMembershipRole).includes(organizationRoleSlug as OrgMembershipRole); + if (isCustomOrgRole) { + if (!plan?.rbac) throw new BadRequestError({ - message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." + message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." }); - } } - const inviteeUsers = await orgDAL.transaction(async (tx) => { - const users: Pick< - TUsers & { orgId: string }, - "id" | "firstName" | "lastName" | "email" | "orgId" | "username" - >[] = []; + const projectsToInvite = invitedProjects?.length + ? await projectDAL.find({ + orgId, + $in: { + id: invitedProjects?.map(({ id }) => id) + } + }) + : []; + if (projectsToInvite.length !== invitedProjects?.length) { + throw new UnauthorizedError({ + message: "One or more project doesn't have access to" + }); + } + + if (projectsToInvite.some((el) => el.version !== ProjectVersion.V3)) { + throw new BadRequestError({ + message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." + }); + } + + const mailsForOrgInvitation: { email: string; userId: string; firstName: string; lastName: string }[] = []; + const mailsForProjectInvitaion: { email: string[]; projectName: string }[] = []; + const newProjectMemberships: TProjectMemberships[] = []; + await orgDAL.transaction(async (tx) => { + const users: Pick[] = []; + for await (const inviteeEmail of inviteeEmails) { - const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); - if (inviteeUser) { - // if user already exist means its already part of infisical - // Thus the signup flow is not needed anymore - const [inviteeMembership] = await orgDAL.findMembership( + // if the user doesn't exist we create the user with the email + if (!inviteeUser) { + inviteeUser = await userDAL.create( { - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, - [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false }, - { tx } + tx ); - if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { - throw new BadRequestError({ - message: `Failed to invite members because ${inviteeEmail} is already part of the organization`, - name: "Invite user to org" - }); - } - - if (!inviteeMembership) { - await orgDAL.createMembership( - { - userId: inviteeUser.id, - inviteEmail: inviteeEmail, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited, - isActive: true - }, - tx - ); - - if (projectIds?.length) { - if ( - organizationRoleSlug === OrgMembershipRole.Custom || - projectRoleSlug === ProjectMembershipRole.Custom - ) { - throw new BadRequestError({ - message: "Custom roles are not supported for inviting users to projects and organizations" - }); - } - - if (!projectRoleSlug) { - throw new BadRequestError({ - message: "Selecting a project role is required to invite users to projects" - }); - } - - await projectMembershipDAL.insertMany( - projectIds.map((id) => ({ projectId: id, userId: inviteeUser.id })), - tx - ); - for await (const projectId of projectIds) { - await addMembersToProject({ - orgDAL, - projectDAL, - projectMembershipDAL, - projectKeyDAL, - userGroupMembershipDAL, - projectBotDAL, - projectUserMembershipRoleDAL, - smtpService - }).addMembersToNonE2EEProject( - { - emails: [inviteeEmail], - usernames: [], - projectId, - projectMembershipRole: projectRoleSlug, - sendEmails: false - }, - { - tx - } - ); - } - } - } - return [{ ...inviteeUser, orgId }]; } - const isEmailInvalid = await isDisposableEmail(inviteeEmail); - if (isEmailInvalid) { - throw new BadRequestError({ - message: "Provided a disposable email", - name: "Org invite" + + const inviteeUserId = inviteeUser?.id; + const existingEncrytionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx); + + // when user is missing the encrytion keys + // this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp + // So what we do is we generate a random secure password and then encrypt it with a random pub-private key + // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password + if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncrytionKey)) { + const serverGeneratedPassword = crypto.randomBytes(32).toString("hex"); + const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(serverGeneratedPassword); + const encKeys = await generateUserSrpKeys(inviteeEmail, serverGeneratedPassword); + await userDAL.createUserEncryption( + { + userId: inviteeUserId, + encryptionVersion: 2, + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + publicKey: encKeys.publicKey, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, + salt: encKeys.salt, + verifier: encKeys.verifier, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext + }, + tx + ); + } + + const [inviteeMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUserId + }, + { tx } + ); + + // if there exist no org membership we set is as given by the request + if (!inviteeMembership) { + let roleId; + const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug; + if (isCustomOrgRole) { + const customRole = await orgRoleDAL.findOne({ slug: organizationRoleSlug, orgId }); + if (!customRole) + throw new BadRequestError({ name: "Invite membership", message: "Organization role not found" }); + roleId = customRole.id; + } + + await orgDAL.createMembership( + { + userId: inviteeUser.id, + inviteEmail: inviteeEmail, + orgId, + role: orgRole, + status: OrgMembershipStatus.Invited, + isActive: true, + roleId + }, + tx + ); + mailsForOrgInvitation.push({ + email: inviteeEmail, + userId: inviteeUser.id, + firstName: inviteeUser?.firstName || "", + lastName: inviteeUser.lastName || "" }); } - // not invited before - const user = await userDAL.create( - { - username: inviteeEmail, - email: inviteeEmail, - isAccepted: false, - authMethods: [AuthMethod.EMAIL], - isGhost: false - }, - tx - ); - await orgDAL.createMembership( - { - inviteEmail: inviteeEmail, - orgId, - userId: user.id, - role: organizationRoleSlug, - status: OrgMembershipStatus.Invited, - isActive: true - }, - tx - ); - users.push({ - ...user, - orgId + users.push(inviteeUser); + } + + const userIds = users.map(({ id }) => id); + const usernames = users.map((el) => el.username); + const userEncryptionKeys = await userDAL.findUserEncKeyByUserIdsBatch({ userIds }, tx); + // we don't need to spam with email. Thus org invitation doesn't need project invitation again + const userIdsWithOrgInvitation = new Set(mailsForOrgInvitation.map((el) => el.userId)); + + // if there exist no project membership we set is as given by the request + for await (const project of projectsToInvite) { + const projectId = project.id; + const { permission: projectPermission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Member + ); + const existingMembers = await projectMembershipDAL.find( + { + projectId: project.id, + $in: { userId: userIds } + }, + { tx } + ); + const existingMembersGroupByUserId = groupBy(existingMembers, (i) => i.userId); + const userIdsToExcludeAsPartOfGroup = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernames, projectId, tx) + ); + const userWithEncryptionKeyInvitedToProject = userEncryptionKeys.filter( + (user) => !existingMembersGroupByUserId?.[user.userId] && !userIdsToExcludeAsPartOfGroup.has(user.userId) + ); + // eslint-disable-next-line no-continue + if (!userWithEncryptionKeyInvitedToProject.length) continue; + + // validate custom project role + const invitedProjectRoles = invitedProjects.find((el) => el.id === project.id)?.projectRoleSlug || [ + ProjectMembershipRole.Member + ]; + + const customProjectRoles = invitedProjectRoles.filter( + (role) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customProjectRoles.length); + if (hasCustomRole) { + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." + }); + } + + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customProjectRoles.map((role) => role) } + }) + : []; + if (customRoles.length !== customProjectRoles.length) + throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const ghostUser = await projectDAL.findProjectGhostUser(projectId, tx); + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId, tx); + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }, tx); + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const newWsMembers = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: botPrivateKey, + members: userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ + orgMembershipId: userEnc.userId, + projectMembershipRole: ProjectMembershipRole.Admin, + userPublicKey: userEnc.publicKey + })) + }); + + const projectMemberships = await projectMembershipDAL.insertMany( + userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ + projectId, + userId: userEnc.userId + })), + tx + ); + newProjectMemberships.push(...projectMemberships); + + const sanitizedProjectMembershipRoles: TProjectUserMembershipRolesInsert[] = []; + invitedProjectRoles.forEach((projectRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[projectRole]?.[0]); + projectMemberships.forEach((membership) => { + sanitizedProjectMembershipRoles.push({ + projectMembershipId: membership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : projectRole, + customRoleId: customRolesGroupBySlug[projectRole] ? customRolesGroupBySlug[projectRole][0].id : null + }); + }); + }); + await projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + + await projectKeyDAL.insertMany( + newWsMembers.map((el) => ({ + encryptedKey: el.workspaceEncryptedKey, + nonce: el.workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: el.orgMembershipId, + projectId + })), + tx + ); + mailsForProjectInvitaion.push({ + email: userWithEncryptionKeyInvitedToProject + .filter((el) => !userIdsWithOrgInvitation.has(el.userId)) + .map((el) => el.email || el.username), + projectName: project.name }); } return users; }); - const user = await userDAL.findById(userId); - + await licenseService.updateSubscriptionOrgMemberCount(orgId); const signupTokens: { email: string; link: string }[] = []; - if (inviteeUsers) { - for await (const invitee of inviteeUsers) { + // send org invite mail + await Promise.allSettled( + mailsForOrgInvitation.map(async (el) => { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: invitee.id, + userId: el.userId, orgId }); - let inviteMetadata: string = ""; - if (projectIds && projectIds?.length > 0) { - inviteMetadata = jwt.sign( - { - type: TokenMetadataType.InviteToProjects, - payload: { - projectIds, - projectRoleSlug: projectRoleSlug!, // Implicitly checked inside transaction if projectRoleSlug is undefined - userId: invitee.id, - orgId - } - } satisfies TTokenMetadata, - appCfg.AUTH_SECRET, - { - expiresIn: appCfg.JWT_INVITE_LIFETIME - } - ); - } - signupTokens.push({ - email: invitee.email || invitee.username, - link: `${appCfg.SITE_URL}/signupinvite?token=${token}${ - inviteMetadata ? `&metadata=${inviteMetadata}` : "" - }&to=${invitee.email || invitee.username}&organization_id=${org?.id}` + email: el.email, + link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${org?.id}` }); - await smtpService.sendMail({ + return smtpService.sendMail({ template: SmtpTemplates.OrgInvite, subjectLine: "Infisical organization invitation", - recipients: [invitee.email || invitee.username], + recipients: [el.email], substitutions: { - metadata: inviteMetadata, - inviterFirstName: user.firstName, - inviterUsername: user.username, + inviterFirstName: el.firstName, + inviterUsername: el.email, organizationName: org?.name, - email: invitee.email || invitee.username, + email: el.email, organizationId: org?.id.toString(), token, callback_url: `${appCfg.SITE_URL}/signupinvite` } }); - } - } - await licenseService.updateSubscriptionOrgMemberCount(orgId); + }) + ); + + await Promise.allSettled( + mailsForProjectInvitaion + .filter((el) => Boolean(el.email.length)) + .map(async (el) => { + return smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: el.email, + substitutions: { + workspaceName: el.projectName, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + }) + ); if (!appCfg.isSmtpConfigured) { - return signupTokens; + return { signupTokens, projectMemberships: newProjectMemberships }; } + + return { signupTokens: undefined, projectMemberships: newProjectMemberships }; }; /** diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 3fa4ae493..ef0a5606a 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,4 +1,3 @@ -import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -26,14 +25,17 @@ export type TDeleteOrgMembershipDTO = { }; export type TInviteUserToOrgDTO = { - userId: string; + actorId: string; + actor: ActorType; orgId: string; actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; inviteeEmails: string[]; - organizationRoleSlug: OrgMembershipRole; - projectIds?: string[]; - projectRoleSlug?: ProjectMembershipRole; + organizationRoleSlug: string; + projects?: { + id: string; + projectRoleSlug?: string[]; + }[]; }; export type TVerifyUserToOrgDTO = { diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 077838918..49a86f31b 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -22,11 +22,9 @@ import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; -import { addMembersToProject } from "./project-membership-fns"; import { ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, - TAddUsersToWorkspaceNonE2EEDTO, TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipsDTO, TGetProjectMembershipByUsernameDTO, @@ -61,7 +59,6 @@ export const projectMembershipServiceFactory = ({ projectUserMembershipRoleDAL, smtpService, projectRoleDAL, - projectBotDAL, orgDAL, projectUserAdditionalPrivilegeDAL, userDAL, @@ -214,52 +211,6 @@ export const projectMembershipServiceFactory = ({ return orgMembers; }; - const addUsersToProjectNonE2EE = async ({ - projectId, - actorId, - actorAuthMethod, - actor, - actorOrgId, - emails, - usernames, - sendEmails = true - }: TAddUsersToWorkspaceNonE2EEDTO) => { - const project = await projectDAL.findById(projectId); - if (!project) throw new BadRequestError({ message: "Project not found" }); - - if (project.version === ProjectVersion.V1) { - throw new BadRequestError({ message: "Please upgrade your project on your dashboard" }); - } - - const { permission } = await permissionService.getProjectPermission( - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId - ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - - const members = await addMembersToProject({ - orgDAL, - projectDAL, - projectMembershipDAL, - projectKeyDAL, - userGroupMembershipDAL, - projectBotDAL, - projectUserMembershipRoleDAL, - smtpService - }).addMembersToNonE2EEProject({ - emails, - usernames, - projectId, - projectMembershipRole: ProjectMembershipRole.Member, - sendEmails - }); - - return members; - }; - const updateProjectMembership = async ({ actorId, actor, @@ -530,7 +481,6 @@ export const projectMembershipServiceFactory = ({ getProjectMemberships, getProjectMembershipByUsername, updateProjectMembership, - addUsersToProjectNonE2EE, deleteProjectMemberships, deleteProjectMembership, // TODO: Remove this addUsersToProject, 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/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 9ec495ca3..99f403e84 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -82,10 +82,9 @@ export const userDALFactory = (db: TDbClient) => { } }; - const findUserEncKeyByUserId = async (userId: string) => { + const findUserEncKeyByUserId = async (userId: string, tx?: Knex) => { try { - const user = await db - .replicaNode()(TableName.Users) + const user = await (tx || db.replicaNode())(TableName.Users) .where(`${TableName.Users}.id`, userId) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) .first(); diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts index 639320e24..23aff77a4 100644 --- a/backend/src/services/user/user-fns.ts +++ b/backend/src/services/user/user-fns.ts @@ -4,18 +4,14 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TUserDALFactory } from "@app/services/user/user-dal"; export const normalizeUsername = async (username: string, userDAL: Pick) => { - let attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); + let attempt: string; + let user; - let user = await userDAL.findOne({ username: attempt }); - if (!user) return attempt; - - while (true) { + do { attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); // eslint-disable-next-line no-await-in-loop user = await userDAL.findOne({ username: attempt }); + } while (user); - if (!user) { - return attempt; - } - } + return attempt; }; diff --git a/backend/src/services/user/user-types.ts b/backend/src/services/user/user-types.ts index e91b23910..9b482de98 100644 --- a/backend/src/services/user/user-types.ts +++ b/backend/src/services/user/user-types.ts @@ -3,3 +3,8 @@ import { TOrgPermission } from "@app/lib/types"; export type TListUserGroupsDTO = { username: string; } & Omit; + +export enum UserEncryption { + V1 = 1, + V2 = 2 +} 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/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index fb4d4418f..b1145bcaf 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -49,8 +49,8 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO." Back in the **Set up Single Sign-On with SAML** screen, select **Edit** in the **Attributes & Claims** section and configure the following map: - `email -> user.userprinciplename` - - `firstName -> user.firstName` - - `lastName -> user.lastName` + - `firstName -> user.givenname` + - `lastName -> user.surname` ![Azure SAML edit attributes and claims](../../../images/sso/azure/edit-attributes-claims.png) 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/components/signup/EnterEmailStep.tsx b/frontend/src/components/signup/EnterEmailStep.tsx index 2b5b0d138..1b1a5c8a3 100644 --- a/frontend/src/components/signup/EnterEmailStep.tsx +++ b/frontend/src/components/signup/EnterEmailStep.tsx @@ -50,7 +50,8 @@ export default function EnterEmailStep({ // If everything is correct, go to the next step if (!emailCheckBool) { try { - await mutateAsync({ email }); + await mutateAsync({ email: email.toLowerCase() }); + setEmail(email.toLowerCase()) incrementStep(); } catch (e) { if (axios.isAxiosError(e)) { 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 5d300fdcb..890327e0e 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/users/types.ts b/frontend/src/hooks/api/users/types.ts index 550159fb4..6c538811b 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -150,8 +150,7 @@ export type DeletOrgMembershipDTO = { export type AddUserToOrgDTO = { inviteeEmails: string[]; - projectIds?: string[]; - projectRoleSlug?: string; + projects?: { id: string; projectRoleSlug: string[] }[]; organizationRoleSlug: string; organizationId: string; }; 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/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index 3e9720718..7194432c5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -129,8 +129,7 @@ export const AddOrgMemberModal = ({ organizationId: currentOrg?.id, inviteeEmails: emails.split(",").map((email) => email.trim()), organizationRoleSlug, - projectIds, - projectRoleSlug + projects: projectIds.map((id) => ({ id, projectRoleSlug: [projectRoleSlug] })) }); setCompleteInviteLinks(data?.completeInviteLinks ?? null); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 7361f7921..d7331f075 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -444,6 +444,23 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop

{`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/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index 8a5fe705a..6db92861e 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -101,9 +101,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { members?.forEach((member) => { wsUserUsernames.set(member.user.username, true); }); - return (orgUsers || []).filter( - ({ status, user: u }) => status === "accepted" && !wsUserUsernames.has(u.username) - ); + return (orgUsers || []).filter(({ user: u }) => !wsUserUsernames.has(u.username)); }, [orgUsers, members]); return ( diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx index 5aab83882..bffd10a61 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersTable.tsx @@ -1,10 +1,11 @@ -import { useMemo,useState } from "react"; +import { useMemo, useState } from "react"; import { faClock, faEdit, faMagnifyingGlass, faTrash, - faUsers} from "@fortawesome/free-solid-svg-icons"; + faUsers +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -27,12 +28,14 @@ import { Th, THead, Tooltip, - Tr} from "@app/components/v2"; + Tr +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useUser, - useWorkspace} from "@app/context"; + useWorkspace +} from "@app/context"; import { useGetWorkspaceUsers } from "@app/hooks/api"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { TWorkspaceUser } from "@app/hooks/api/types"; @@ -104,7 +107,7 @@ export const MembersTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Prop {!isMembersLoading && filterdUsers?.map((projectMember, index) => { const { user: u, inviteEmail, id: membershipId, roles } = projectMember; - const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-"; const email = u?.email || inviteEmail; 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)} + /> + + )} + /> +
+ +
+ ); +};