Merge pull request #2358 from Infisical/feat/native-slack-integration

feat: native slack integration
This commit is contained in:
Maidul Islam
2024-09-11 09:36:25 -04:00
committed by GitHub
77 changed files with 3665 additions and 37 deletions
+148
View File
@@ -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",
+2
View File
@@ -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",
+4
View File
@@ -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
+25 -1
View File
@@ -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
>;
}
}
@@ -0,0 +1,96 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
}
});
}
+3
View File
@@ -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";
+4 -1
View File
@@ -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";
@@ -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<typeof ProjectSlackConfigsSchema>;
export type TProjectSlackConfigsInsert = Omit<z.input<typeof ProjectSlackConfigsSchema>, TImmutableDBKeys>;
export type TProjectSlackConfigsUpdate = Partial<Omit<z.input<typeof ProjectSlackConfigsSchema>, TImmutableDBKeys>>;
@@ -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<typeof SlackIntegrationsSchema>;
export type TSlackIntegrationsInsert = Omit<z.input<typeof SlackIntegrationsSchema>, TImmutableDBKeys>;
export type TSlackIntegrationsUpdate = Partial<Omit<z.input<typeof SlackIntegrationsSchema>, TImmutableDBKeys>>;
+5 -1
View File
@@ -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<typeof SuperAdminSchema>;
@@ -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<typeof WorkflowIntegrationsSchema>;
export type TWorkflowIntegrationsInsert = Omit<z.input<typeof WorkflowIntegrationsSchema>, TImmutableDBKeys>;
export type TWorkflowIntegrationsUpdate = Partial<Omit<z.input<typeof WorkflowIntegrationsSchema>, TImmutableDBKeys>>;
@@ -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<TPermissionServiceFactory, "getProjectPermission">;
accessApprovalPolicyApproverDAL: Pick<TAccessApprovalPolicyApproverDALFactory, "find">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findProjectBySlug">;
projectDAL: Pick<
TProjectDALFactory,
"checkProjectUpgradeStatus" | "findProjectBySlug" | "findProjectWithOrg" | "findById"
>;
accessApprovalRequestDAL: Pick<
TAccessApprovalRequestDALFactory,
| "create"
@@ -56,6 +63,8 @@ type TSecretApprovalRequestServiceFactoryDep = {
TUserDALFactory,
"findUserByProjectMembershipId" | "findUsersByProjectMembershipIds" | "find" | "findById"
>;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
};
export type TAccessApprovalRequestServiceFactory = ReturnType<typeof accessApprovalRequestServiceFactory>;
@@ -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
});
@@ -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;
@@ -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<TSecretVersionDALFactory, "findLatestVersionMany" | "insertMany">;
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
smtpService: Pick<TSmtpService, "sendMail">;
userDAL: Pick<TUserDALFactory, "find" | "findOne">;
userDAL: Pick<TUserDALFactory, "find" | "findOne" | "findById">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<
TProjectDALFactory,
@@ -104,6 +107,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
secretApprovalPolicyDAL: Pick<TSecretApprovalPolicyDALFactory, "findById">;
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
@@ -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,
+3 -1
View File
@@ -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,
+35 -5
View File
@@ -182,6 +182,9 @@ import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/
import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal";
import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service";
import { projectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal";
import { slackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal";
import { slackServiceFactory } from "@app/services/slack/slack-service";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
@@ -194,6 +197,8 @@ import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
import { webhookDALFactory } from "@app/services/webhook/webhook-dal";
import { webhookServiceFactory } from "@app/services/webhook/webhook-service";
import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal";
import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
import { injectAuditLogInfo } from "../plugins/audit-log";
import { injectIdentity } from "../plugins/auth/inject-identity";
@@ -322,6 +327,10 @@ export const registerRoutes = async (
const externalKmsDAL = externalKmsDALFactory(db);
const kmsRootConfigDAL = kmsRootConfigDALFactory(db);
const slackIntegrationDAL = slackIntegrationDALFactory(db);
const projectSlackConfigDAL = projectSlackConfigDALFactory(db);
const workflowIntegrationDAL = workflowIntegrationDALFactory(db);
const permissionService = permissionServiceFactory({
permissionDAL,
orgRoleDAL,
@@ -521,8 +530,10 @@ export const registerRoutes = async (
serverCfgDAL: superAdminDAL,
orgService,
keyStore,
licenseService
licenseService,
kmsService
});
const orgAdminService = orgAdminServiceFactory({
projectDAL,
permissionService,
@@ -722,7 +733,9 @@ export const registerRoutes = async (
keyStore,
kmsService,
projectBotDAL,
certificateTemplateDAL
certificateTemplateDAL,
projectSlackConfigDAL,
slackIntegrationDAL
});
const projectEnvService = projectEnvServiceFactory({
@@ -873,7 +886,8 @@ export const registerRoutes = async (
smtpService,
projectEnvDAL,
userDAL,
licenseService
licenseService,
projectSlackConfigDAL
});
const secretService = secretServiceFactory({
@@ -923,7 +937,9 @@ export const registerRoutes = async (
projectEnvDAL,
userDAL,
smtpService,
accessApprovalPolicyApproverDAL
accessApprovalPolicyApproverDAL,
projectSlackConfigDAL,
kmsService
});
const secretReplicationService = secretReplicationServiceFactory({
@@ -1151,6 +1167,18 @@ export const registerRoutes = async (
userDAL
});
const slackService = slackServiceFactory({
permissionService,
kmsService,
slackIntegrationDAL,
workflowIntegrationDAL
});
const workflowIntegrationService = workflowIntegrationServiceFactory({
permissionService,
workflowIntegrationDAL
});
await superAdminService.initServerCfg();
//
// setup the communication with license key server
@@ -1232,7 +1260,9 @@ export const registerRoutes = async (
secretSharing: secretSharingService,
userEngagement: userEngagementService,
externalKms: externalKmsService,
orgAdmin: orgAdminService
orgAdmin: orgAdminService,
slack: slackService,
workflowIntegration: workflowIntegrationService
});
const cronJobs: CronJob[] = [];
+35 -2
View File
@@ -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",
+10
View File
@@ -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);
@@ -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;
}
});
};
@@ -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`);
}
});
}
});
};
@@ -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;
}
});
};
+19
View File
@@ -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,
+119 -2
View File
@@ -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<TIdentityProjectMembershipRoleDALFactory, "create">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">;
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "findOne" | "transaction" | "updateById" | "create">;
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "findById" | "findByIdWithWorkflowIntegrationDetails">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject">;
@@ -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
};
};
@@ -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;
@@ -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<typeof projectSlackConfigDALFactory>;
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 };
};
@@ -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"
});
+177
View File
@@ -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<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
projectDAL: Pick<TProjectDALFactory, "findById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
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));
}
};
@@ -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<typeof slackIntegrationDALFactory>;
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<TSlackIntegrations> & Partial<TWorkflowIntegrations>,
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 };
};
+463
View File
@@ -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<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithRootKey" | "decryptWithRootKey">;
workflowIntegrationDAL: Pick<TWorkflowIntegrationDALFactory, "transaction" | "create" | "updateById" | "deleteById">;
};
export type TSlackServiceFactory = ReturnType<typeof slackServiceFactory>;
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
};
};
+79
View File
@@ -0,0 +1,79 @@
import { TOrgPermission } from "@app/lib/types";
export type TGetSlackInstallUrlDTO = {
slug: string;
description?: string;
} & Omit<TOrgPermission, "orgId">;
export type TGetReinstallUrlDTO = {
id: string;
} & Omit<TOrgPermission, "orgId">;
export type TGetSlackIntegrationByOrgDTO = Omit<TOrgPermission, "orgId">;
export type TGetSlackIntegrationByIdDTO = { id: string } & Omit<TOrgPermission, "orgId">;
export type TGetSlackIntegrationChannelsDTO = { id: string } & Omit<TOrgPermission, "orgId">;
export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit<
TOrgPermission,
"orgId"
>;
export type TDeleteSlackIntegrationDTO = {
id: string;
} & Omit<TOrgPermission, "orgId">;
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;
};
};
@@ -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<TAuthLoginFactory, "generateUserTokens">;
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey">;
orgService: Pick<TOrgServiceFactory, "createOrganization">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">;
@@ -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
};
};
@@ -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<typeof workflowIntegrationDALFactory>;
export const workflowIntegrationDALFactory = (db: TDbClient) => {
const workflowIntegrationOrm = ormify(db, TableName.WorkflowIntegrations);
return workflowIntegrationOrm;
};
@@ -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<TWorkflowIntegrationDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
};
export type TWorkflowIntegrationServiceFactory = ReturnType<typeof workflowIntegrationServiceFactory>;
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
};
};
@@ -0,0 +1,7 @@
import { TOrgPermission } from "@app/lib/types";
export enum WorkflowIntegration {
SLACK = "slack"
}
export type TGetWorkflowIntegrationsByOrg = Omit<TOrgPermission, "orgId">;