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

@@ -1,21 +1,247 @@
import DisabledWarning from "../../../Components/Monitor/DisabledWarning";
import IncidentsTable from "../../../Components/Incident/IncidentsTable";
import AlertsTable from "../../../Components/Alert/AlertsTable";
import MonitorMetricsElement from "../../../Components/Monitor/MonitorMetrics";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import MonitorMetricsElement from "../../../Components/Monitor/MonitorMetrics";
import React, { Fragment, FunctionComponent, ReactElement, useState } from "react";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import Incident from "Common/Models/DatabaseModels/Incident";
import Alert from "Common/Models/DatabaseModels/Alert";
import Query from "Common/Types/BaseDatabase/Query";
import ProjectUtil from "Common/UI/Utils/Project";
import Includes from "Common/Types/BaseDatabase/Includes";
import MonitorStatusTimeline from "Common/Models/DatabaseModels/MonitorStatusTimeline";
import MonitorStatus from "Common/Models/DatabaseModels/MonitorStatus";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import BadDataException from "Common/Types/Exception/BadDataException";
import Statusbubble from "Common/UI/Components/StatusBubble/StatusBubble";
import { Black } from "Common/Types/BrandColors";
import OneUptimeDate from "Common/Types/Date";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
const MonitorDelete: FunctionComponent<
const MonitorMetrics: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
> = (props: PageComponentProps): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [_currentTab, setCurrentTab] = useState<Tab | null>(null);
const incidentQuery: Query<Incident> = {
projectId: ProjectUtil.getCurrentProjectId()!,
monitors: new Includes([modelId]),
};
const alertQuery: Query<Alert> = {
projectId: ProjectUtil.getCurrentProjectId()!,
monitor: modelId,
};
const tabs: Array<Tab> = [
{
name: "Monitor Metrics",
children: <MonitorMetricsElement monitorId={modelId} />,
},
{
name: "Incidents",
children: (
<IncidentsTable
query={incidentQuery}
noItemsMessage="No incidents found for this monitor."
title="Monitor Incidents"
description="Incidents associated with this monitor."
/>
),
},
{
name: "Alerts",
children: (
<AlertsTable
query={alertQuery}
noItemsMessage="No alerts found for this monitor."
title="Monitor Alerts"
description="Alerts associated with this monitor."
createInitialValues={{
monitor: modelId,
}}
/>
),
},
{
name: "Status Timeline",
children: (
<ModelTable<MonitorStatusTimeline>
modelType={MonitorStatusTimeline}
id="table-monitor-status-timeline"
name="Monitor > Status Timeline"
userPreferencesKey="monitor-status-timeline-table"
isDeleteable={true}
showViewIdButton={true}
isCreateable={true}
isViewable={false}
query={{
monitorId: modelId,
projectId: ProjectUtil.getCurrentProjectId()!,
}}
sortBy="startsAt"
sortOrder={SortOrder.Descending}
onBeforeCreate={(
item: MonitorStatusTimeline,
): Promise<MonitorStatusTimeline> => {
if (!props.currentProject || !props.currentProject._id) {
throw new BadDataException("Project ID cannot be null");
}
item.monitorId = modelId;
item.projectId = new ObjectID(props.currentProject._id);
return Promise.resolve(item);
}}
cardProps={{
title: "Status Timeline",
description: "Here is the status timeline for this monitor",
}}
noItemsMessage={
"No status timeline created for this monitor so far."
}
formFields={[
{
field: {
monitorStatus: true,
},
title: "Monitor Status",
fieldType: FormFieldSchemaType.Dropdown,
required: true,
placeholder: "Monitor Status",
dropdownModal: {
type: MonitorStatus,
labelField: "name",
valueField: "_id",
},
},
{
field: {
startsAt: true,
},
title: "Starts At",
fieldType: FormFieldSchemaType.DateTime,
required: true,
placeholder: "Starts At",
getDefaultValue: () => {
return OneUptimeDate.getCurrentDate();
},
},
]}
showRefreshButton={true}
viewPageRoute={Navigation.getCurrentRoute()}
filters={[
{
field: {
monitorStatus: {
name: true,
},
},
title: "Monitor Status",
type: FieldType.Entity,
filterEntityType: MonitorStatus,
filterQuery: {
projectId: ProjectUtil.getCurrentProjectId()!,
},
filterDropdownField: {
label: "name",
value: "_id",
},
},
{
field: {
startsAt: true,
},
title: "Starts At",
type: FieldType.Date,
},
{
field: {
endsAt: true,
},
title: "Ends At",
type: FieldType.Date,
},
]}
columns={[
{
field: {
monitorStatus: {
name: true,
color: true,
},
},
title: "Monitor Status",
type: FieldType.Text,
getElement: (item: MonitorStatusTimeline): ReactElement => {
if (!item["monitorStatus"]) {
throw new BadDataException("Monitor Status not found");
}
return (
<Statusbubble
color={item.monitorStatus.color || Black}
shouldAnimate={false}
text={item.monitorStatus.name || "Unknown"}
/>
);
},
},
{
field: {
startsAt: true,
},
title: "Starts At",
type: FieldType.DateTime,
},
{
field: {
endsAt: true,
},
title: "Ends At",
type: FieldType.DateTime,
noValueMessage: "Currently Active",
},
{
field: {
endsAt: true,
},
title: "Duration",
type: FieldType.Text,
getElement: (item: MonitorStatusTimeline): ReactElement => {
return (
<p>
{OneUptimeDate.differenceBetweenTwoDatesAsFromattedString(
item["startsAt"] as Date,
(item["endsAt"] as Date) || OneUptimeDate.getCurrentDate(),
)}
</p>
);
},
},
]}
/>
),
},
];
return (
<Fragment>
<DisabledWarning monitorId={modelId} />
<MonitorMetricsElement monitorId={modelId} />
<Tabs
tabs={tabs}
onTabChange={(tab: Tab) => {
setCurrentTab(tab);
}}
/>
</Fragment>
);
};
export default MonitorDelete;
export default MonitorMetrics;

View File

@@ -46,6 +46,7 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
const [nodes, setNodes] = useState<Array<Node>>([]);
const [edges, setEdges] = useState<Array<Edge>>([]);
const [error, setError] = useState<string>("");
const [webhookSecretKey, setWebhookSecretKey] = useState<string>("");
const [showRunSuccessConfirmation, setShowRunSuccessConfirmation] =
useState<boolean>(false);
@@ -63,11 +64,16 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
id: modelId,
select: {
graph: true,
webhookSecretKey: true,
},
requestOptions: {},
});
if (workflow) {
if (workflow.webhookSecretKey) {
setWebhookSecretKey(workflow.webhookSecretKey);
}
const allComponents: {
components: Array<ComponentMetadata>;
categories: Array<ComponentCategory>;
@@ -349,6 +355,7 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
) : (
<Workflow
workflowId={modelId}
webhookSecretKey={webhookSecretKey}
showComponentsPickerModal={showComponentPickerModal}
onComponentPickerModalUpdate={(value: boolean) => {
setShowComponentPickerModal(value);

View File

@@ -5,15 +5,111 @@ import Route from "Common/Types/API/Route";
import ObjectID from "Common/Types/ObjectID";
import DuplicateModel from "Common/UI/Components/DuplicateModel/DuplicateModel";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import Workflow from "Common/Models/DatabaseModels/Workflow";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import React, { Fragment, FunctionComponent, ReactElement, useState } from "react";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import UUID from "Common/Utils/UUID";
const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [showResetConfirmation, setShowResetConfirmation] =
useState<boolean>(false);
const [refresher, setRefresher] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const resetSecretKey: () => void = (): void => {
setShowResetConfirmation(false);
ModelAPI.updateById({
modelType: Workflow,
id: modelId,
data: {
webhookSecretKey: UUID.generate(),
},
})
.then(() => {
setRefresher(!refresher);
})
.catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
};
return (
<Fragment>
<CardModelDetail<Workflow>
name="Workflow > Webhook Secret Key"
cardProps={{
title: "Webhook Secret Key",
description:
"This secret key is used to trigger this workflow via webhook. Use this key in the webhook URL instead of the workflow ID for security. You can reset this key if it is compromised.",
buttons: [
{
title: "Reset Secret Key",
buttonStyle: ButtonStyleType.DANGER_OUTLINE,
onClick: () => {
setShowResetConfirmation(true);
},
icon: IconProp.Refresh,
},
],
}}
isEditable={false}
refresher={refresher}
modelDetailProps={{
showDetailsInNumberOfColumns: 1,
modelType: Workflow,
id: "model-detail-workflow-webhook-secret",
fields: [
{
field: {
webhookSecretKey: true,
},
fieldType: FieldType.HiddenText,
title: "Webhook Secret Key",
placeholder:
"No secret key generated yet. Save the workflow to generate one.",
opts: {
isCopyable: true,
},
},
],
modelId: modelId,
}}
/>
{showResetConfirmation && (
<ConfirmModal
title="Reset Webhook Secret Key"
description="Are you sure you want to reset the webhook secret key? Any existing integrations using the current key will stop working."
submitButtonText="Reset Key"
submitButtonType={ButtonStyleType.DANGER}
onClose={() => {
setShowResetConfirmation(false);
}}
onSubmit={resetSecretKey}
/>
)}
{error && (
<ConfirmModal
title="Error"
description={error}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
onSubmit={() => {
setError("");
}}
/>
)}
<DuplicateModel
modelId={modelId}
modelType={Workflow}

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

View File

@@ -1,12 +1,14 @@
This trigger lets you start the workflow with the incoming HTTP request.
This trigger lets you start the workflow with the incoming HTTP request.
**URL of this trigger:**
**URL of this trigger:**
```text
{{serverUrl}}workflow/trigger/{{workflowId}}
{{serverUrl}}workflow/trigger/{{webhookSecretKey}}
```
This URL uses a secret key unique to this workflow. You can reset this secret key from the Workflow Settings page if it is compromised.
This can be a GET or POST request. You can send request headers, and body to the Webhook trigger and that can be accessed by any other components downstream.