feat: initial installation flow

This commit is contained in:
Sheen Capadngan
2024-08-31 02:56:02 +08:00
parent b975996158
commit fe096772e0
22 changed files with 600 additions and 3 deletions
+2
View File
@@ -70,6 +70,7 @@ import { TSecretReplicationServiceFactory } from "@app/services/secret-replicati
import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service";
import { TSlackServiceFactory } from "@app/services/slack/slack-service";
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
@@ -177,6 +178,7 @@ declare module "fastify" {
userEngagement: TUserEngagementServiceFactory;
externalKms: TExternalKmsServiceFactory;
orgAdmin: TOrgAdminServiceFactory;
slack: TSlackServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer
+16
View File
@@ -14,6 +14,9 @@ import {
TAccessApprovalRequestsReviewersInsert,
TAccessApprovalRequestsReviewersUpdate,
TAccessApprovalRequestsUpdate,
TAdminSlackConfigs,
TAdminSlackConfigsInsert,
TAdminSlackConfigsUpdate,
TApiKeys,
TApiKeysInsert,
TApiKeysUpdate,
@@ -299,6 +302,9 @@ import {
TServiceTokens,
TServiceTokensInsert,
TServiceTokensUpdate,
TSlackIntegrations,
TSlackIntegrationsInsert,
TSlackIntegrationsUpdate,
TSuperAdmin,
TSuperAdminInsert,
TSuperAdminUpdate,
@@ -776,5 +782,15 @@ declare module "knex/types/tables" {
TKmsKeyVersionsInsert,
TKmsKeyVersionsUpdate
>;
[TableName.SlackIntegrations]: KnexOriginal.CompositeTableType<
TSlackIntegrations,
TSlackIntegrationsInsert,
TSlackIntegrationsUpdate
>;
[TableName.AdminSlackConfig]: KnexOriginal.CompositeTableType<
TAdminSlackConfigs,
TAdminSlackConfigsInsert,
TAdminSlackConfigsUpdate
>;
}
}
@@ -0,0 +1,47 @@
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.SlackIntegrations))) {
await knex.schema.createTable(TableName.SlackIntegrations, (tb) => {
tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
tb.string("projectId").notNullable().unique();
tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
tb.string("teamId").notNullable();
tb.string("teamName").notNullable();
tb.string("slackUserId").notNullable();
tb.string("slackAppId").notNullable();
tb.binary("encryptedBotAccessToken").notNullable();
tb.string("slackBotId").notNullable();
tb.string("slackBotUserId").notNullable();
tb.boolean("isAccessRequestNotificationEnabled").defaultTo(false);
tb.string("accessRequestChannels");
tb.boolean("isSecretRequestNotificationEnabled").defaultTo(false);
tb.string("secretRequestChannels");
tb.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.SlackIntegrations);
}
if (!(await knex.schema.hasTable(TableName.AdminSlackConfig))) {
await knex.schema.createTable(TableName.AdminSlackConfig, (tb) => {
tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
tb.binary("encryptedClientId").notNullable();
tb.binary("encryptedClientSecret").notNullable();
tb.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.AdminSlackConfig);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.SlackIntegrations);
await dropOnUpdateTrigger(knex, TableName.SlackIntegrations);
await knex.schema.dropTableIfExists(TableName.AdminSlackConfig);
await dropOnUpdateTrigger(knex, TableName.AdminSlackConfig);
}
@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const AdminSlackConfigsSchema = z.object({
id: z.string().uuid(),
encryptedClientId: zodBuffer,
encryptedClientSecret: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
});
export type TAdminSlackConfigs = z.infer<typeof AdminSlackConfigsSchema>;
export type TAdminSlackConfigsInsert = Omit<z.input<typeof AdminSlackConfigsSchema>, TImmutableDBKeys>;
export type TAdminSlackConfigsUpdate = Partial<Omit<z.input<typeof AdminSlackConfigsSchema>, TImmutableDBKeys>>;
+2
View File
@@ -2,6 +2,7 @@ export * from "./access-approval-policies";
export * from "./access-approval-policies-approvers";
export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers";
export * from "./admin-slack-configs";
export * from "./api-keys";
export * from "./audit-log-streams";
export * from "./audit-logs";
@@ -101,6 +102,7 @@ export * from "./secret-versions-v2";
export * from "./secrets";
export * from "./secrets-v2";
export * from "./service-tokens";
export * from "./slack-integrations";
export * from "./super-admin";
export * from "./trusted-ips";
export * from "./user-actions";
+3 -1
View File
@@ -114,7 +114,9 @@ export enum TableName {
InternalKms = "internal_kms",
InternalKmsKeyVersion = "internal_kms_key_version",
// @depreciated
KmsKeyVersion = "kms_key_versions"
KmsKeyVersion = "kms_key_versions",
SlackIntegrations = "slack_integrations",
AdminSlackConfig = "admin_slack_configs"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";
@@ -0,0 +1,32 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const SlackIntegrationsSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
teamId: z.string(),
teamName: z.string(),
slackUserId: z.string(),
slackAppId: z.string(),
encryptedBotAccessToken: zodBuffer,
slackBotId: z.string(),
slackBotUserId: z.string(),
isAccessRequestNotificationEnabled: z.boolean().nullable().optional(),
accessRequestChannels: z.string().nullable().optional(),
isSecretRequestNotificationEnabled: z.boolean().nullable().optional(),
secretRequestChannels: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TSlackIntegrations = z.infer<typeof SlackIntegrationsSchema>;
export type TSlackIntegrationsInsert = Omit<z.input<typeof SlackIntegrationsSchema>, TImmutableDBKeys>;
export type TSlackIntegrationsUpdate = Partial<Omit<z.input<typeof SlackIntegrationsSchema>, TImmutableDBKeys>>;
+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"),
SLACK_CLIENT_ID: zpStr(z.string()).optional(),
SLACK_CLIENT_SECRET: zpStr(z.string()).optional()
})
.transform((data) => ({
...data,
+13 -1
View File
@@ -182,6 +182,8 @@ import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/
import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal";
import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service";
import { slackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal";
import { slackServiceFactory } from "@app/services/slack/slack-service";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
@@ -322,6 +324,8 @@ export const registerRoutes = async (
const externalKmsDAL = externalKmsDALFactory(db);
const kmsRootConfigDAL = kmsRootConfigDALFactory(db);
const slackIntegrationDAL = slackIntegrationDALFactory(db);
const permissionService = permissionServiceFactory({
permissionDAL,
orgRoleDAL,
@@ -1150,6 +1154,13 @@ export const registerRoutes = async (
userDAL
});
const slackService = slackServiceFactory({
projectDAL,
permissionService,
kmsService,
slackIntegrationDAL
});
await superAdminService.initServerCfg();
//
// setup the communication with license key server
@@ -1231,7 +1242,8 @@ export const registerRoutes = async (
secretSharing: secretSharingService,
userEngagement: userEngagementService,
externalKms: externalKmsService,
orgAdmin: orgAdminService
orgAdmin: orgAdminService,
slack: slackService
});
const cronJobs: CronJob[] = [];
+2
View File
@@ -29,6 +29,7 @@ import { registerSecretFolderRouter } from "./secret-folder-router";
import { registerSecretImportRouter } from "./secret-import-router";
import { registerSecretSharingRouter } from "./secret-sharing-router";
import { registerSecretTagRouter } from "./secret-tag-router";
import { registerSlackRouter } from "./slack-router";
import { registerSsoRouter } from "./sso-router";
import { registerUserActionRouter } from "./user-action-router";
import { registerUserEngagementRouter } from "./user-engagement-router";
@@ -60,6 +61,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserActionRouter, { prefix: "/user-action" });
await server.register(registerSecretImportRouter, { prefix: "/secret-imports" });
await server.register(registerSecretFolderRouter, { prefix: "/folders" });
await server.register(registerSlackRouter, { prefix: "/slack" });
await server.register(
async (projectRouter) => {
@@ -0,0 +1,65 @@
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSlackRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
server.route({
method: "GET",
url: "/install",
config: {
rateLimit: readLimit
},
schema: {
security: [
{
bearerAuth: []
}
],
querystring: z.object({
projectId: z.string()
}),
response: {
200: z.string()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.slack.getInstallUrl({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.projectId
});
}
});
server.route({
method: "GET",
url: "/oauth_redirect",
config: {
rateLimit: readLimit
},
handler: async (req, res) => {
const installer = await server.services.slack.getSlackInstaller();
return installer.handleCallback(req.raw, res.raw, {
failureAsync: async () => {
return res.redirect(appCfg.SITE_URL as string);
},
successAsync: async (installation) => {
const metadata = JSON.parse(installation.metadata || "") as {
projectId: string;
};
return res.redirect(`${appCfg.SITE_URL}/project/${metadata.projectId}/settings`);
}
});
}
});
};
@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAdminSlackConfigDALFactory = ReturnType<typeof adminSlackConfigDALFactory>;
export const adminSlackConfigDALFactory = (db: TDbClient) => {
const adminSlackConfigOrm = ormify(db, TableName.AdminSlackConfig);
return adminSlackConfigOrm;
};
@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSlackIntegrationDALFactory = ReturnType<typeof slackIntegrationDALFactory>;
export const slackIntegrationDALFactory = (db: TDbClient) => {
const slackIntegrationOrm = ormify(db, TableName.SlackIntegrations);
return slackIntegrationOrm;
};
+152
View File
@@ -0,0 +1,152 @@
import { ForbiddenError } from "@casl/ability";
import { InstallProvider } from "@slack/oauth";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TProjectDALFactory } from "../project/project-dal";
import { TSlackIntegrationDALFactory } from "./slack-integration-dal";
import { TCompleteSlackIntegrationDTO, TGetSlackInstallUrlDTO } from "./slack-types";
type TSlackServiceFactoryDep = {
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "create">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectDAL: Pick<TProjectDALFactory, "findById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
export type TSlackServiceFactory = ReturnType<typeof slackServiceFactory>;
export const slackServiceFactory = ({
projectDAL,
permissionService,
slackIntegrationDAL,
kmsService
}: TSlackServiceFactoryDep) => {
const completeSlackIntegration = async ({
projectId,
teamId,
teamName,
slackUserId,
slackAppId,
botAccessToken,
slackBotId,
slackBotUserId
}: TCompleteSlackIntegrationDTO) => {
const project = await projectDAL.findById(projectId);
if (!project) {
throw new NotFoundError({
message: "Project not found"
});
}
const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: project.orgId
});
const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({
plainText: Buffer.from(botAccessToken, "utf8")
});
await slackIntegrationDAL.create({
projectId,
teamId,
teamName,
slackUserId,
slackAppId,
slackBotId,
slackBotUserId,
encryptedBotAccessToken
});
};
const getSlackInstaller = async () => {
const appCfg = getConfig();
if (!appCfg.SLACK_CLIENT_ID || !appCfg.SLACK_CLIENT_SECRET) {
throw new BadRequestError({
message: "Invalid slack configuration"
});
}
return new InstallProvider({
clientId: appCfg.SLACK_CLIENT_ID,
clientSecret: appCfg.SLACK_CLIENT_SECRET,
stateSecret: appCfg.AUTH_SECRET,
legacyStateVerification: true,
installationStore: {
storeInstallation: async (installation) => {
if (installation.isEnterpriseInstall && installation.enterprise?.id) {
throw new BadRequestError({
message: "Enterprise not yet supported"
});
}
const metadata = JSON.parse(installation.metadata || "") as {
projectId: string;
};
return completeSlackIntegration({
projectId: metadata.projectId,
teamId: installation.team?.id || "",
teamName: installation.team?.name || "",
slackUserId: installation.user.id,
slackAppId: installation.appId || "",
botAccessToken: installation.bot?.token || "",
slackBotId: installation.bot?.id || "",
slackBotUserId: installation.bot?.userId || ""
});
},
fetchInstallation: () => {
return {} as never;
},
deleteInstallation: () => {
return {} as never;
}
}
});
};
const getInstallUrl = async ({ actorId, actor, actorOrgId, actorAuthMethod, projectId }: TGetSlackInstallUrlDTO) => {
const appCfg = getConfig();
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
const project = await projectDAL.findById(projectId);
if (!project) {
throw new NotFoundError({
message: "Project not found"
});
}
const installer = await getSlackInstaller();
const url = await installer.generateInstallUrl({
scopes: ["chat:write"],
metadata: JSON.stringify({
projectId: project.id
}),
redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect`
});
// TODO: add audit log here
return url;
};
return {
getInstallUrl,
completeSlackIntegration,
getSlackInstaller
};
};
+14
View File
@@ -0,0 +1,14 @@
import { TProjectPermission } from "@app/lib/types";
export type TGetSlackInstallUrlDTO = TProjectPermission;
export type TCompleteSlackIntegrationDTO = {
projectId: string;
teamId: string;
teamName: string;
slackUserId: string;
slackAppId: string;
botAccessToken: string;
slackBotId: string;
slackBotUserId: string;
};