feat: added native slack webhook type

This commit is contained in:
Sheen Capadngan
2024-07-02 19:57:58 +08:00
parent b8f65fc91a
commit ed5a7d72ab
7 changed files with 290 additions and 69 deletions

View File

@ -0,0 +1,49 @@
import { Knex } from "knex";
import { WebhookType } from "@app/services/webhook/webhook-types";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasEncryptedURL = await knex.schema.hasColumn(TableName.Webhook, "encryptedUrl");
const hasUrlIV = await knex.schema.hasColumn(TableName.Webhook, "urlIV");
const hasUrlTag = await knex.schema.hasColumn(TableName.Webhook, "urlTag");
const hasType = await knex.schema.hasColumn(TableName.Webhook, "type");
await knex.schema.alterTable(TableName.Webhook, (tb) => {
if (!hasEncryptedURL) {
tb.text("encryptedUrl");
}
if (!hasUrlIV) {
tb.string("urlIV");
}
if (!hasUrlTag) {
tb.string("urlTag");
}
if (!hasType) {
tb.string("type").defaultTo(WebhookType.GENERAL);
}
});
}
export async function down(knex: Knex): Promise<void> {
const hasEncryptedURL = await knex.schema.hasColumn(TableName.Webhook, "encryptedUrl");
const hasUrlIV = await knex.schema.hasColumn(TableName.Webhook, "urlIV");
const hasUrlTag = await knex.schema.hasColumn(TableName.Webhook, "urlTag");
const hasType = await knex.schema.hasColumn(TableName.Webhook, "type");
await knex.schema.alterTable(TableName.Webhook, (t) => {
if (hasEncryptedURL) {
t.dropColumn("encryptedUrl");
}
if (hasUrlIV) {
t.dropColumn("urlIV");
}
if (hasUrlTag) {
t.dropColumn("urlTag");
}
if (hasType) {
t.dropColumn("type");
}
});
}

View File

@ -21,7 +21,11 @@ export const WebhooksSchema = z.object({
keyEncoding: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
envId: z.string().uuid()
envId: z.string().uuid(),
encryptedUrl: z.string().nullable().optional(),
urlIV: z.string().nullable().optional(),
urlTag: z.string().nullable().optional(),
type: z.string().default("general").nullable().optional()
});
export type TWebhooks = z.infer<typeof WebhooksSchema>;

View File

@ -6,13 +6,17 @@ import { removeTrailingSlash } from "@app/lib/fn";
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 { WebhookType } from "@app/services/webhook/webhook-types";
export const sanitizedWebhookSchema = WebhooksSchema.omit({
encryptedSecretKey: true,
iv: true,
tag: true,
algorithm: true,
keyEncoding: true
keyEncoding: true,
encryptedUrl: true,
urlIV: true,
urlTag: true
}).merge(
z.object({
projectId: z.string(),
@ -33,13 +37,24 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
body: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
webhookUrl: z.string().url().trim(),
webhookSecretKey: z.string().trim().optional(),
secretPath: z.string().trim().default("/").transform(removeTrailingSlash)
}),
body: z
.object({
type: z.nativeEnum(WebhookType).default(WebhookType.GENERAL),
workspaceId: z.string().trim(),
environment: z.string().trim(),
webhookUrl: z.string().url().trim(),
webhookSecretKey: z.string().trim().optional(),
secretPath: z.string().trim().default("/").transform(removeTrailingSlash)
})
.superRefine((data, ctx) => {
if (data.type === WebhookType.SLACK && !data.webhookUrl.includes("hooks.slack.com")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Incoming Webhook URL is invalid.",
path: ["webhookUrl"]
});
}
}),
response: {
200: z.object({
message: z.string(),

View File

@ -12,47 +12,82 @@ import { logger } from "@app/lib/logger";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TWebhookDALFactory } from "./webhook-dal";
import { WebhookType } from "./webhook-types";
const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000;
export const triggerWebhookRequest = async (
{ url, encryptedSecretKey, iv, tag, keyEncoding }: TWebhooks,
data: Record<string, unknown>
) => {
const headers: Record<string, string> = {};
const payload = { ...data, timestamp: Date.now() };
const appCfg = getConfig();
if (encryptedSecretKey) {
const encryptionKey = appCfg.ENCRYPTION_KEY;
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
let secretKey;
if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) {
// case: encoding scheme is base64
secretKey = decryptSymmetric({
export const decryptWebhookDetails = (webhook: TWebhooks) => {
const appCfg = getConfig();
const { keyEncoding, iv, encryptedSecretKey, tag, encryptedUrl, urlIV, urlTag, url } = webhook;
const encryptionKey = appCfg.ENCRYPTION_KEY;
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
let decryptedSecretKey = "";
let decryptedUrl = url;
if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) {
// case: encoding scheme is base64
if (encryptedSecretKey) {
decryptedSecretKey = decryptSymmetric({
ciphertext: encryptedSecretKey,
iv: iv as string,
tag: tag as string,
key: rootEncryptionKey
});
} else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) {
// case: encoding scheme is utf8
secretKey = decryptSymmetric128BitHexKeyUTF8({
}
if (encryptedUrl) {
decryptedUrl = decryptSymmetric({
ciphertext: encryptedUrl,
iv: urlIV as string,
tag: urlTag as string,
key: rootEncryptionKey
});
}
} else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) {
// case: encoding scheme is utf8
if (encryptedSecretKey) {
decryptedSecretKey = decryptSymmetric128BitHexKeyUTF8({
ciphertext: encryptedSecretKey,
iv: iv as string,
tag: tag as string,
key: encryptionKey
});
}
if (secretKey) {
const webhookSign = crypto.createHmac("sha256", secretKey).update(JSON.stringify(payload)).digest("hex");
headers["x-infisical-signature"] = `t=${payload.timestamp};${webhookSign}`;
if (encryptedUrl) {
decryptedUrl = decryptSymmetric128BitHexKeyUTF8({
ciphertext: encryptedUrl,
iv: urlIV as string,
tag: urlTag as string,
key: encryptionKey
});
}
}
return {
secretKey: decryptedSecretKey,
url: decryptedUrl
};
};
export const triggerWebhookRequest = async (webhook: TWebhooks, data: Record<string, unknown>) => {
const headers: Record<string, string> = {};
const payload = { ...data, timestamp: Date.now() };
const { secretKey, url } = decryptWebhookDetails(webhook);
if (secretKey) {
const webhookSign = crypto.createHmac("sha256", secretKey).update(JSON.stringify(payload)).digest("hex");
headers["x-infisical-signature"] = `t=${payload.timestamp};${webhookSign}`;
}
const req = await request.post(url, payload, {
headers,
timeout: WEBHOOK_TRIGGER_TIMEOUT,
signal: AbortSignal.timeout(WEBHOOK_TRIGGER_TIMEOUT)
});
return req;
};
@ -60,15 +95,48 @@ export const getWebhookPayload = (
eventName: string,
workspaceId: string,
environment: string,
secretPath?: string
) => ({
event: eventName,
project: {
workspaceId,
environment,
secretPath
secretPath?: string,
type?: string | null
) => {
switch (type) {
case WebhookType.SLACK:
return {
text: "A secret value has been updated",
attachments: [
{
color: "#E7F256",
fields: [
{
title: "Workspace ID",
value: workspaceId,
short: true
},
{
title: "Environment",
value: environment,
short: true
},
{
title: "Secret Path",
value: secretPath,
short: true
}
]
}
]
};
case WebhookType.GENERAL:
default:
return {
event: eventName,
project: {
workspaceId,
environment,
secretPath
}
};
}
});
};
export type TFnTriggerWebhookDTO = {
projectId: string;
@ -95,9 +163,10 @@ export const fnTriggerWebhook = async ({
logger.info("Secret webhook job started", { environment, secretPath, projectId });
const webhooksTriggered = await Promise.allSettled(
toBeTriggeredHooks.map((hook) =>
triggerWebhookRequest(hook, getWebhookPayload("secrets.modified", projectId, environment, secretPath))
triggerWebhookRequest(hook, getWebhookPayload("secrets.modified", projectId, environment, secretPath, hook.type))
)
);
// filter hooks by status
const successWebhooks = webhooksTriggered
.filter(({ status }) => status === "fulfilled")

View File

@ -9,7 +9,7 @@ import { BadRequestError } from "@app/lib/errors";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TWebhookDALFactory } from "./webhook-dal";
import { getWebhookPayload, triggerWebhookRequest } from "./webhook-fns";
import { decryptWebhookDetails, getWebhookPayload, triggerWebhookRequest } from "./webhook-fns";
import {
TCreateWebhookDTO,
TDeleteWebhookDTO,
@ -36,8 +36,13 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
webhookUrl,
environment,
secretPath,
webhookSecretKey
webhookSecretKey,
type
}: TCreateWebhookDTO) => {
const appCfg = getConfig();
const encryptionKey = appCfg.ENCRYPTION_KEY;
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
@ -50,15 +55,14 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
if (!env) throw new BadRequestError({ message: "Env not found" });
const insertDoc: TWebhooksInsert = {
url: webhookUrl,
url: "", // deprecated - we are moving away from plaintext URLs
envId: env.id,
isDisabled: false,
secretPath: secretPath || "/"
secretPath: secretPath || "/",
type
};
if (webhookSecretKey) {
const appCfg = getConfig();
const encryptionKey = appCfg.ENCRYPTION_KEY;
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
if (rootEncryptionKey) {
const { ciphertext, iv, tag } = encryptSymmetric(webhookSecretKey, rootEncryptionKey);
insertDoc.encryptedSecretKey = ciphertext;
@ -76,6 +80,22 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
}
}
if (rootEncryptionKey) {
const { ciphertext, iv, tag } = encryptSymmetric(webhookUrl, rootEncryptionKey);
insertDoc.encryptedUrl = ciphertext;
insertDoc.urlIV = iv;
insertDoc.urlTag = tag;
insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM;
insertDoc.keyEncoding = SecretKeyEncoding.BASE64;
} else if (encryptionKey) {
const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8(webhookUrl, encryptionKey);
insertDoc.encryptedUrl = ciphertext;
insertDoc.urlIV = iv;
insertDoc.urlTag = tag;
insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM;
insertDoc.keyEncoding = SecretKeyEncoding.UTF8;
}
const webhook = await webhookDAL.create(insertDoc);
return { ...webhook, projectId, environment: env };
};
@ -131,7 +151,7 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
try {
await triggerWebhookRequest(
webhook,
getWebhookPayload("test", webhook.projectId, webhook.environment.slug, webhook.secretPath)
getWebhookPayload("test", webhook.projectId, webhook.environment.slug, webhook.secretPath, webhook.type)
);
} catch (err) {
webhookError = (err as Error).message;
@ -162,7 +182,14 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks);
return webhookDAL.findAllWebhooks(projectId, environment, secretPath);
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment, secretPath);
return webhooks.map((w) => {
const { url } = decryptWebhookDetails(w);
return {
...w,
url
};
});
};
return {

View File

@ -5,6 +5,7 @@ export type TCreateWebhookDTO = {
secretPath?: string;
webhookUrl: string;
webhookSecretKey?: string;
type: string;
} & TProjectPermission;
export type TUpdateWebhookDTO = {
@ -24,3 +25,8 @@ export type TListWebhookDTO = {
environment?: string;
secretPath?: string;
} & TProjectPermission;
export enum WebhookType {
GENERAL = "general",
SLACK = "slack"
}

View File

@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
import {
@ -15,14 +15,30 @@ import {
SelectItem
} from "@app/components/v2";
const formSchema = yup.object({
environment: yup.string().required().trim().label("Environment"),
webhookUrl: yup.string().url().required().trim().label("Webhook URL"),
webhookSecretKey: yup.string().trim().label("Secret Key"),
secretPath: yup.string().required().trim().label("Secret Path")
});
enum WebhookType {
GENERAL = "general",
SLACK = "slack"
}
export type TFormSchema = yup.InferType<typeof formSchema>;
const formSchema = z
.object({
environment: z.string().trim().describe("Environment"),
webhookUrl: z.string().url().trim().describe("Webhook URL"),
webhookSecretKey: z.string().trim().optional().describe("Secret Key"),
secretPath: z.string().trim().describe("Secret Path"),
type: z.nativeEnum(WebhookType).describe("Type").default(WebhookType.GENERAL)
})
.superRefine((data, ctx) => {
if (data.type === WebhookType.SLACK && !data.webhookUrl.includes("hooks.slack.com")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Incoming Webhook URL is invalid.",
path: ["webhookUrl"]
});
}
});
type TFormSchema = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
@ -42,11 +58,17 @@ export const AddWebhookForm = ({
handleSubmit,
register,
reset,
watch,
formState: { errors, isSubmitting }
} = useForm<TFormSchema>({
resolver: yupResolver(formSchema)
resolver: zodResolver(formSchema),
defaultValues: {
type: WebhookType.GENERAL
}
});
const webhookType = watch("type");
useEffect(() => {
if (!isOpen) {
reset();
@ -58,6 +80,33 @@ export const AddWebhookForm = ({
<ModalContent title="Create a new webhook">
<form onSubmit={handleSubmit(onCreateWebhook)}>
<div>
<Controller
control={control}
name="type"
defaultValue={WebhookType.GENERAL}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Type"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
<SelectItem value={WebhookType.GENERAL} key={WebhookType.GENERAL}>
General
</SelectItem>
<SelectItem value={WebhookType.SLACK} key={WebhookType.SLACK}>
Slack
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
@ -97,19 +146,21 @@ export const AddWebhookForm = ({
{...register("secretPath")}
/>
</FormControl>
{webhookType === WebhookType.GENERAL && (
<FormControl
label="Secret Key"
isError={Boolean(errors?.webhookSecretKey)}
errorText={errors?.webhookSecretKey?.message}
helperText="To generate webhook signature for verification"
>
<Input
placeholder="Provided during webhook setup"
{...register("webhookSecretKey")}
/>
</FormControl>
)}
<FormControl
label="Secret Key"
isError={Boolean(errors?.webhookSecretKey)}
errorText={errors?.webhookSecretKey?.message}
helperText="To generate webhook signature for verification"
>
<Input
placeholder="Provided during webhook setup"
{...register("webhookSecretKey")}
/>
</FormControl>
<FormControl
label="Webhook URL"
label={webhookType === WebhookType.SLACK ? "Incoming Webhook URL" : "Webhook URL"}
isRequired
isError={Boolean(errors?.webhookUrl)}
errorText={errors?.webhookUrl?.message}