feat: add webhook secret key functionality to workflows and update related components

This commit is contained in:
Nawaz Dhandala
2026-03-31 12:22:17 +01:00
parent 67b9d245ec
commit 043ddebc6c
13 changed files with 466 additions and 20 deletions

View File

@@ -527,6 +527,35 @@ export default class Workflow extends BaseModel {
})
public triggerArguments?: JSONObject = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadWorkflow,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditWorkflow,
],
})
@TableColumn({
isDefaultValueColumn: false,
required: false,
type: TableColumnType.LongText,
title: "Webhook Secret Key",
description:
"Secret key used to trigger this workflow via webhook. Use this instead of the workflow ID for security.",
})
@Column({
type: ColumnType.LongText,
nullable: true,
})
public webhookSecretKey?: string = undefined;
// This is a BullMQ job key that is used to schedule job for this workflow. This is used internally to remove existing job.
@ColumnAccessControl({
create: [],

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1774559064920 implements MigrationInterface {
public name = "MigrationName1774559064920";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Workflow" ADD "webhookSecretKey" text`,
);
// Set secret key to existing workflow ID so current webhook URLs keep working.
await queryRunner.query(
`UPDATE "Workflow" SET "webhookSecretKey" = "_id"::text WHERE "webhookSecretKey" IS NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Workflow" DROP COLUMN "webhookSecretKey"`,
);
}
}

View File

@@ -274,6 +274,7 @@ import { MigrationName1774524742177 } from "./1774524742177-MigrationName";
import { MigrationName1774524742178 } from "./1774524742178-MigrationName";
import { MigrationName1774524742179 } from "./1774524742179-MigrationName";
import { MigrationName1774559064919 } from "./1774559064919-MigrationName";
import { MigrationName1774559064920 } from "./1774559064920-MigrationName";
export default [
InitialMigration,
@@ -552,4 +553,5 @@ export default [
MigrationName1774524742178,
MigrationName1774524742179,
MigrationName1774559064919,
MigrationName1774559064920,
];

View File

@@ -1,6 +1,7 @@
import { WorkflowHostname } from "../EnvironmentConfig";
import ClusterKeyAuthorization from "../Middleware/ClusterKeyAuthorization";
import { OnUpdate } from "../Types/Database/Hooks";
import CreateBy from "../Types/Database/CreateBy";
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
import DatabaseService from "./DatabaseService";
import EmptyResponseData from "../../Types/API/EmptyResponse";
import Protocol from "../../Types/API/Protocol";
@@ -17,12 +18,28 @@ import {
import API from "../../Utils/API";
import Model from "../../Models/DatabaseModels/Workflow";
import logger from "../Utils/Logger";
import UUID from "../../Utils/UUID";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
// Auto-generate webhook secret key for new workflows.
if (!createBy.data.webhookSecretKey) {
createBy.data.webhookSecretKey = UUID.generate();
}
return {
createBy,
carryForward: null,
};
}
@CaptureSpan()
protected override async onUpdateSuccess(
onUpdate: OnUpdate<Model>,

View File

@@ -13,6 +13,8 @@ import ComponentMetadata, { Port } from "../../../../Types/Workflow/Component";
import ComponentID from "../../../../Types/Workflow/ComponentID";
import WebhookComponents from "../../../../Types/Workflow/Components/Webhook";
import CaptureSpan from "../../../Utils/Telemetry/CaptureSpan";
import WorkflowService from "../../../Services/WorkflowService";
import Workflow from "../../../../Models/DatabaseModels/Workflow";
export default class WebhookTrigger extends TriggerCode {
public constructor() {
@@ -54,7 +56,7 @@ export default class WebhookTrigger extends TriggerCode {
@CaptureSpan()
public override async init(props: InitProps): Promise<void> {
props.router.get(
`/trigger/:workflowId`,
`/trigger/:secretkey`,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.initTrigger(req, res, props);
@@ -65,7 +67,7 @@ export default class WebhookTrigger extends TriggerCode {
);
props.router.post(
`/trigger/:workflowId`,
`/trigger/:secretkey`,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.initTrigger(req, res, props);
@@ -82,12 +84,33 @@ export default class WebhookTrigger extends TriggerCode {
res: ExpressResponse,
props: InitProps,
): Promise<void> {
/// Run Graph.
const secretKey: string = req.params["secretkey"] as string;
// check if this workflow has the trigger enabled.
if (!secretKey) {
throw new BadDataException("Secret key is required to trigger workflow.");
}
// Look up the workflow by webhook secret key.
const workflow: Workflow | null = await WorkflowService.findOneBy({
query: {
webhookSecretKey: secretKey,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!workflow || !workflow._id) {
throw new BadDataException(
"Workflow not found for the provided secret key.",
);
}
const executeWorkflow: ExecuteWorkflowType = {
workflowId: new ObjectID(req.params["workflowId"] as string),
workflowId: new ObjectID(workflow._id),
returnValues: {
"request-headers": req.headers,
"request-params": req.query,

View File

@@ -713,12 +713,25 @@ ${contextBlock}
if (breakdown.affectedResources && breakdown.affectedResources.length > 0) {
const resourceLines: Array<string> = [];
// Sort by metric value descending (worst first)
// Sort by metric value descending (worst first) and filter out zero-value resources
const sortedResources: Array<KubernetesAffectedResource> = [
...breakdown.affectedResources,
].sort((a: KubernetesAffectedResource, b: KubernetesAffectedResource) => {
return b.metricValue - a.metricValue;
});
]
.filter((r: KubernetesAffectedResource) => {
return r.metricValue > 0;
})
.sort(
(
a: KubernetesAffectedResource,
b: KubernetesAffectedResource,
) => {
return b.metricValue - a.metricValue;
},
);
if (sortedResources.length === 0) {
continue;
}
// Show top 10 affected resources
const resourcesToShow: Array<KubernetesAffectedResource> =

View File

@@ -25,6 +25,7 @@ export interface ComponentProps {
component: NodeDataProp;
graphComponents: Array<NodeDataProp>;
workflowId: ObjectID;
webhookSecretKey?: string | undefined;
}
const ComponentSettingsModal: FunctionComponent<ComponentProps> = (
@@ -179,6 +180,7 @@ const ComponentSettingsModal: FunctionComponent<ComponentProps> = (
<DocumentationViewer
documentationLink={component.metadata.documentationLink}
workflowId={props.workflowId}
webhookSecretKey={props.webhookSecretKey}
/>
</div>
)}

View File

@@ -14,6 +14,7 @@ import useAsyncEffect from "use-async-effect";
export interface ComponentProps {
documentationLink: Route;
workflowId: ObjectID;
webhookSecretKey?: string | undefined;
}
const DocumentationViewer: FunctionComponent<ComponentProps> = (
@@ -30,6 +31,10 @@ const DocumentationViewer: FunctionComponent<ComponentProps> = (
): string => {
text = text.replace("{{serverUrl}}", HOME_URL.toString());
text = text.replace("{{workflowId}}", props.workflowId.toString());
text = text.replace(
"{{webhookSecretKey}}",
props.webhookSecretKey || "Loading...",
);
return text;
};

View File

@@ -111,6 +111,7 @@ export interface ComponentProps {
workflowId: ObjectID;
onRunModalUpdate: (isModalShown: boolean) => void;
onRun: (trigger: NodeDataProp) => void;
webhookSecretKey?: string | undefined;
}
const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
@@ -562,6 +563,7 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
return node.data as NodeDataProp;
})}
workflowId={props.workflowId}
webhookSecretKey={props.webhookSecretKey}
component={selectedNodeData}
title={
selectedNodeData && selectedNodeData.metadata.title