feat: admin slack configuration

This commit is contained in:
Sheen Capadngan
2024-09-04 23:06:30 +08:00
parent 71b8c59050
commit 956719f797
13 changed files with 465 additions and 15 deletions

View File

@@ -182,6 +182,7 @@ 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 { adminSlackConfigDALFactory } from "@app/services/slack/admin-slack-config-dal";
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";
@@ -327,6 +328,7 @@ export const registerRoutes = async (
const slackIntegrationDAL = slackIntegrationDALFactory(db);
const projectSlackConfigDAL = projectSlackConfigDALFactory(db);
const adminSlackConfigDAL = adminSlackConfigDALFactory(db);
const permissionService = permissionServiceFactory({
permissionDAL,
@@ -526,8 +528,11 @@ export const registerRoutes = async (
serverCfgDAL: superAdminDAL,
orgService,
keyStore,
licenseService
licenseService,
adminSlackConfigDAL,
kmsService
});
const orgAdminService = orgAdminServiceFactory({
projectDAL,
permissionService,
@@ -1162,7 +1167,8 @@ export const registerRoutes = async (
const slackService = slackServiceFactory({
permissionService,
kmsService,
slackIntegrationDAL
slackIntegrationDAL,
adminSlackConfigDAL
});
await superAdminService.initServerCfg();

View File

@@ -123,6 +123,87 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/integrations/slack/bot-creation-url",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.string()
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async () => {
const url = await server.services.superAdmin.getCustomSlackBotCreationUrl();
return url;
}
});
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: "PUT",
url: "/integrations/slack/config",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
clientId: z.string(),
clientSecret: z.string()
}),
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 (req) => {
const adminSlackConfig = await server.services.superAdmin.updateAdminSlackConfig({
...req.body
});
return adminSlackConfig;
}
});
server.route({
method: "DELETE",
url: "/user-management/users/:userId",

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,

View File

@@ -1,13 +1,57 @@
import { Block, WebClient } from "@slack/web-api";
import { getConfig } from "@app/lib/config/env";
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 { TAdminSlackConfigDALFactory } from "./admin-slack-config-dal";
import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal";
import { SlackTriggerFeature } from "./slack-types";
const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000";
export const getCustomSlackBotManifest = () => {
const appCfg = getConfig();
return {
display_information: {
name: "Infisical",
description: "Get real-time Infisical updates in Slack",
background_color: "#c2d62b",
long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack.
- Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice.
- Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent.
- Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects.
By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.`
},
features: {
app_home: {
home_tab_enabled: false,
messages_tab_enabled: false,
messages_tab_read_only_enabled: true
},
bot_user: {
display_name: "Infisical",
always_online: true
}
},
oauth_config: {
redirect_urls: [`${appCfg.SITE_URL}/api/v1/workflow-integrations/slack/oauth_redirect`],
scopes: {
bot: ["chat:write.public", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"]
}
},
settings: {
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false
}
};
};
export const fetchSlackChannels = async (botKey: string) => {
const slackChannels: {
name: string;
@@ -98,3 +142,29 @@ export const triggerSlackNotification = async ({
.catch((err) => void logger.error(err));
}
};
export const getAdminSlackCredentials = async ({
adminSlackConfigDAL,
kmsService
}: {
adminSlackConfigDAL: Pick<TAdminSlackConfigDALFactory, "findById">;
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey">;
}) => {
const adminSlackConfig = await adminSlackConfigDAL.findById(ADMIN_CONFIG_DB_UUID);
let clientId = "";
let clientSecret = "";
const decrypt = await kmsService.decryptWithRootKey();
if (adminSlackConfig.encryptedClientId) {
clientId = (await decrypt({ cipherTextBlob: adminSlackConfig.encryptedClientId })).toString();
}
if (adminSlackConfig.encryptedClientSecret) {
clientSecret = (await decrypt({ cipherTextBlob: adminSlackConfig.encryptedClientSecret })).toString();
}
return {
clientId,
clientSecret
};
};

View File

@@ -8,7 +8,8 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { fetchSlackChannels } from "./slack-fns";
import { TAdminSlackConfigDALFactory } from "./admin-slack-config-dal";
import { fetchSlackChannels, getAdminSlackCredentials } from "./slack-fns";
import { TSlackIntegrationDALFactory } from "./slack-integration-dal";
import {
TCompleteSlackIntegrationDTO,
@@ -25,7 +26,8 @@ import {
type TSlackServiceFactoryDep = {
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "find" | "findById" | "deleteById" | "updateById" | "create">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey" | "encryptWithRootKey" | "decryptWithRootKey">;
adminSlackConfigDAL: Pick<TAdminSlackConfigDALFactory, "findById">;
};
export type TSlackServiceFactory = ReturnType<typeof slackServiceFactory>;
@@ -33,7 +35,8 @@ export type TSlackServiceFactory = ReturnType<typeof slackServiceFactory>;
export const slackServiceFactory = ({
permissionService,
slackIntegrationDAL,
kmsService
kmsService,
adminSlackConfigDAL
}: TSlackServiceFactoryDep) => {
const completeSlackIntegration = async ({
orgId,
@@ -104,16 +107,31 @@ export const slackServiceFactory = ({
const getSlackInstaller = async () => {
const appCfg = getConfig();
const adminSlackCredentials = await getAdminSlackCredentials({
kmsService,
adminSlackConfigDAL
});
if (!appCfg.SLACK_CLIENT_ID || !appCfg.SLACK_CLIENT_SECRET) {
let slackClientId = "";
let slackClientSecret = "";
if (adminSlackCredentials.clientId && adminSlackCredentials.clientSecret) {
slackClientId = adminSlackCredentials.clientId;
slackClientSecret = adminSlackCredentials.clientSecret;
} else {
slackClientId = appCfg.SLACK_CLIENT_ID as string;
slackClientSecret = appCfg.SLACK_CLIENT_SECRET as string;
}
if (!slackClientId || !slackClientSecret) {
throw new BadRequestError({
message: "Invalid slack configuration"
});
}
return new InstallProvider({
clientId: appCfg.SLACK_CLIENT_ID,
clientSecret: appCfg.SLACK_CLIENT_SECRET,
clientId: slackClientId,
clientSecret: slackClientSecret,
stateSecret: appCfg.AUTH_SECRET,
legacyStateVerification: true,
installationStore: {

View File

@@ -10,15 +10,20 @@ import { BadRequestError } 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 { TAdminSlackConfigDALFactory } from "../slack/admin-slack-config-dal";
import { getAdminSlackCredentials, getCustomSlackBotManifest } from "../slack/slack-fns";
import { TUserDALFactory } from "../user/user-dal";
import { TSuperAdminDALFactory } from "./super-admin-dal";
import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types";
import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO, TUpdateAdminSlackConfigDTO } from "./super-admin-types";
type TSuperAdminServiceFactoryDep = {
serverCfgDAL: TSuperAdminDALFactory;
userDAL: TUserDALFactory;
adminSlackConfigDAL: Pick<TAdminSlackConfigDALFactory, "create" | "updateById" | "transaction" | "findById">;
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey">;
orgService: Pick<TOrgServiceFactory, "createOrganization">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">;
@@ -38,7 +43,9 @@ export const superAdminServiceFactory = ({
userDAL,
authService,
orgService,
adminSlackConfigDAL,
keyStore,
kmsService,
licenseService
}: TSuperAdminServiceFactoryDep) => {
const initServerCfg = async () => {
@@ -232,11 +239,63 @@ export const superAdminServiceFactory = ({
return user;
};
const getCustomSlackBotCreationUrl = async () => {
return `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(
JSON.stringify(getCustomSlackBotManifest())
)}`;
};
const updateAdminSlackConfig = async ({ clientId, clientSecret }: TUpdateAdminSlackConfigDTO) => {
return adminSlackConfigDAL.transaction(async (tx) => {
const adminSlackConfig = await adminSlackConfigDAL.findById(ADMIN_CONFIG_DB_UUID, tx);
const encrypt = await kmsService.encryptWithRootKey();
const { cipherTextBlob: encryptedClientId } = await encrypt({ plainText: Buffer.from(clientId) });
const { cipherTextBlob: encryptedClientSecret } = await encrypt({ plainText: Buffer.from(clientSecret) });
if (adminSlackConfig) {
await adminSlackConfigDAL.updateById(
ADMIN_CONFIG_DB_UUID,
{
encryptedClientId,
encryptedClientSecret
},
tx
);
} else {
await adminSlackConfigDAL.create(
{
// @ts-expect-error id is kept as fixed for idempotence and to avoid race condition
id: ADMIN_CONFIG_DB_UUID,
encryptedClientId,
encryptedClientSecret
},
tx
);
}
return {
clientId,
clientSecret
};
});
};
const getAdminSlackConfig = async () => {
return getAdminSlackCredentials({
kmsService,
adminSlackConfigDAL
});
};
return {
initServerCfg,
updateServerCfg,
adminSignUp,
getUsers,
deleteUser
deleteUser,
getCustomSlackBotCreationUrl,
updateAdminSlackConfig,
getAdminSlackConfig
};
};

View File

@@ -31,3 +31,8 @@ export enum LoginMethod {
LDAP = "ldap",
OIDC = "oidc"
}
export type TUpdateAdminSlackConfigDTO = {
clientId: string;
clientSecret: string;
};

View File

@@ -1,2 +1,12 @@
export { useAdminDeleteUser, useCreateAdminUser, useUpdateServerConfig } from "./mutation";
export { useAdminGetUsers, useGetServerConfig } from "./queries";
export {
useAdminDeleteUser,
useCreateAdminUser,
useUpdateAdminSlackConfig,
useUpdateServerConfig
} from "./mutation";
export {
useAdminGetUsers,
useGetAdminSlackConfig,
useGetCustomSlackAppCreationUrl,
useGetServerConfig
} from "./queries";

View File

@@ -5,7 +5,12 @@ import { apiRequest } from "@app/config/request";
import { organizationKeys } from "../organization/queries";
import { User } from "../users/types";
import { adminQueryKeys, adminStandaloneKeys } from "./queries";
import { TCreateAdminUserDTO, TServerConfig } from "./types";
import {
AdminSlackConfig,
TCreateAdminUserDTO,
TServerConfig,
TUpdateAdminSlackConfigDTO
} from "./types";
export const useCreateAdminUser = () => {
const queryClient = useQueryClient();
@@ -59,3 +64,20 @@ export const useAdminDeleteUser = () => {
}
});
};
export const useUpdateAdminSlackConfig = () => {
const queryClient = useQueryClient();
return useMutation<AdminSlackConfig, {}, TUpdateAdminSlackConfigDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.put<AdminSlackConfig>(
"/api/v1/admin/integrations/slack/config",
dto
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries(adminQueryKeys.getAdminSlackConfig());
}
});
};

View File

@@ -3,7 +3,7 @@ import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-que
import { apiRequest } from "@app/config/request";
import { User } from "../types";
import { AdminGetUsersFilters, TServerConfig } from "./types";
import { AdminGetUsersFilters, AdminSlackConfig, TServerConfig } from "./types";
export const adminStandaloneKeys = {
getUsers: "get-users"
@@ -11,7 +11,9 @@ export const adminStandaloneKeys = {
export const adminQueryKeys = {
serverConfig: () => ["server-config"] as const,
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
getCustomSlackAppCreationUrl: () => ["custom-slack-app-creation-url"] as const,
getAdminSlackConfig: () => ["admin-slack-config"] as const
};
const fetchServerConfig = async () => {
@@ -59,3 +61,27 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
lastPage.length !== 0 ? pages.length * filters.limit : undefined
});
};
export const useGetCustomSlackAppCreationUrl = () =>
useQuery({
queryKey: adminQueryKeys.getCustomSlackAppCreationUrl(),
queryFn: async () => {
const { data } = await apiRequest.get<string>(
"/api/v1/admin/integrations/slack/bot-creation-url"
);
return data;
}
});
export const useGetAdminSlackConfig = () =>
useQuery({
queryKey: adminQueryKeys.getAdminSlackConfig(),
queryFn: async () => {
const { data } = await apiRequest.get<AdminSlackConfig>(
"/api/v1/admin/integrations/slack/config"
);
return data;
}
});

View File

@@ -38,7 +38,17 @@ export type TCreateAdminUserDTO = {
salt: string;
};
export type TUpdateAdminSlackConfigDTO = {
clientId: string;
clientSecret: string;
};
export type AdminGetUsersFilters = {
limit: number;
searchTerm: string;
};
export type AdminSlackConfig = {
clientId: string;
clientSecret: string;
};

View File

@@ -25,6 +25,7 @@ import { useOrganization, useServerConfig, useUser } from "@app/context";
import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api";
import { AuthPanel } from "./AuthPanel";
import { IntegrationPanel } from "./IntegrationPanel";
import { RateLimitPanel } from "./RateLimitPanel";
import { UserPanel } from "./UserPanel";
@@ -32,6 +33,7 @@ enum TabSections {
Settings = "settings",
Auth = "auth",
RateLimit = "rate-limit",
Integrations = "integrations",
Users = "users"
}
@@ -137,6 +139,7 @@ export const AdminDashboardPage = () => {
<Tab value={TabSections.Settings}>General</Tab>
<Tab value={TabSections.Auth}>Authentication</Tab>
<Tab value={TabSections.RateLimit}>Rate Limit</Tab>
<Tab value={TabSections.Integrations}>Integrations</Tab>
<Tab value={TabSections.Users}>Users</Tab>
</div>
</TabList>
@@ -323,6 +326,9 @@ export const AdminDashboardPage = () => {
<TabPanel value={TabSections.RateLimit}>
<RateLimitPanel />
</TabPanel>
<TabPanel value={TabSections.Integrations}>
<IntegrationPanel />
</TabPanel>
<TabPanel value={TabSections.Users}>
<UserPanel />
</TabPanel>

View File

@@ -0,0 +1,118 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import {
useGetAdminSlackConfig,
useGetCustomSlackAppCreationUrl,
useUpdateAdminSlackConfig
} from "@app/hooks/api";
const slackFormSchema = z.object({
clientId: z.string(),
clientSecret: z.string()
});
type TSlackForm = z.infer<typeof slackFormSchema>;
export const IntegrationPanel = () => {
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TSlackForm>({
resolver: zodResolver(slackFormSchema)
});
const { data: customSlackAppCreationUrl } = useGetCustomSlackAppCreationUrl();
const { data: adminSlackConfig } = useGetAdminSlackConfig();
const { mutateAsync: updateAdminSlackConfig } = useUpdateAdminSlackConfig();
useEffect(() => {
if (adminSlackConfig) {
setValue("clientId", adminSlackConfig.clientId);
setValue("clientSecret", adminSlackConfig.clientSecret);
}
}, [adminSlackConfig]);
const onSlackFormSubmit = async (data: TSlackForm) => {
await updateAdminSlackConfig(data);
createNotification({
text: "Updated admin slack configuration",
type: "success"
});
};
return (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onSlackFormSubmit)}
>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Slack Integration</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 1: Create your Infisical Slack App
</div>
<div className="mb-6">
<Button colorSchema="secondary" onClick={() => window.open(customSlackAppCreationUrl)}>
Create Slack App
</Button>
</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 2: Configure your instance-wide settings to enable integration with Slack. Copy the
values from the App Credentials page of your custom Slack App.
</div>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client ID"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
</div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
);
};