From af14f96786e632d61c06fba41a2a65bacf479bef Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Wed, 15 Jul 2026 13:14:36 +0200 Subject: [PATCH 01/19] moving away from subaction --- package.json | 1 + .../shared/kbn-connector-specs/index.ts | 8 + .../kbn-connector-specs/src/all_specs.ts | 1 + .../compute_ingest_token_hash.ts} | 24 +- .../inbound_webhook/filter_inbound_headers.ts | 38 +++ .../src/inbound_webhook_constants.ts} | 4 +- .../inbound_webhook_received_event_schema.ts | 14 +- .../inbound_webhook/inbound_webhook.test.ts | 42 ++++ .../specs/inbound_webhook/inbound_webhook.ts | 151 ++++++++++++ .../common/connector_sub_actions_map.ts | 4 - .../inbound_webhook/inbound_webhook.tsx | 55 ----- .../inbound_webhook_connector_fields.tsx | 139 ----------- .../workflows_management/public/plugin.ts | 4 - .../inbound_webhook/get_webhook_status.ts | 68 ----- .../routes/inbound_webhook/post_webhook.ts | 171 ------------- .../connectors/inbound_webhook/index.ts | 232 ------------------ .../connectors/inbound_webhook/schema.ts | 33 --- .../connectors/inbound_webhook/types.ts | 45 ---- .../workflows_management/server/plugin.ts | 106 -------- .../server/saved_objects/inbound_webhook.ts | 53 ---- .../inbound_webhook_api_key_service.ts | 118 --------- .../services/inbound_webhook_request_store.ts | 34 --- .../inbound_webhook_mapping_repository.ts | 206 ---------------- .../tasks/inbound_webhook_cleanup_task.ts | 68 ----- tsconfig.base.json | 2 + .../plugins/shared/actions/kibana.jsonc | 2 +- .../actions_client/actions_client.test.ts | 4 + .../actions/server/actions_config.test.ts | 4 + .../plugins/shared/actions/server/config.ts | 4 + .../inbound/dispatch_connector_events.test.ts | 49 ++++ .../inbound/dispatch_connector_events.ts | 41 ++++ .../server/inbound/handle_inbound_request.ts | 139 +++++++++++ .../server/inbound/load_inbound_connector.ts | 101 ++++++++ .../server/inbound/register_inbound_routes.ts | 124 ++++++++++ .../inbound/verify_ingress_auth.test.ts | 54 ++++ .../server/inbound/verify_ingress_auth.ts | 52 ++++ .../plugins/shared/actions/server/index.ts | 2 + .../axios_utils_connection.test.ts | 4 + .../axios_utils_proxy.test.ts | 4 + .../get_axios_instance_connection.test.ts | 4 + .../server/lib/custom_host_settings.test.ts | 4 + .../plugins/shared/actions/server/mocks.ts | 1 + .../shared/actions/server/plugin.test.ts | 12 + .../plugins/shared/actions/server/plugin.ts | 29 +++ .../plugins/shared/actions/server/types.ts | 18 ++ .../connector_events_bridge/jest.config.js | 14 ++ .../connector_events_bridge/kibana.jsonc | 15 ++ .../connector_events_bridge/server/index.ts | 13 + .../connector_events_bridge/server/plugin.ts | 59 +++++ ..._workflows_connector_event_emitter.test.ts | 42 ++++ ...ister_workflows_connector_event_emitter.ts | 54 ++++ .../connector_events_bridge/tsconfig.json | 9 + .../shared/stack_connectors/kibana.jsonc | 2 +- .../inbound_webhook/inbound_webhook.tsx | 36 +++ .../inbound_webhook_connector_fields.tsx | 118 +++++++++ .../shared/stack_connectors/public/plugin.ts | 8 + .../register_connector_event_triggers.ts | 44 ++++ .../server/connector_types_from_spec/index.ts | 13 +- ...register_inbound_webhook_connector_type.ts | 62 +++++ .../shared/stack_connectors/server/plugin.ts | 11 +- .../check_registered_task_types.ts | 2 - 61 files changed, 1418 insertions(+), 1357 deletions(-) rename src/platform/{plugins/shared/workflows_extensions/public/eui_icons.d.ts => packages/shared/kbn-connector-specs/src/inbound_webhook/compute_ingest_token_hash.ts} (55%) create mode 100644 src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/filter_inbound_headers.ts rename src/platform/{plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_params_fields.tsx => packages/shared/kbn-connector-specs/src/inbound_webhook_constants.ts} (66%) create mode 100644 src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts create mode 100644 src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts delete mode 100644 src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook.tsx delete mode 100644 src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_connector_fields.tsx delete mode 100644 src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/get_webhook_status.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/post_webhook.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/index.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/schema.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/types.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/saved_objects/inbound_webhook.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_api_key_service.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_request_store.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/storage/inbound_webhook_mapping_repository.ts delete mode 100644 src/platform/plugins/shared/workflows_management/server/tasks/inbound_webhook_cleanup_task.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.test.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts create mode 100644 x-pack/platform/plugins/shared/connector_events_bridge/jest.config.js create mode 100644 x-pack/platform/plugins/shared/connector_events_bridge/kibana.jsonc create mode 100644 x-pack/platform/plugins/shared/connector_events_bridge/server/index.ts create mode 100644 x-pack/platform/plugins/shared/connector_events_bridge/server/plugin.ts create mode 100644 x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts create mode 100644 x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts create mode 100644 x-pack/platform/plugins/shared/connector_events_bridge/tsconfig.json create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts diff --git a/package.json b/package.json index 0fff47bd3bfe6..fc3d4954f63d2 100644 --- a/package.json +++ b/package.json @@ -311,6 +311,7 @@ "@kbn/config": "link:src/platform/packages/shared/kbn-config", "@kbn/config-mocks": "link:src/platform/packages/private/kbn-config-mocks", "@kbn/config-schema": "link:src/platform/packages/shared/kbn-config-schema", + "@kbn/connector-events-bridge-plugin": "link:x-pack/platform/plugins/shared/connector_events_bridge", "@kbn/connector-schemas": "link:src/platform/packages/shared/kbn-connector-schemas", "@kbn/connector-specs": "link:src/platform/packages/shared/kbn-connector-specs", "@kbn/console-plugin": "link:src/platform/plugins/shared/console", diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts index f53f34a8f6fc7..9235f5083abca 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -38,6 +38,14 @@ export { } from './src/list_connector_event_infos'; export { resolveRegisteredConnectorEventByEventId } from './src/resolve_registered_connector_event_by_event_id'; export { inboundWebhookReceivedEventSchema } from './src/inbound_webhook_received_event_schema'; +export { + INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + INBOUND_WEBHOOK_RECEIVED_EVENT_ID, + INBOUND_WEBHOOK_RECEIVED_EVENT_KEY, +} from './src/inbound_webhook_constants'; +export { computeIngestTokenHash } from './src/inbound_webhook/compute_ingest_token_hash'; +export { filterInboundHeaders } from './src/inbound_webhook/filter_inbound_headers'; +export { InboundWebhookConnector } from './src/specs/inbound_webhook/inbound_webhook'; export { isToolAction, TEST_CONNECTOR_SUB_ACTION } from './src/connector_spec'; export { getConnectorActionErrorMeta, diff --git a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts index 035f516c872a9..19d72b063a07f 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts @@ -37,6 +37,7 @@ export * from './specs/zoom/zoom'; export * from './specs/zendesk/zendesk'; export * from './specs/amazon_s3/amazon_s3'; export * from './specs/hubspot/hubspot'; +export * from './specs/inbound_webhook/inbound_webhook'; export * from './specs/google_cloud_storage/google_cloud_storage'; export * from './specs/sharepoint_server/sharepoint_server'; export * from './specs/microsoft_teams/microsoft_teams'; diff --git a/src/platform/plugins/shared/workflows_extensions/public/eui_icons.d.ts b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/compute_ingest_token_hash.ts similarity index 55% rename from src/platform/plugins/shared/workflows_extensions/public/eui_icons.d.ts rename to src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/compute_ingest_token_hash.ts index c9eb3f08df2ab..5f3487fb0795a 100644 --- a/src/platform/plugins/shared/workflows_extensions/public/eui_icons.d.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/compute_ingest_token_hash.ts @@ -7,17 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -declare module '@elastic/eui/es/components/icon/assets/*' { - import type * as React from 'react'; +// eslint-disable-next-line import/no-nodejs-modules -- server-side ingress credential hashing +import { createHash } from 'node:crypto'; - interface SVGRProps { - title?: string; - titleId?: string; - } - export const icon: ({ - title, - titleId, - ...props - }: React.SVGProps & SVGRProps) => React.JSX.Element; - export {}; -} +export const computeIngestTokenHash = ({ + connectorId, + spaceId, + token, +}: { + connectorId: string; + spaceId: string; + token: string; +}): string => createHash('sha256').update(`${connectorId}|${spaceId}|${token}`).digest('hex'); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/filter_inbound_headers.ts b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/filter_inbound_headers.ts new file mode 100644 index 0000000000000..646512bae0a07 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/filter_inbound_headers.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const SENSITIVE_HEADERS = new Set([ + 'authorization', + 'cookie', + 'host', + 'proxy-authorization', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-inbound-query', +]); + +export const filterInboundHeaders = ( + headers: Record +): Record => + Object.fromEntries( + Object.entries(headers).flatMap(([name, value]) => { + const normalizedName = name.toLowerCase(); + if ( + SENSITIVE_HEADERS.has(normalizedName) || + (!normalizedName.startsWith('x-') && + normalizedName !== 'content-type' && + normalizedName !== 'user-agent') + ) { + return []; + } + const normalizedValue = Array.isArray(value) ? value.join(',') : value; + return typeof normalizedValue === 'string' ? [[normalizedName, normalizedValue]] : []; + }) + ); diff --git a/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_params_fields.tsx b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook_constants.ts similarity index 66% rename from src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_params_fields.tsx rename to src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook_constants.ts index caa180ff52036..068da911a0d00 100644 --- a/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_params_fields.tsx +++ b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook_constants.ts @@ -7,4 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export const InboundWebhookParamsFields = () => null; +export const INBOUND_WEBHOOK_CONNECTOR_TYPE_ID = '.inboundWebhook' as const; +export const INBOUND_WEBHOOK_RECEIVED_EVENT_ID = 'inboundWebhook.received' as const; +export const INBOUND_WEBHOOK_RECEIVED_EVENT_KEY = 'received' as const; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook_received_event_schema.ts b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook_received_event_schema.ts index b550cf4cfb18d..8d8a448644f7b 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook_received_event_schema.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook_received_event_schema.ts @@ -11,10 +11,22 @@ import { z } from '@kbn/zod/v4'; /** * Payload shape for inbound webhook `received` connector events. - * Describes the emitted event data; connector binding is resolved separately at ingress. + * Hub adds `connectorId` and `connectorTypeId` before emit; handleEvents supplies the rest. */ export const inboundWebhookReceivedEventSchema = z.object({ connectorId: z.string().describe('Saved connector instance id.'), + connectorTypeId: z.string().describe('Connector type id (e.g. .inboundWebhook).'), body: z.unknown().describe('Parsed request body.'), + headers: z + .record(z.string(), z.string()) + .describe('Non-sensitive request headers forwarded with the event.'), + query: z + .record(z.string(), z.union([z.string(), z.array(z.string())])) + .optional() + .describe('Query string parameters from the inbound request.'), receivedAt: z.string().describe('ISO timestamp when the event was received.'), + correlationKey: z + .string() + .optional() + .describe('Idempotency / deduplication key for this ingress request.'), }); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts new file mode 100644 index 0000000000000..b87d71bfa48ea --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +import { InboundWebhookConnector, INBOUND_WEBHOOK_RECEIVED_EVENT_ID } from '@kbn/connector-specs'; + +describe('InboundWebhookConnector.handleEvents', () => { + it('returns inboundWebhook.received payload', async () => { + const result = await InboundWebhookConnector.events!.handleEvents({ + connectorId: 'sales-ingress', + connectorTypeId: '.inboundWebhook', + spaceId: 'default', + config: {}, + secrets: {}, + rawBody: { eventType: 'order.created' }, + headers: { + 'content-type': 'application/json', + authorization: 'Bearer secret', + 'x-custom': 'value', + 'x-inbound-query': JSON.stringify({ source: 'shopify' }), + }, + log: loggingSystemMock.create().get(), + }); + + expect(result.httpResponse).toBeUndefined(); + expect(result.events).toHaveLength(1); + expect(result.events?.[0]?.eventId).toBe(INBOUND_WEBHOOK_RECEIVED_EVENT_ID); + expect(result.events?.[0]?.payload.body).toEqual({ eventType: 'order.created' }); + expect(result.events?.[0]?.payload.headers).toEqual({ + 'content-type': 'application/json', + 'x-custom': 'value', + }); + expect(result.events?.[0]?.payload.query).toEqual({ source: 'shopify' }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts new file mode 100644 index 0000000000000..8b5caf5a710f9 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { v4 as uuidv4 } from 'uuid'; + +import { i18n } from '@kbn/i18n'; +import { z, lazySchema } from '@kbn/zod/v4'; + +import type { ConnectorSpec } from '../../connector_spec'; +import { defineConnectorEvent } from '../../define_connector_event'; +import { buildConnectorEventId } from '../../connector_event_type_id'; +import { inboundWebhookReceivedEventSchema } from '../../inbound_webhook_received_event_schema'; +import { + INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + INBOUND_WEBHOOK_RECEIVED_EVENT_KEY, +} from '../../inbound_webhook_constants'; +import { filterInboundHeaders } from '../../inbound_webhook/filter_inbound_headers'; + +export const InboundWebhookConnector: ConnectorSpec = { + metadata: { + id: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + displayName: 'Inbound Webhook', + description: i18n.translate('core.kibanaConnectorSpecs.inboundWebhook.metadata.description', { + defaultMessage: 'Receive HTTP requests from external systems and trigger workflows.', + }), + minimumLicense: 'gold', + isTechnicalPreview: true, + supportedFeatureIds: ['workflows'], + }, + + auth: { + types: ['none'], + }, + + schema: lazySchema(() => + z.object({ + ingestTokenHash: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional() + .meta({ + hidden: true, + label: i18n.translate('core.kibanaConnectorSpecs.inboundWebhook.config.ingestTokenHash', { + defaultMessage: 'Ingest token hash', + }), + }), + ingestToken: z + .string() + .min(32) + .optional() + .meta({ + sensitive: true, + label: i18n.translate('core.kibanaConnectorSpecs.inboundWebhook.secrets.ingestToken', { + defaultMessage: 'Ingest token', + }), + helpText: i18n.translate( + 'core.kibanaConnectorSpecs.inboundWebhook.secrets.ingestTokenHelp', + { + defaultMessage: + 'Secret token for authenticating inbound HTTP requests. Leave blank on create to auto-generate.', + } + ), + }), + webhookUrl: z + .string() + .url() + .optional() + .meta({ + hidden: true, + label: i18n.translate('core.kibanaConnectorSpecs.inboundWebhook.config.webhookUrl', { + defaultMessage: 'Webhook URL', + }), + }), + }) + ), + + actions: {}, + + events: { + definitions: { + [INBOUND_WEBHOOK_RECEIVED_EVENT_KEY]: defineConnectorEvent({ + eventId: buildConnectorEventId( + INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + INBOUND_WEBHOOK_RECEIVED_EVENT_KEY + ), + title: i18n.translate('core.kibanaConnectorSpecs.inboundWebhook.events.received.title', { + defaultMessage: 'Webhook received', + }), + description: i18n.translate( + 'core.kibanaConnectorSpecs.inboundWebhook.events.received.description', + { + defaultMessage: 'Fires when an authenticated request hits this connector endpoint.', + } + ), + eventSchema: inboundWebhookReceivedEventSchema, + stability: 'tech_preview', + }), + }, + + async handleEvents(ctx) { + const receivedAt = new Date().toISOString(); + const query = normalizeQuery(ctx.headers); + return { + events: [ + { + eventId: buildConnectorEventId( + INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + INBOUND_WEBHOOK_RECEIVED_EVENT_KEY + ), + correlationKey: uuidv4(), + payload: { + body: ctx.rawBody, + headers: filterInboundHeaders(ctx.headers), + ...(query ? { query } : {}), + receivedAt, + }, + }, + ], + }; + }, + }, + + test: { + enabled: false, + handler: async () => ({}), + }, +}; + +const normalizeQuery = ( + headers: Record +): Record | undefined => { + const rawQuery = headers['x-inbound-query']; + if (typeof rawQuery !== 'string' || rawQuery.trim() === '') { + return undefined; + } + try { + const parsed: unknown = JSON.parse(rawQuery); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return undefined; + } + return parsed as Record; + } catch { + return undefined; + } +}; diff --git a/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts b/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts index e0a4957163f63..f0ad728fdba39 100644 --- a/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts +++ b/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts @@ -115,9 +115,6 @@ function createSubActionsMapping() { const CASES_WEBHOOK_SUB_ACTIONS = { PUSH_TO_SERVICE: 'pushToService', }; - const INBOUND_WEBHOOK_SUB_ACTIONS = { - RECEIVE: 'receive', - }; // Define all connector sub-actions const connectorSubActions = [ @@ -140,7 +137,6 @@ function createSubActionsMapping() { { id: '.servicenow-itom', actions: SERVICENOW_ITOM_SUB_ACTIONS }, { id: '.swimlane', actions: SWIMLANE_SUB_ACTIONS }, { id: '.cases-webhook', actions: CASES_WEBHOOK_SUB_ACTIONS }, - { id: '.workflows-inbound-webhook', actions: INBOUND_WEBHOOK_SUB_ACTIONS }, ]; connectorSubActions.forEach(({ id, actions }) => { diff --git a/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook.tsx b/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook.tsx deleted file mode 100644 index d08c16d3132f8..0000000000000 --- a/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { lazy } from 'react'; -import { i18n } from '@kbn/i18n'; -import type { - ActionTypeModel, - GenericValidationResult, -} from '@kbn/triggers-actions-ui-plugin/public'; - -interface InboundWebhookConfig { - webhookKeyHash: string; - credentialRevision: string; -} - -interface InboundWebhookSecrets { - webhookUrl?: string; -} - -interface InboundWebhookParams { - subAction: 'receive'; - subActionParams: Record; -} - -export const getInboundWebhookConnectorType = (): ActionTypeModel< - InboundWebhookConfig, - InboundWebhookSecrets, - InboundWebhookParams -> => ({ - id: '.workflows-inbound-webhook', - iconClass: 'link', - selectMessage: i18n.translate('workflowsManagement.inboundWebhook.connectorSelectDescription', { - defaultMessage: 'Receive JSON events from an external webhook.', - }), - actionTypeTitle: i18n.translate('workflowsManagement.inboundWebhook.connectorTitle', { - defaultMessage: 'Inbound Webhook', - }), - validateParams: async (): Promise> => ({ errors: {} }), - actionConnectorFields: lazy(() => - import('./inbound_webhook_connector_fields').then(({ InboundWebhookConnectorFields }) => ({ - default: InboundWebhookConnectorFields, - })) - ), - actionParamsFields: lazy(() => - import('./inbound_webhook_params_fields').then(({ InboundWebhookParamsFields }) => ({ - default: InboundWebhookParamsFields, - })) - ), -}); diff --git a/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_connector_fields.tsx b/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_connector_fields.tsx deleted file mode 100644 index 4a7ce1fc36308..0000000000000 --- a/src/platform/plugins/shared/workflows_management/public/connectors/inbound_webhook/inbound_webhook_connector_fields.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { EuiButton, EuiCallOut, EuiCopy, EuiSpacer } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; -import { HiddenField, TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import { - UseField, - useFormContext, - useFormData, -} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { ActionConnectorFieldsProps } from '@kbn/triggers-actions-ui-plugin/public'; -import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; - -const sha256 = async (value: string): Promise => { - const bytes = new TextEncoder().encode(value); - const digest = await window.crypto.subtle.digest('SHA-256', bytes); - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join(''); -}; - -export const InboundWebhookConnectorFields = ({ - isEdit, - readOnly, - registerPreSubmitValidator, -}: ActionConnectorFieldsProps) => { - const { http } = useKibana().services; - const { setFieldValue } = useFormContext(); - const [{ id, secrets }] = useFormData({ watch: ['id', 'secrets.webhookUrl'] }); - const webhookUrl = secrets?.webhookUrl as string | undefined; - const [status, setStatus] = useState(); - - useEffect(() => { - registerPreSubmitValidator(async () => { - setFieldValue('config.credentialRevision', window.crypto.randomUUID()); - }); - }, [registerPreSubmitValidator, setFieldValue]); - - useEffect(() => { - if (isEdit || webhookUrl) { - return; - } - const createWebhook = async () => { - const key = window.crypto.randomUUID().replaceAll('-', ''); - const path = http.basePath.prepend(`/api/event/${key}`); - setFieldValue('secrets.webhookUrl', `${window.location.origin}${path}`); - setFieldValue('config.webhookKeyHash', await sha256(key)); - setFieldValue('config.credentialRevision', window.crypto.randomUUID()); - }; - void createWebhook(); - }, [http.basePath, isEdit, setFieldValue, webhookUrl]); - - useEffect(() => { - if (!isEdit || !id) { - return; - } - const loadStatus = async () => { - try { - const result = await http.get<{ status: string }>( - `/internal/workflows/inbound_webhook/${encodeURIComponent(id as string)}/status` - ); - setStatus(result.status); - } catch { - setStatus('unavailable'); - } - }; - void loadStatus(); - }, [http, id, isEdit]); - - return ( - <> - - - {isEdit ? ( - - - - ) : ( - <> - - - - {(copy) => ( - - - - )} - - - )} - - ); -}; diff --git a/src/platform/plugins/shared/workflows_management/public/plugin.ts b/src/platform/plugins/shared/workflows_management/public/plugin.ts index cad65065756f7..5ba2f1d58fc91 100644 --- a/src/platform/plugins/shared/workflows_management/public/plugin.ts +++ b/src/platform/plugins/shared/workflows_management/public/plugin.ts @@ -89,11 +89,7 @@ export class WorkflowsPlugin // Register workflows connector UI component lazily to reduce main bundle size const registerConnectorType = async () => { const { getWorkflowsConnectorType } = await import('./connectors/workflows'); - const { getInboundWebhookConnectorType } = await import( - './connectors/inbound_webhook/inbound_webhook' - ); plugins.triggersActionsUi.actionTypeRegistry.register(getWorkflowsConnectorType()); - plugins.triggersActionsUi.actionTypeRegistry.register(getInboundWebhookConnectorType()); }; registerConnectorType(); diff --git a/src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/get_webhook_status.ts b/src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/get_webhook_status.ts deleted file mode 100644 index 3ba6c681e81c6..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/get_webhook_status.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { schema } from '@kbn/config-schema'; -import type { KibanaRequest } from '@kbn/core/server'; -import type { InboundWebhookMappingRepository } from '../../../storage/inbound_webhook_mapping_repository'; -import type { WorkflowsRouter } from '../../../types'; -import { WORKFLOW_READ_SECURITY } from '../utils/route_security'; - -export const registerGetInboundWebhookStatusRoute = ({ - router, - getMappingRepository, - getSpaceId, -}: { - router: WorkflowsRouter; - getMappingRepository: () => InboundWebhookMappingRepository; - getSpaceId: (request: KibanaRequest) => string; -}): void => { - router.get( - { - path: '/internal/workflows/inbound_webhook/{connectorId}/status', - security: WORKFLOW_READ_SECURITY, - options: { access: 'internal' }, - validate: { - params: schema.object({ connectorId: schema.string() }), - }, - }, - async (context, request, response) => { - try { - const actionsClient = (await context.actions).getActionsClient(); - await actionsClient.get({ id: request.params.connectorId }); - const mappings = await getMappingRepository().getForConnector( - request.params.connectorId, - getSpaceId(request) - ); - const active = mappings.find(({ attributes }) => attributes.payload.status === 'active'); - const pending = mappings.find(({ attributes }) => attributes.payload.status === 'pending'); - if (pending) { - return response.ok({ - body: { - status: 'updating', - credentialRevision: pending.attributes.payload.credentialRevision, - }, - }); - } - if (!active) { - return response.ok({ body: { status: 'disabled' } }); - } - return response.ok({ - body: { - status: 'active', - credentialRevision: active.attributes.payload.credentialRevision, - credentialVersion: active.attributes.payload.secrets.credentialVersion, - delegatedUsername: active.attributes.payload.delegatedUsername, - }, - }); - } catch { - return response.notFound(); - } - } - ); -}; diff --git a/src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/post_webhook.ts b/src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/post_webhook.ts deleted file mode 100644 index 52618ab9d1cd0..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/api/routes/inbound_webhook/post_webhook.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { createHash, randomUUID } from 'crypto'; -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import { schema } from '@kbn/config-schema'; -import type { KibanaRequest, Logger } from '@kbn/core/server'; -import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; -import { asSpaceId } from '@kbn/core-spaces-common'; -import type { InboundWebhookApiKeyService } from '../../../services/inbound_webhook_api_key_service'; -import type { InboundWebhookRequestStore } from '../../../services/inbound_webhook_request_store'; -import type { InboundWebhookMappingRepository } from '../../../storage/inbound_webhook_mapping_repository'; -import type { WorkflowsRouter } from '../../../types'; - -const MAX_BODY_BYTES = 1024 * 1024; -const SENSITIVE_HEADERS = new Set([ - 'authorization', - 'cookie', - 'host', - 'proxy-authorization', - 'x-forwarded-for', - 'x-forwarded-host', - 'x-forwarded-proto', -]); - -const filterHeaders = (headers: KibanaRequest['headers']): Record => - Object.fromEntries( - Object.entries(headers).flatMap(([name, value]) => { - const normalizedName = name.toLowerCase(); - if ( - SENSITIVE_HEADERS.has(normalizedName) || - (!normalizedName.startsWith('x-') && - normalizedName !== 'content-type' && - normalizedName !== 'user-agent') - ) { - return []; - } - const normalizedValue = Array.isArray(value) ? value.join(',') : value; - return typeof normalizedValue === 'string' ? [[normalizedName, normalizedValue]] : []; - }) - ); - -export interface InboundWebhookRouteDependencies { - router: WorkflowsRouter; - getActionsClient: (request: KibanaRequest, spaceId: string) => Promise; - getApiKeyService: () => InboundWebhookApiKeyService; - getMappingRepository: () => InboundWebhookMappingRepository; - getSpaceId: (request: KibanaRequest) => string; - logger: Logger; - requestStore: InboundWebhookRequestStore; -} - -export const registerPostInboundWebhookRoute = ({ - router, - getActionsClient, - getApiKeyService, - getMappingRepository, - getSpaceId, - logger, - requestStore, -}: InboundWebhookRouteDependencies): void => { - router.post( - { - path: '/api/event/{webhookKey}', - security: { - authc: { - enabled: false, - reason: 'The webhook URL contains the credential used to authenticate the caller', - }, - authz: { - enabled: false, - reason: - 'Authorization is delegated to the encrypted webhook mapping and a user-scoped Actions client', - }, - }, - options: { - access: 'public', - xsrfRequired: false, - tags: ['api'], - body: { - accepts: ['application/json'], - maxBytes: MAX_BODY_BYTES, - }, - }, - validate: { - params: schema.object({ - webhookKey: schema.string({ - validate: (value) => - /^[0-9a-f]{32}$/i.test(value) ? undefined : 'must be a 32-character hexadecimal key', - }), - }), - query: schema.recordOf( - schema.string(), - schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) - ), - body: schema.recordOf(schema.string(), schema.any()), - }, - }, - async (_context, request, response) => { - const spaceId = getSpaceId(request); - const webhookKeyHash = createHash('sha256').update(request.params.webhookKey).digest('hex'); - let mapping; - try { - mapping = await getMappingRepository().resolve(webhookKeyHash, spaceId); - } catch { - return response.notFound(); - } - const payload = mapping?.attributes.payload; - if ( - !payload || - payload.status !== 'active' || - payload.connectorTypeId !== '.workflows-inbound-webhook' - ) { - return response.notFound(); - } - - try { - const authorization = getApiKeyService().getAuthorizationHeader(payload.secrets); - const fakeRequest = kibanaRequestFactory({ - headers: { authorization }, - spaceId: asSpaceId(spaceId), - }); - const actionsClient = await getActionsClient(fakeRequest, spaceId); - const eventId = randomUUID(); - requestStore.set(eventId, fakeRequest); - const result = await (async () => { - try { - return await actionsClient.execute({ - actionId: payload.connectorId, - params: { - subAction: 'receive', - subActionParams: { - eventId, - credentialRevision: payload.credentialRevision, - body: request.body, - query: request.query, - headers: filterHeaders(request.headers), - receivedAt: new Date().toISOString(), - }, - }, - }); - } finally { - requestStore.delete(eventId); - } - })(); - if (result.status !== 'ok') { - logger.warn( - `Inbound webhook connector execution failed: connectorId=${payload.connectorId} eventId=${eventId} spaceId=${spaceId}` - ); - return response.customError({ statusCode: 503, body: 'Webhook delivery failed' }); - } - logger.info( - `Inbound webhook event accepted: connectorId=${payload.connectorId} eventId=${eventId} spaceId=${spaceId}` - ); - return response.accepted({ body: { accepted: true, eventId } }); - } catch (error) { - logger.warn( - `Inbound webhook delivery failed: connectorId=${payload.connectorId} spaceId=${spaceId}`, - { error } - ); - return response.customError({ statusCode: 503, body: 'Webhook delivery failed' }); - } - } - ); -}; diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/index.ts b/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/index.ts deleted file mode 100644 index d505ae458a48a..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/index.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import Boom from '@hapi/boom'; -import { createHash } from 'crypto'; -import { WorkflowsConnectorFeatureId } from '@kbn/actions-plugin/common'; -import type { - ActionType, - ActionTypeExecutorOptions, - ActionTypeExecutorResult, -} from '@kbn/actions-plugin/server/types'; -import type { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; -import { i18n } from '@kbn/i18n'; -import { - InboundWebhookConfigSchema, - InboundWebhookParamsSchema, - InboundWebhookSecretsSchema, -} from './schema'; -import type { - InboundWebhookConfig, - InboundWebhookEvent, - InboundWebhookParams, - InboundWebhookResult, - InboundWebhookSecrets, -} from './types'; -import type { - DelegatedWebhookCredentials, - InboundWebhookApiKeyService, -} from '../../services/inbound_webhook_api_key_service'; -import type { InboundWebhookMappingRepository } from '../../storage/inbound_webhook_mapping_repository'; - -export const INBOUND_WEBHOOK_CONNECTOR_TYPE_ID = '.workflows-inbound-webhook' as const; -const INBOUND_WEBHOOK_TRIGGER_ID = 'webhook'; - -export interface InboundWebhookConnectorDependencies { - canEncrypt: () => boolean; - emitEvent: ( - request: KibanaRequest, - triggerId: string, - event: InboundWebhookEvent - ) => Promise; - getApiKeyService: () => InboundWebhookApiKeyService; - getMappingRepository: () => InboundWebhookMappingRepository; - takeRequest: (eventId: string) => KibanaRequest | undefined; - getSavedObjectsClient: (request: KibanaRequest) => Promise; - getSpaceId: (request: KibanaRequest) => string; -} - -const hashWebhookKey = (key: string): string => createHash('sha256').update(key).digest('hex'); - -const getWebhookKey = (webhookUrl: string): string => { - const url = new URL(webhookUrl); - const key = url.pathname.split('/').filter(Boolean).at(-1); - if (!key) { - throw new Error('Webhook URL must contain a key'); - } - return key; -}; - -const getPendingId = (connectorId: string, credentialRevision: string): string => - `pending:${connectorId}:${credentialRevision}`; - -export const getInboundWebhookConnectorType = ( - dependencies: InboundWebhookConnectorDependencies -): ActionType< - InboundWebhookConfig, - InboundWebhookSecrets, - InboundWebhookParams, - InboundWebhookResult -> => ({ - id: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, - name: i18n.translate('workflowsManagement.inboundWebhook.connectorName', { - defaultMessage: 'Inbound Webhook', - }), - minimumLicenseRequired: 'gold', - supportedFeatureIds: [WorkflowsConnectorFeatureId], - validate: { - config: { schema: InboundWebhookConfigSchema }, - secrets: { schema: InboundWebhookSecretsSchema }, - params: { schema: InboundWebhookParamsSchema }, - }, - preSaveHook: async ({ connectorId, config, secrets, request, isUpdate }) => { - if (!dependencies.canEncrypt()) { - throw new Error('Encrypted saved objects encryption key is not configured'); - } - if (!isUpdate && !secrets.webhookUrl) { - throw new Error('Webhook URL is required'); - } - if (secrets.webhookUrl) { - const calculatedHash = hashWebhookKey(getWebhookKey(secrets.webhookUrl)); - if (calculatedHash !== config.webhookKeyHash) { - throw new Error('Webhook URL does not match webhook key hash'); - } - } - - const spaceId = dependencies.getSpaceId(request); - const repository = dependencies.getMappingRepository(); - const previous = await repository.resolve(config.webhookKeyHash, spaceId); - let credentials: DelegatedWebhookCredentials; - try { - credentials = await dependencies.getApiKeyService().grant(request, connectorId); - } catch (error) { - throw Boom.badRequest( - `Failed to grant inbound webhook credentials: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - const credentialVersion = (previous?.attributes.payload.secrets.credentialVersion ?? 0) + 1; - try { - await repository.stage( - { - connectorId, - credentialRevision: config.credentialRevision, - webhookKeyHash: config.webhookKeyHash, - spaceId, - attributes: { - ...credentials, - secrets: { - ...credentials.secrets, - credentialVersion, - webhookUrl: secrets.webhookUrl ?? previous?.attributes.payload.secrets.webhookUrl, - }, - }, - }, - await dependencies.getSavedObjectsClient(request) - ); - } catch (error) { - await dependencies.getApiKeyService().invalidate({ - ...credentials, - connectorId, - connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, - status: 'pending', - targetWebhookKeyHash: config.webhookKeyHash, - credentialRevision: config.credentialRevision, - createdAt: new Date().toISOString(), - secrets: { - ...credentials.secrets, - credentialVersion, - }, - updatedAt: new Date().toISOString(), - }); - throw Boom.badRequest( - `Failed to stage inbound webhook credentials: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - }, - postSaveHook: async ({ connectorId, config, request, wasSuccessful }) => { - const spaceId = dependencies.getSpaceId(request); - const repository = dependencies.getMappingRepository(); - const apiKeyService = dependencies.getApiKeyService(); - const pendingId = getPendingId(connectorId, config.credentialRevision); - - if (!wasSuccessful) { - const pending = await repository.resolve(pendingId, spaceId); - if (pending) { - await apiKeyService.invalidate(pending.attributes.payload); - await repository.deletePending(pendingId, spaceId); - } - return; - } - - const { previous } = await repository.promote({ - pendingId, - spaceId, - savedObjectsClient: await dependencies.getSavedObjectsClient(request), - }); - if (previous) { - await apiKeyService.invalidate(previous.attributes.payload); - } - }, - postDeleteHook: async ({ connectorId, request }) => { - const spaceId = dependencies.getSpaceId(request); - const repository = dependencies.getMappingRepository(); - const mappings = await repository.getForConnector(connectorId, spaceId); - await Promise.all( - mappings.map(({ attributes }) => - dependencies.getApiKeyService().invalidate(attributes.payload) - ) - ); - await repository.deleteForConnector(connectorId, spaceId); - }, - executor: async ( - options: ActionTypeExecutorOptions< - InboundWebhookConfig, - InboundWebhookSecrets, - InboundWebhookParams - > - ): Promise> => { - const { actionId, config, logger, params, request } = options; - const scopedRequest = request ?? dependencies.takeRequest(params.subActionParams.eventId); - if (!scopedRequest) { - throw new Error('Inbound webhook execution requires a scoped request'); - } - if (config.credentialRevision !== params.subActionParams.credentialRevision) { - throw new Error('Inbound webhook credential revision is stale'); - } - - const event: InboundWebhookEvent = { - connectorId: actionId, - eventId: params.subActionParams.eventId, - body: params.subActionParams.body, - query: params.subActionParams.query, - headers: params.subActionParams.headers, - receivedAt: params.subActionParams.receivedAt, - }; - logger.debug(`Inbound webhook event received: ${JSON.stringify(event)}`); - // Uncomment when the webhook trigger is wired up - // await dependencies.emitEvent(scopedRequest, INBOUND_WEBHOOK_TRIGGER_ID, event); - return { - status: 'ok', - actionId, - data: { eventId: event.eventId, accepted: true }, - }; - }, -}); - -export type { - InboundWebhookConfig, - InboundWebhookEvent, - InboundWebhookParams, - InboundWebhookResult, - InboundWebhookSecrets, -} from './types'; diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/schema.ts b/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/schema.ts deleted file mode 100644 index 1d0af8daefde7..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { z } from '@kbn/zod/v4'; - -export const InboundWebhookConfigSchema = z.object({ - webhookKeyHash: z.string().regex(/^[a-f0-9]{64}$/), - credentialRevision: z.string().uuid(), -}); - -export const InboundWebhookSecretsSchema = z.object({ - webhookUrl: z.string().url().optional(), -}); - -export const ReceiveWebhookSubActionParamsSchema = z.object({ - eventId: z.string().uuid(), - credentialRevision: z.string().uuid(), - body: z.record(z.string(), z.unknown()), - query: z.record(z.string(), z.union([z.string(), z.array(z.string())])), - headers: z.record(z.string(), z.string()), - receivedAt: z.string().datetime(), -}); - -export const InboundWebhookParamsSchema = z.object({ - subAction: z.literal('receive'), - subActionParams: ReceiveWebhookSubActionParamsSchema, -}); diff --git a/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/types.ts b/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/types.ts deleted file mode 100644 index 5bd0146156886..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/connectors/inbound_webhook/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export interface InboundWebhookConfig extends Record { - webhookKeyHash: string; - credentialRevision: string; -} - -export interface InboundWebhookSecrets extends Record { - webhookUrl?: string; -} - -export interface ReceiveWebhookSubActionParams { - eventId: string; - credentialRevision: string; - body: Record; - query: Record; - headers: Record; - receivedAt: string; -} - -export interface InboundWebhookEvent extends Record { - connectorId: string; - eventId: string; - body: Record; - query: Record; - headers: Record; - receivedAt: string; -} - -export interface InboundWebhookParams extends Record { - subAction: 'receive'; - subActionParams: ReceiveWebhookSubActionParams; -} - -export interface InboundWebhookResult { - eventId: string; - accepted: true; -} diff --git a/src/platform/plugins/shared/workflows_management/server/plugin.ts b/src/platform/plugins/shared/workflows_management/server/plugin.ts index 9db7551097342..e6b3311e48cbb 100644 --- a/src/platform/plugins/shared/workflows_management/server/plugin.ts +++ b/src/platform/plugins/shared/workflows_management/server/plugin.ts @@ -13,11 +13,8 @@ import type { Plugin, PluginInitializerContext, } from '@kbn/core/server'; -import { SECURITY_EXTENSION_ID } from '@kbn/core/server'; import { defineRoutes } from './api/routes'; -import { registerGetInboundWebhookStatusRoute } from './api/routes/inbound_webhook/get_webhook_status'; -import { registerPostInboundWebhookRoute } from './api/routes/inbound_webhook/post_webhook'; import { WorkflowManagementAuditLog } from './api/routes/utils/workflow_audit_logging'; import { WorkflowsManagementApi } from './api/workflows_management_api'; import { WorkflowsService } from './api/workflows_management_service'; @@ -27,24 +24,12 @@ import { createWorkflowsClientProvider, } from './client/workflows_client'; import type { WorkflowsManagementConfig } from './config'; -import { getInboundWebhookConnectorType } from './connectors/inbound_webhook'; import { getWorkflowsConnectorAdapter, getConnectorType as getWorkflowsConnectorType, } from './connectors/workflows'; import { WorkflowsManagementFeatureConfig } from './features'; import { createWorkflowsInboxProvider } from './inbox/workflows_inbox_provider'; -import { - INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - inboundWebhookSavedObjectType, -} from './saved_objects/inbound_webhook'; -import { InboundWebhookApiKeyService } from './services/inbound_webhook_api_key_service'; -import { InboundWebhookRequestStore } from './services/inbound_webhook_request_store'; -import { InboundWebhookMappingRepository } from './storage/inbound_webhook_mapping_repository'; -import { - registerInboundWebhookCleanupTask, - scheduleInboundWebhookCleanupTask, -} from './tasks/inbound_webhook_cleanup_task'; import type { WorkflowsRequestHandlerContext, WorkflowsServerPluginSetup, @@ -70,10 +55,6 @@ export class WorkflowsPlugin private availabilityUpdater: AvailabilityUpdater | null = null; private api: WorkflowsManagementApi | null = null; private workflowsService: WorkflowsService | null = null; - private canEncryptInboundWebhooks = false; - private inboundWebhookApiKeyService?: InboundWebhookApiKeyService; - private inboundWebhookMappingRepository?: InboundWebhookMappingRepository; - private readonly inboundWebhookRequestStore = new InboundWebhookRequestStore(); constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -99,46 +80,8 @@ export class WorkflowsPlugin const api = new WorkflowsManagementApi(workflowsService, this.config.available); this.api = api; - core.savedObjects.registerType(inboundWebhookSavedObjectType); - if (plugins.encryptedSavedObjects) { - this.canEncryptInboundWebhooks = plugins.encryptedSavedObjects.canEncrypt; - plugins.encryptedSavedObjects.registerType({ - type: INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - enforceRandomId: false, - attributesToEncrypt: new Set(['payload']), - }); - } - if (plugins.taskManager) { - registerInboundWebhookCleanupTask({ - taskManager: plugins.taskManager, - getApiKeyService: () => this.getInboundWebhookApiKeyService(), - getMappingRepository: () => this.getInboundWebhookMappingRepository(), - }); - } - if (plugins.actions) { plugins.actions.registerType(getWorkflowsConnectorType(api)); - plugins.actions.registerType( - getInboundWebhookConnectorType({ - canEncrypt: () => this.canEncryptInboundWebhooks, - emitEvent: async (request, triggerId, event) => { - const [, startPlugins] = await core.getStartServices(); - const workflowsClient = await startPlugins.workflowsExtensions.getClient(request); - await workflowsClient.emitEvent(triggerId, event); - }, - getApiKeyService: () => this.getInboundWebhookApiKeyService(), - getMappingRepository: () => this.getInboundWebhookMappingRepository(), - getSavedObjectsClient: async (request) => { - const [coreStart] = await core.getStartServices(); - return coreStart.savedObjects.getScopedClient(request, { - includedHiddenTypes: [INBOUND_WEBHOOK_SAVED_OBJECT_TYPE], - excludedExtensions: [SECURITY_EXTENSION_ID], - }); - }, - getSpaceId: (request) => plugins.spaces.spacesService.getSpaceId(request), - takeRequest: (eventId) => this.inboundWebhookRequestStore.take(eventId), - }) - ); if (plugins.alerting) { plugins.alerting.registerConnectorAdapter(getWorkflowsConnectorAdapter()); @@ -157,24 +100,6 @@ export class WorkflowsPlugin const router = core.http.createRouter(); const audit = new WorkflowManagementAuditLog({ service: workflowsService }); - registerPostInboundWebhookRoute({ - router, - getActionsClient: async (request, spaceId) => { - const [, startPlugins] = await core.getStartServices(); - return startPlugins.actions.getActionsClientWithRequestInSpace(request, spaceId); - }, - getApiKeyService: () => this.getInboundWebhookApiKeyService(), - getMappingRepository: () => this.getInboundWebhookMappingRepository(), - getSpaceId: (request) => plugins.spaces.spacesService.getSpaceId(request), - logger: this.logger.get('inboundWebhookRoute'), - requestStore: this.inboundWebhookRequestStore, - }); - registerGetInboundWebhookStatusRoute({ - router, - getMappingRepository: () => this.getInboundWebhookMappingRepository(), - getSpaceId: (request) => plugins.spaces.spacesService.getSpaceId(request), - }); - defineRoutes({ router, config: this.config, @@ -200,23 +125,6 @@ export class WorkflowsPlugin public start(core: CoreStart, plugins: WorkflowsServerPluginStartDeps) { this.logger.debug('Workflows Management: Start'); - if (plugins.encryptedSavedObjects) { - this.inboundWebhookApiKeyService = new InboundWebhookApiKeyService( - core.security, - this.logger.get('inboundWebhookApiKey') - ); - this.inboundWebhookMappingRepository = new InboundWebhookMappingRepository( - core.savedObjects.createInternalRepository([INBOUND_WEBHOOK_SAVED_OBJECT_TYPE]), - plugins.encryptedSavedObjects.getClient({ - includedHiddenTypes: [INBOUND_WEBHOOK_SAVED_OBJECT_TYPE], - }), - (spaceId) => plugins.spaces.spacesService.spaceIdToNamespace(spaceId) - ); - void scheduleInboundWebhookCleanupTask(plugins.taskManager).catch((error) => { - this.logger.warn('Failed to schedule inbound webhook cleanup task', { error }); - }); - } - stepSchemas.initialize(plugins.workflowsExtensions); if (this.api) { @@ -257,18 +165,4 @@ export class WorkflowsPlugin public stop() { this.availabilityUpdater?.stop(); } - - private getInboundWebhookApiKeyService(): InboundWebhookApiKeyService { - if (!this.inboundWebhookApiKeyService) { - throw new Error('Inbound webhook API key service is unavailable'); - } - return this.inboundWebhookApiKeyService; - } - - private getInboundWebhookMappingRepository(): InboundWebhookMappingRepository { - if (!this.inboundWebhookMappingRepository) { - throw new Error('Inbound webhook mapping repository is unavailable'); - } - return this.inboundWebhookMappingRepository; - } } diff --git a/src/platform/plugins/shared/workflows_management/server/saved_objects/inbound_webhook.ts b/src/platform/plugins/shared/workflows_management/server/saved_objects/inbound_webhook.ts deleted file mode 100644 index ac0eb55637201..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/saved_objects/inbound_webhook.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { SavedObjectsType } from '@kbn/core/server'; - -export const INBOUND_WEBHOOK_SAVED_OBJECT_TYPE = 'workflow_inbound_webhook' as const; - -export interface InboundWebhookCredentialSecrets { - apiKey: string; - credentialVersion: number; - uiamApiKey?: string; - webhookUrl?: string; -} - -export interface InboundWebhookPayload { - connectorId: string; - connectorTypeId: '.workflows-inbound-webhook'; - status: 'pending' | 'active'; - targetWebhookKeyHash: string; - credentialRevision: string; - apiKeyId: string; - uiamApiKeyId?: string; - delegatedUsername?: string; - delegatedUserProfileId?: string; - createdAt: string; - updatedAt: string; - secrets: InboundWebhookCredentialSecrets; -} - -export interface InboundWebhookSavedObject { - payload: InboundWebhookPayload; -} - -export const inboundWebhookSavedObjectType: SavedObjectsType = { - name: INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - hidden: true, - namespaceType: 'multiple-isolated', - mappings: { - dynamic: false, - properties: { - payload: { type: 'binary' }, - }, - }, - management: { - importableAndExportable: false, - }, -}; diff --git a/src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_api_key_service.ts b/src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_api_key_service.ts deleted file mode 100644 index 6c94d5d7878a2..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_api_key_service.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { KibanaRequest, Logger, SecurityServiceStart } from '@kbn/core/server'; -import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; -import { HTTPAuthorizationHeader, isUiamCredential } from '@kbn/core-security-server'; -import type { - InboundWebhookCredentialSecrets, - InboundWebhookPayload, -} from '../saved_objects/inbound_webhook'; - -export type DelegatedWebhookCredentials = Pick< - InboundWebhookPayload, - 'apiKeyId' | 'uiamApiKeyId' | 'delegatedUsername' | 'delegatedUserProfileId' -> & { - secrets: Pick; -}; - -export class InboundWebhookApiKeyService { - constructor(private readonly security: SecurityServiceStart, private readonly logger: Logger) {} - - public async grant( - request: KibanaRequest, - connectorId: string - ): Promise { - const apiKeys = this.security.authc.apiKeys; - if (!(await apiKeys.areAPIKeysEnabled())) { - throw new Error('API keys are disabled'); - } - - const user = this.security.authc.getCurrentUser(request); - if (!user) { - throw new Error('Cannot create an inbound webhook without an authenticated user'); - } - - const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); - const isUiamRequest = authorizationHeader ? isUiamCredential(authorizationHeader) : false; - const keyName = `Workflows inbound webhook: ${connectorId}`; - - const esResult = - user.authentication_type === 'api_key' && !isUiamRequest - ? await apiKeys.cloneAsInternalUser(request, { name: keyName }) - : await apiKeys.grantAsInternalUser(request, { - name: keyName, - role_descriptors: {}, - }); - if (!esResult) { - throw new Error('Failed to create an API key for the inbound webhook'); - } - const encodedApiKey = - 'encoded' in esResult && typeof esResult.encoded === 'string' - ? esResult.encoded - : Buffer.from(`${esResult.id}:${esResult.api_key}`).toString('base64'); - - let uiamResult: { id: string; api_key: string } | null = null; - if (apiKeys.uiam && isUiamRequest) { - uiamResult = await apiKeys.uiam.grant(request, { name: keyName }); - } - - return { - apiKeyId: esResult.id, - ...(uiamResult ? { uiamApiKeyId: uiamResult.id } : {}), - delegatedUsername: user.username, - ...(user.profile_uid ? { delegatedUserProfileId: user.profile_uid } : {}), - secrets: { - apiKey: encodedApiKey, - ...(uiamResult - ? { uiamApiKey: Buffer.from(`${uiamResult.id}:${uiamResult.api_key}`).toString('base64') } - : {}), - }, - }; - } - - public getAuthorizationHeader(secrets: InboundWebhookCredentialSecrets): string { - if (secrets.uiamApiKey) { - const [, value] = Buffer.from(secrets.uiamApiKey, 'base64').toString().split(':'); - if (!value || !isUiamCredential(value)) { - throw new Error('Stored UIAM API key is invalid'); - } - return `ApiKey ${value}`; - } - return `ApiKey ${secrets.apiKey}`; - } - - public async invalidate(attributes: InboundWebhookPayload): Promise { - try { - await this.security.authc.apiKeys.invalidateAsInternalUser({ ids: [attributes.apiKeyId] }); - } catch (error) { - this.logger.warn(`Failed to invalidate inbound webhook API key ${attributes.apiKeyId}`, { - error, - }); - } - - if (!attributes.uiamApiKeyId || !attributes.secrets.uiamApiKey) { - return; - } - try { - const [, value] = Buffer.from(attributes.secrets.uiamApiKey, 'base64').toString().split(':'); - const request = kibanaRequestFactory({ - headers: { authorization: `ApiKey ${value}` }, - }); - await this.security.authc.apiKeys.uiam?.invalidate(request, { - id: attributes.uiamApiKeyId, - }); - } catch (error) { - this.logger.warn( - `Failed to invalidate inbound webhook UIAM API key ${attributes.uiamApiKeyId}`, - { error } - ); - } - } -} diff --git a/src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_request_store.ts b/src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_request_store.ts deleted file mode 100644 index 6c7751c15f2e0..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/services/inbound_webhook_request_store.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { KibanaRequest } from '@kbn/core/server'; - -/** - * Bridges the request-scoped Actions call to a user-created connector executor. - * Actions only exposes `request` to system connector executors, so the inbound - * route keeps the synthetic request in memory for the duration of the - * synchronous connector execution. - */ -export class InboundWebhookRequestStore { - private readonly requests = new Map(); - - public set(eventId: string, request: KibanaRequest): void { - this.requests.set(eventId, request); - } - - public take(eventId: string): KibanaRequest | undefined { - const request = this.requests.get(eventId); - this.requests.delete(eventId); - return request; - } - - public delete(eventId: string): void { - this.requests.delete(eventId); - } -} diff --git a/src/platform/plugins/shared/workflows_management/server/storage/inbound_webhook_mapping_repository.ts b/src/platform/plugins/shared/workflows_management/server/storage/inbound_webhook_mapping_repository.ts deleted file mode 100644 index 18b41484f4d8a..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/storage/inbound_webhook_mapping_repository.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { - ISavedObjectsRepository, - SavedObject, - SavedObjectsClientContract, -} from '@kbn/core/server'; -import { SavedObjectsErrorHelpers } from '@kbn/core/server'; -import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; -import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; -import type { - InboundWebhookPayload, - InboundWebhookSavedObject, -} from '../saved_objects/inbound_webhook'; -import { INBOUND_WEBHOOK_SAVED_OBJECT_TYPE } from '../saved_objects/inbound_webhook'; - -export interface StageInboundWebhookParams { - connectorId: string; - credentialRevision: string; - webhookKeyHash: string; - spaceId: string; - attributes: Omit< - InboundWebhookPayload, - | 'connectorId' - | 'connectorTypeId' - | 'status' - | 'targetWebhookKeyHash' - | 'credentialRevision' - | 'createdAt' - | 'updatedAt' - >; -} - -interface PromoteResult { - active: SavedObject; - previous?: SavedObject; -} - -export class InboundWebhookMappingRepository { - constructor( - private readonly savedObjectsRepository: ISavedObjectsRepository, - private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient, - private readonly spaceIdToNamespace: (spaceId: string) => string | undefined - ) {} - - public async stage( - params: StageInboundWebhookParams, - savedObjectsClient: SavedObjectsClientContract - ): Promise { - const pendingId = this.getPendingId(params.connectorId, params.credentialRevision); - const now = new Date().toISOString(); - await savedObjectsClient.create( - INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - { - payload: { - ...params.attributes, - connectorId: params.connectorId, - connectorTypeId: '.workflows-inbound-webhook', - status: 'pending', - targetWebhookKeyHash: params.webhookKeyHash, - credentialRevision: params.credentialRevision, - createdAt: now, - updatedAt: now, - }, - }, - { - id: pendingId, - overwrite: true, - } - ); - return pendingId; - } - - public async promote({ - pendingId, - spaceId, - savedObjectsClient, - }: { - pendingId: string; - spaceId: string; - savedObjectsClient: SavedObjectsClientContract; - }): Promise { - const namespace = this.spaceIdToNamespace(spaceId); - const pending = - await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( - INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - pendingId, - { namespace } - ); - const activeId = pending.attributes.payload.targetWebhookKeyHash; - const previous = await this.resolve(activeId, spaceId); - const active = await savedObjectsClient.create( - INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - { - payload: { - ...pending.attributes.payload, - status: 'active', - updatedAt: new Date().toISOString(), - }, - }, - { id: activeId, overwrite: true } - ); - await savedObjectsClient.delete(INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, pendingId); - return { active, previous }; - } - - public async resolve( - webhookKeyHash: string, - spaceId: string - ): Promise | undefined> { - try { - return await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( - INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - webhookKeyHash, - { namespace: this.spaceIdToNamespace(spaceId) } - ); - } catch (error) { - if (SavedObjectsErrorHelpers.isNotFoundError(error)) { - return undefined; - } - throw error; - } - } - - public async deletePending(pendingId: string, spaceId: string): Promise { - await this.savedObjectsRepository.delete(INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, pendingId, { - namespace: this.spaceIdToNamespace(spaceId), - force: true, - }); - } - - public async getForConnector( - connectorId: string, - spaceId: string - ): Promise>> { - const namespace = this.spaceIdToNamespace(spaceId); - const finder = - await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser( - { - type: INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - namespaces: [namespace ?? DEFAULT_NAMESPACE_STRING], - perPage: 100, - } - ); - const results: Array> = []; - for await (const page of finder.find()) { - results.push(...page.saved_objects); - } - await finder.close(); - return results.filter(({ attributes }) => attributes.payload.connectorId === connectorId); - } - - public async deleteForConnector(connectorId: string, spaceId: string): Promise { - const namespace = this.spaceIdToNamespace(spaceId); - const savedObjects = await this.getForConnector(connectorId, spaceId); - await Promise.all( - savedObjects.map(({ id }) => - this.savedObjectsRepository.delete(INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, id, { - namespace, - force: true, - }) - ) - ); - } - - public async getPendingBefore( - timestamp: string - ): Promise>> { - const finder = - await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser( - { - type: INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, - namespaces: ['*'], - perPage: 100, - } - ); - const results: Array> = []; - for await (const page of finder.find()) { - results.push(...page.saved_objects); - } - await finder.close(); - return results.filter( - ({ attributes }) => - attributes.payload.status === 'pending' && attributes.payload.updatedAt < timestamp - ); - } - - public async deleteSavedObject(savedObject: SavedObject): Promise { - const namespace = savedObject.namespaces?.[0]; - await this.savedObjectsRepository.delete(INBOUND_WEBHOOK_SAVED_OBJECT_TYPE, savedObject.id, { - namespace: namespace === DEFAULT_NAMESPACE_STRING ? undefined : namespace, - force: true, - }); - } - - private getPendingId(connectorId: string, credentialRevision: string): string { - return `pending:${connectorId}:${credentialRevision}`; - } -} diff --git a/src/platform/plugins/shared/workflows_management/server/tasks/inbound_webhook_cleanup_task.ts b/src/platform/plugins/shared/workflows_management/server/tasks/inbound_webhook_cleanup_task.ts deleted file mode 100644 index 965bcf5a41517..0000000000000 --- a/src/platform/plugins/shared/workflows_management/server/tasks/inbound_webhook_cleanup_task.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { - TaskManagerSetupContract, - TaskManagerStartContract, -} from '@kbn/task-manager-plugin/server'; -import { TaskCost, TaskPriority } from '@kbn/task-manager-plugin/server'; -import type { InboundWebhookApiKeyService } from '../services/inbound_webhook_api_key_service'; -import type { InboundWebhookMappingRepository } from '../storage/inbound_webhook_mapping_repository'; - -export const INBOUND_WEBHOOK_CLEANUP_TASK_TYPE = 'workflows:inbound-webhook-cleanup'; -const INBOUND_WEBHOOK_CLEANUP_TASK_ID = 'workflows:inbound-webhook-cleanup'; -const PENDING_MAX_AGE_MS = 60 * 60 * 1000; - -export const registerInboundWebhookCleanupTask = ({ - taskManager, - getApiKeyService, - getMappingRepository, -}: { - taskManager: TaskManagerSetupContract; - getApiKeyService: () => InboundWebhookApiKeyService; - getMappingRepository: () => InboundWebhookMappingRepository; -}): void => { - taskManager.registerTaskDefinitions({ - [INBOUND_WEBHOOK_CLEANUP_TASK_TYPE]: { - title: 'Clean up inbound webhook credentials', - description: 'Invalidates credentials abandoned by interrupted connector saves.', - timeout: '2m', - maxAttempts: 1, - cost: TaskCost.Tiny, - priority: TaskPriority.Low, - createTaskRunner: ({ abortController }) => ({ - run: async () => { - const pending = await getMappingRepository().getPendingBefore( - new Date(Date.now() - PENDING_MAX_AGE_MS).toISOString() - ); - for (const savedObject of pending) { - if (abortController.signal.aborted) { - break; - } - await getApiKeyService().invalidate(savedObject.attributes.payload); - await getMappingRepository().deleteSavedObject(savedObject); - } - return { state: {} }; - }, - }), - }, - }); -}; - -export const scheduleInboundWebhookCleanupTask = async ( - taskManager: TaskManagerStartContract -): Promise => { - await taskManager.ensureScheduled({ - id: INBOUND_WEBHOOK_CLEANUP_TASK_ID, - taskType: INBOUND_WEBHOOK_CLEANUP_TASK_TYPE, - schedule: { interval: '1h' }, - params: {}, - state: {}, - }); -}; diff --git a/tsconfig.base.json b/tsconfig.base.json index b11269e4dbfed..3322b0008acc2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -323,6 +323,8 @@ "@kbn/config-schema/*": ["./src/platform/packages/shared/kbn-config-schema/*"], "@kbn/connector-cli": ["./src/platform/packages/shared/kbn-connector-cli"], "@kbn/connector-cli/*": ["./src/platform/packages/shared/kbn-connector-cli/*"], + "@kbn/connector-events-bridge-plugin": ["./x-pack/platform/plugins/shared/connector_events_bridge"], + "@kbn/connector-events-bridge-plugin/*": ["./x-pack/platform/plugins/shared/connector_events_bridge/*"], "@kbn/connector-schemas": ["./src/platform/packages/shared/kbn-connector-schemas"], "@kbn/connector-schemas/*": ["./src/platform/packages/shared/kbn-connector-schemas/*"], "@kbn/connector-specs": ["./src/platform/packages/shared/kbn-connector-specs"], diff --git a/x-pack/platform/plugins/shared/actions/kibana.jsonc b/x-pack/platform/plugins/shared/actions/kibana.jsonc index 1957133372a91..fe5c944627627 100644 --- a/x-pack/platform/plugins/shared/actions/kibana.jsonc +++ b/x-pack/platform/plugins/shared/actions/kibana.jsonc @@ -28,7 +28,7 @@ "monitoringCollection", "serverless", "cloud", - "usageApi" + "usageApi", ], "extraPublicDirs": [ "common" diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts index 29e3445a6a279..1d890f376c19e 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts @@ -549,6 +549,10 @@ describe('create()', () => { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }); const localActionTypeRegistryParams = { diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts index 1fcdc0c650607..08db237f4df60 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts @@ -51,6 +51,10 @@ const defaultActionsConfig: ActionsConfig = { }, ears: { enabled: false, enableExperimental: false }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }; describe('ensureUriAllowed', () => { diff --git a/x-pack/platform/plugins/shared/actions/server/config.ts b/x-pack/platform/plugins/shared/actions/server/config.ts index 07e71769fe515..4f5429eb4cc16 100644 --- a/x-pack/platform/plugins/shared/actions/server/config.ts +++ b/x-pack/platform/plugins/shared/actions/server/config.ts @@ -248,6 +248,10 @@ export const configSchema = schema.object({ }) ), }), + inboundConnectors: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + maxBodyBytes: schema.number({ defaultValue: 1024 * 1024 }), + }), }); export type ActionsConfig = TypeOf; diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.test.ts b/x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.test.ts new file mode 100644 index 0000000000000..f89adf5162653 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +import { dispatchConnectorEvents } from './dispatch_connector_events'; + +describe('dispatchConnectorEvents', () => { + it('calls all registered emitters', async () => { + const emit = jest.fn().mockResolvedValue(undefined); + const params = { + eventId: 'inboundWebhook.received', + payload: { connectorId: 'c1' }, + spaceId: 'default', + connectorId: 'c1', + connectorTypeId: '.inboundWebhook', + }; + + await dispatchConnectorEvents({ + emitters: [{ emit }], + params, + logger: loggingSystemMock.create().get(), + }); + + expect(emit).toHaveBeenCalledWith(params); + }); + + it('warns when no emitters are registered', async () => { + const logger = loggingSystemMock.create().get(); + + await dispatchConnectorEvents({ + emitters: [], + params: { + eventId: 'inboundWebhook.received', + payload: {}, + spaceId: 'default', + connectorId: 'c1', + connectorTypeId: '.inboundWebhook', + }, + logger, + }); + + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.ts b/x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.ts new file mode 100644 index 0000000000000..7b4274523c883 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/dispatch_connector_events.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; + +import type { ConnectorEventEmitParams, ConnectorEventEmitter } from '../types'; + +export async function dispatchConnectorEvents({ + emitters, + params, + logger, +}: { + emitters: ConnectorEventEmitter[]; + params: ConnectorEventEmitParams; + logger: Logger; +}): Promise { + if (emitters.length === 0) { + logger.warn( + `No connector event emitters registered; dropping event ${params.eventId} for connector ${params.connectorId}` + ); + return; + } + + await Promise.all( + emitters.map(async (emitter) => { + try { + await emitter.emit(params); + } catch (error) { + logger.warn( + `Connector event emitter failed for event ${params.eventId} connector ${ + params.connectorId + }: ${error instanceof Error ? error.message : String(error)}` + ); + } + }) + ); +} diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts b/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts new file mode 100644 index 0000000000000..b7cf4eaee15d1 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { getConnectorSpec, normalizeConnectorTypeId } from '@kbn/connector-specs'; + +import type { ActionsConfig } from '../config'; +import type { ActionTypeRegistry, ConnectorEventEmitParams, InMemoryConnector } from '../types'; +import { loadInboundConnector } from './load_inbound_connector'; +import { extractIngestToken, verifyIngestToken } from './verify_ingress_auth'; + +export interface HandleInboundRequestParams { + request: import('@kbn/core/server').KibanaRequest; + response: import('@kbn/core/server').KibanaResponseFactory; + typeId: string; + connectorId: string; + inboundConnectorsConfig: ActionsConfig['inboundConnectors']; + emitConnectorEvents: (params: ConnectorEventEmitParams) => Promise; + logger: Logger; + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + inMemoryConnectors: InMemoryConnector[]; + actionTypeRegistry: ActionTypeRegistry; + isESOCanEncrypt: boolean; + getSpaceId: (request: import('@kbn/core/server').KibanaRequest) => string; +} + +export async function handleInboundRequest({ + request, + response, + typeId, + connectorId, + inboundConnectorsConfig, + emitConnectorEvents, + logger, + encryptedSavedObjectsClient, + unsecuredSavedObjectsClient, + inMemoryConnectors, + actionTypeRegistry, + isESOCanEncrypt, + getSpaceId, +}: HandleInboundRequestParams) { + if (!inboundConnectorsConfig.enabled) { + return response.forbidden({ body: 'Inbound connector events are disabled' }); + } + + const connectorTypeId = normalizeConnectorTypeId(typeId); + const spec = getConnectorSpec(connectorTypeId); + if (!spec?.events) { + return response.notFound(); + } + + const spaceId = getSpaceId(request); + const connector = await loadInboundConnector({ + connectorId, + connectorTypeId, + spaceId, + encryptedSavedObjectsClient, + unsecuredSavedObjectsClient, + inMemoryConnectors, + actionTypeRegistry, + isESOCanEncrypt, + logger, + }); + if (!connector) { + return response.notFound(); + } + + const ingestTokenHash = + typeof connector.config.ingestTokenHash === 'string' + ? connector.config.ingestTokenHash + : undefined; + if (typeof ingestTokenHash !== 'string' || ingestTokenHash.length === 0) { + return response.notFound(); + } + + const providedToken = extractIngestToken({ + query: request.query as Record, + headers: request.headers, + }); + if ( + !providedToken || + !verifyIngestToken({ + connectorId, + spaceId, + providedToken, + ingestTokenHash, + }) + ) { + return response.notFound(); + } + + const headersForHandler = { + ...request.headers, + 'x-inbound-query': JSON.stringify(request.query ?? {}), + }; + + const result = await spec.events.handleEvents({ + connectorId, + connectorTypeId, + spaceId, + config: connector.config, + secrets: connector.secrets, + rawBody: request.body, + headers: headersForHandler, + log: logger, + }); + + if (result.httpResponse) { + const { status, body, headers } = result.httpResponse; + return response.custom({ + statusCode: status, + body: body as Parameters[0]['body'], + headers, + }); + } + + for (const event of result.events ?? []) { + await emitConnectorEvents({ + eventId: event.eventId, + payload: { + ...event.payload, + connectorId, + connectorTypeId, + correlationKey: event.correlationKey, + }, + spaceId, + connectorId, + connectorTypeId, + }); + } + + return response.accepted({ body: { ok: true } }); +} diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts b/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts new file mode 100644 index 0000000000000..b4a40a42a57cc --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { getConnectorSpec, normalizeConnectorTypeId } from '@kbn/connector-specs'; + +import type { ActionTypeRegistry, InMemoryConnector, RawAction } from '../types'; +import { ACTION_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; +import { validateSecrets } from '../lib'; + +export interface LoadedInboundConnector { + connectorId: string; + connectorTypeId: string; + spaceId: string; + config: Record; + secrets: Record; +} + +export async function loadInboundConnector({ + connectorId, + connectorTypeId, + spaceId, + encryptedSavedObjectsClient, + unsecuredSavedObjectsClient, + inMemoryConnectors, + actionTypeRegistry, + isESOCanEncrypt, + logger, +}: { + connectorId: string; + connectorTypeId: string; + spaceId: string; + encryptedSavedObjectsClient?: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + inMemoryConnectors: InMemoryConnector[]; + actionTypeRegistry: ActionTypeRegistry; + isESOCanEncrypt: boolean; + logger: Logger; +}): Promise { + const normalizedTypeId = normalizeConnectorTypeId(connectorTypeId); + const spec = getConnectorSpec(normalizedTypeId); + if (!spec?.events) { + return undefined; + } + + const inMemoryConnector = inMemoryConnectors.find((connector) => connector.id === connectorId); + let actionTypeId: string | undefined; + let config: Record = {}; + let secrets: Record = {}; + + if (inMemoryConnector) { + actionTypeId = inMemoryConnector.actionTypeId; + config = inMemoryConnector.config ?? {}; + secrets = inMemoryConnector.secrets ?? {}; + } else { + try { + const { attributes } = await unsecuredSavedObjectsClient.get( + ACTION_SAVED_OBJECT_TYPE, + connectorId + ); + actionTypeId = attributes.actionTypeId; + config = attributes.config ?? {}; + + if (!isESOCanEncrypt || !encryptedSavedObjectsClient) { + logger.warn('Inbound connector ingress requires encrypted saved objects'); + return undefined; + } + + const decrypted = await encryptedSavedObjectsClient.getDecryptedAsInternalUser( + ACTION_SAVED_OBJECT_TYPE, + connectorId, + spaceId !== 'default' ? { namespace: spaceId } : {} + ); + secrets = decrypted.attributes.secrets ?? {}; + } catch (error) { + logger.debug(`Failed to load inbound connector ${connectorId}: ${String(error)}`); + return undefined; + } + } + + if (actionTypeId !== normalizedTypeId) { + return undefined; + } + + const actionType = actionTypeRegistry.get(actionTypeId); + const configurationUtilities = actionTypeRegistry.getUtils(); + const validatedSecrets = validateSecrets(actionType, secrets, { configurationUtilities }); + + return { + connectorId, + connectorTypeId: normalizedTypeId, + spaceId, + config, + secrets: validatedSecrets, + }; +} diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts b/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts new file mode 100644 index 0000000000000..bf7b7188990bc --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; +import { asSpaceId } from '@kbn/core-spaces-common'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { + CoreSetup, + IRouter, + KibanaRequest, + Logger, + SavedObjectsClientContract, +} from '@kbn/core/server'; + +import type { ActionsConfig } from '../config'; +import type { + ActionsRequestHandlerContext, + ActionTypeRegistry, + ConnectorEventEmitParams, + InMemoryConnector, +} from '../types'; +import type { ActionsPluginsStart } from '../plugin'; +import { handleInboundRequest } from './handle_inbound_request'; + +export interface InboundConnectorLoaderDeps { + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + inMemoryConnectors: InMemoryConnector[]; + actionTypeRegistry: ActionTypeRegistry; + isESOCanEncrypt: boolean; + getSpaceId: (request: KibanaRequest) => string; +} + +export interface RegisterInboundRoutesParams { + router: IRouter; + inboundConnectorsConfig: ActionsConfig['inboundConnectors']; + logger: Logger; + emitConnectorEvents: (params: ConnectorEventEmitParams) => Promise; + loaderDeps: Omit< + InboundConnectorLoaderDeps, + 'encryptedSavedObjectsClient' | 'unsecuredSavedObjectsClient' + > & { + getStartServices: CoreSetup['getStartServices']; + }; +} + +export const INBOUND_CONNECTOR_EVENTS_SECURITY = { + authc: { + enabled: false, + reason: 'Inbound connector events authenticate with connector-scoped ingest tokens.', + }, + authz: { + enabled: false, + reason: 'Authorization is delegated to connector ingress token verification.', + }, +} as const; + +export function registerInboundRoutes({ + router, + inboundConnectorsConfig, + logger, + emitConnectorEvents, + loaderDeps, +}: RegisterInboundRoutesParams): void { + router.post( + { + path: '/api/events/v1/{typeId}/{connectorId}', + security: INBOUND_CONNECTOR_EVENTS_SECURITY, + options: { + access: 'public', + xsrfRequired: false, + tags: ['api'], + body: { + accepts: ['application/json', 'application/*+json', '*/*'], + maxBytes: inboundConnectorsConfig.maxBodyBytes, + }, + }, + validate: { + params: schema.object({ + typeId: schema.string({ minLength: 1 }), + connectorId: schema.string({ minLength: 1 }), + }), + query: schema.recordOf( + schema.string(), + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), + body: schema.maybe(schema.any()), + }, + }, + async (_context, request, response) => { + const [coreStart, startPlugins] = await loaderDeps.getStartServices(); + const spaceId = loaderDeps.getSpaceId(request); + const internalRequest = kibanaRequestFactory({ + headers: {}, + spaceId: asSpaceId(spaceId), + }); + const unsecuredSavedObjectsClient = coreStart.savedObjects.getScopedClient(internalRequest); + const encryptedSavedObjectsClient = startPlugins.encryptedSavedObjects.getClient({ + includedHiddenTypes: ['action'], + }); + + return handleInboundRequest({ + request, + response, + typeId: request.params.typeId, + connectorId: request.params.connectorId, + inboundConnectorsConfig, + emitConnectorEvents, + logger, + getSpaceId: loaderDeps.getSpaceId, + encryptedSavedObjectsClient, + unsecuredSavedObjectsClient, + inMemoryConnectors: loaderDeps.inMemoryConnectors, + actionTypeRegistry: loaderDeps.actionTypeRegistry, + isESOCanEncrypt: loaderDeps.isESOCanEncrypt, + }); + } + ); +} diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts new file mode 100644 index 0000000000000..53f30457369c8 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { computeIngestTokenHash } from '@kbn/connector-specs'; + +import { extractIngestToken, verifyIngestToken } from './verify_ingress_auth'; + +describe('verifyIngestToken', () => { + it('accepts matching query token', () => { + const token = 'a'.repeat(64); + const hash = computeIngestTokenHash({ + connectorId: 'connector-1', + spaceId: 'default', + token, + }); + + expect( + verifyIngestToken({ + connectorId: 'connector-1', + spaceId: 'default', + providedToken: token, + ingestTokenHash: hash, + }) + ).toBe(true); + }); + + it('rejects invalid token', () => { + expect( + verifyIngestToken({ + connectorId: 'connector-1', + spaceId: 'default', + providedToken: 'wrong-token', + ingestTokenHash: computeIngestTokenHash({ + connectorId: 'connector-1', + spaceId: 'default', + token: 'expected-token', + }), + }) + ).toBe(false); + }); + + it('extracts bearer authorization token', () => { + expect( + extractIngestToken({ + query: {}, + headers: { authorization: 'Bearer my-token' }, + }) + ).toBe('my-token'); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts new file mode 100644 index 0000000000000..b31bac12db4c9 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { timingSafeEqual } from 'node:crypto'; + +import { computeIngestTokenHash } from '@kbn/connector-specs'; + +export const extractIngestToken = ({ + query, + headers, +}: { + query: Record; + headers: Record; +}): string | undefined => { + const queryToken = query.token; + if (typeof queryToken === 'string' && queryToken.length > 0) { + return queryToken; + } + if (Array.isArray(queryToken) && typeof queryToken[0] === 'string' && queryToken[0].length > 0) { + return queryToken[0]; + } + + const authorization = headers.authorization; + const headerValue = Array.isArray(authorization) ? authorization[0] : authorization; + if (typeof headerValue !== 'string') { + return undefined; + } + const bearerMatch = /^Bearer\s+(.+)$/i.exec(headerValue.trim()); + return bearerMatch?.[1]; +}; + +export const verifyIngestToken = ({ + connectorId, + spaceId, + providedToken, + ingestTokenHash, +}: { + connectorId: string; + spaceId: string; + providedToken: string; + ingestTokenHash: string; +}): boolean => { + const expectedHash = computeIngestTokenHash({ connectorId, spaceId, token: providedToken }); + if (expectedHash.length !== ingestTokenHash.length) { + return false; + } + return timingSafeEqual(Buffer.from(expectedHash), Buffer.from(ingestTokenHash)); +}; diff --git a/x-pack/platform/plugins/shared/actions/server/index.ts b/x-pack/platform/plugins/shared/actions/server/index.ts index 160002ca97529..2dc705ab4c635 100644 --- a/x-pack/platform/plugins/shared/actions/server/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/index.ts @@ -27,6 +27,8 @@ export type { ConnectorLifecycleListener, ConnectorLifecyclePostCreateParams, ConnectorLifecyclePostDeleteParams, + ConnectorEventEmitter, + ConnectorEventEmitParams, } from './types'; export type { diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts index e06d4c1e3d48f..2d93e0e2ae429 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts @@ -724,6 +724,10 @@ const BaseActionsConfig: ActionsConfig = { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts index e3695c7a81a2c..2f6abdb389f2f 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts @@ -604,6 +604,10 @@ const BaseActionsConfig: ActionsConfig = { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts index 19c9ce9f20fbb..f2c4f94e436fb 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts @@ -803,6 +803,10 @@ const BaseActionsConfig: ActionsConfig = { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts index a8d317794af0b..a0b5c5c19a04c 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts @@ -88,6 +88,10 @@ describe('custom_host_settings', () => { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }; test('ensure it copies over the config parts that it does not touch', () => { diff --git a/x-pack/platform/plugins/shared/actions/server/mocks.ts b/x-pack/platform/plugins/shared/actions/server/mocks.ts index 390813767dd88..39009db8cca78 100644 --- a/x-pack/platform/plugins/shared/actions/server/mocks.ts +++ b/x-pack/platform/plugins/shared/actions/server/mocks.ts @@ -45,6 +45,7 @@ const createSetupMock = () => { setEnabledConnectorTypes: jest.fn(), isActionTypeEnabled: jest.fn(), registerConnectorLifecycleListener: jest.fn(), + registerConnectorEventEmitter: jest.fn(), }); return mock; }; diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts index 4b0a8b8c531a6..21147e905eae7 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts @@ -73,6 +73,10 @@ function getConfig(overrides = {}) { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, ...overrides, }; } @@ -131,6 +135,10 @@ describe('Actions Plugin', () => { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); @@ -543,6 +551,10 @@ describe('Actions Plugin', () => { }, }, }, + inboundConnectors: { + enabled: true, + maxBodyBytes: 1024 * 1024, + }, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.ts b/x-pack/platform/plugins/shared/actions/server/plugin.ts index 623a34a8afae9..479dc6263091b 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.ts @@ -67,12 +67,15 @@ import type { ActionsRequestHandlerContext, UnsecuredServices, ConnectorLifecycleListener, + ConnectorEventEmitter, } from './types'; import type { ActionsConfigurationUtilities } from './actions_config'; import { getActionsConfigurationUtilities } from './actions_config'; import { defineRoutes } from './routes'; +import { registerInboundRoutes } from './inbound/register_inbound_routes'; +import { dispatchConnectorEvents } from './inbound/dispatch_connector_events'; import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task'; import { initializeOAuthStateCleanupTask, @@ -156,6 +159,9 @@ export interface PluginSetupContract { isActionTypeEnabled(id: string, options?: { notifyUsage: boolean }): boolean; registerConnectorLifecycleListener(listener: ConnectorLifecycleListener): void; + + /** Register a sink for connector events emitted by the inbound hub. */ + registerConnectorEventEmitter(emitter: ConnectorEventEmitter): void; } export interface PluginStartContract { @@ -271,6 +277,7 @@ export class ActionsPlugin private inMemoryMetrics: InMemoryMetrics; private connectorUsageReportingTask: ConnectorUsageReportingTask | undefined; private connectorLifecycleListeners: ConnectorLifecycleListener[] = []; + private connectorEventEmitters: ConnectorEventEmitter[] = []; private skippedPreconfiguredConnectorIds: Set = new Set(); constructor(initContext: PluginInitializerContext) { @@ -458,6 +465,25 @@ export class ActionsPlugin oauthRateLimiter, }); + registerInboundRoutes({ + router: core.http.createRouter(), + inboundConnectorsConfig: this.actionsConfig.inboundConnectors, + logger: this.logger.get('inboundConnectors'), + loaderDeps: { + getStartServices: core.getStartServices, + getSpaceId: (request) => plugins.spaces?.spacesService.getSpaceId(request) ?? 'default', + inMemoryConnectors: this.inMemoryConnectors, + actionTypeRegistry, + isESOCanEncrypt: Boolean(this.isESOCanEncrypt), + }, + emitConnectorEvents: (params) => + dispatchConnectorEvents({ + emitters: this.connectorEventEmitters, + params, + logger: this.logger.get('inboundConnectors'), + }), + }); + return { registerType: < Config extends ActionTypeConfig = ActionTypeConfig, @@ -516,6 +542,9 @@ export class ActionsPlugin registerConnectorLifecycleListener: (listener: ConnectorLifecycleListener) => { this.connectorLifecycleListeners.push(listener); }, + registerConnectorEventEmitter: (emitter: ConnectorEventEmitter) => { + this.connectorEventEmitters.push(emitter); + }, }; } diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index 72988e7bdd2e3..6c117bf1eaf5b 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -37,6 +37,7 @@ export type WithoutQueryAndParams = Pick Services; export type GetUnsecuredServicesFunction = () => UnsecuredServices; export type ActionTypeRegistryContract = PublicMethodsOf; +export type { ActionTypeRegistry }; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; export type ActionTypeConfig = Record; export type ActionTypeSecrets = Record; @@ -212,6 +213,23 @@ export interface ConnectorLifecycleListener { onPostDelete?: (params: ConnectorLifecyclePostDeleteParams) => Promise; } +/** Params passed to connector event emitters after inbound hub ingress handling. */ +export interface ConnectorEventEmitParams { + eventId: string; + payload: Record; + spaceId: string; + connectorId: string; + connectorTypeId: string; +} + +/** + * Sink for connector events produced by the inbound hub. + * Registered by bridge plugins (workflows today, event bus later). + */ +export interface ConnectorEventEmitter { + emit(params: ConnectorEventEmitParams): Promise; +} + export type ActionType< Config extends ActionTypeConfig = ActionTypeConfig, Secrets extends ActionTypeSecrets = ActionTypeSecrets, diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/jest.config.js b/x-pack/platform/plugins/shared/connector_events_bridge/jest.config.js new file mode 100644 index 0000000000000..91f7ba2ce6fb4 --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/x-pack/platform/plugins/shared/connector_events_bridge/server'], +}; diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/kibana.jsonc b/x-pack/platform/plugins/shared/connector_events_bridge/kibana.jsonc new file mode 100644 index 0000000000000..991d8b44f71d1 --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/kibana.jsonc @@ -0,0 +1,15 @@ +{ + "type": "plugin", + "id": "@kbn/connector-events-bridge-plugin", + "owner": ["@elastic/workflows-eng"], + "group": "platform", + "visibility": "shared", + "description": "Bridges connector ingress events from the Actions hub to workflow triggers (and later to the integration event bus).", + "plugin": { + "id": "connectorEventsBridge", + "browser": false, + "server": true, + "configPath": ["xpack", "connectorEventsBridge"], + "requiredPlugins": ["actions", "workflowsExtensions"] + } +} diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/server/index.ts b/x-pack/platform/plugins/shared/connector_events_bridge/server/index.ts new file mode 100644 index 0000000000000..cf0e5e017d7ba --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/server/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginInitializerContext } from '@kbn/core/server'; + +export async function plugin(initializerContext: PluginInitializerContext) { + const { ConnectorEventsBridgePlugin } = await import('./plugin'); + return new ConnectorEventsBridgePlugin(initializerContext); +} diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/server/plugin.ts b/x-pack/platform/plugins/shared/connector_events_bridge/server/plugin.ts new file mode 100644 index 0000000000000..7568ee95301f9 --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/server/plugin.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; +import type { WorkflowsExtensionsServerPluginStart } from '@kbn/workflows-extensions/server'; + +import { registerWorkflowsConnectorEventEmitter } from './register_workflows_connector_event_emitter'; + +export interface ConnectorEventsBridgeSetupDeps { + actions: ActionsPluginSetupContract; +} + +export interface ConnectorEventsBridgeStartDeps { + workflowsExtensions: WorkflowsExtensionsServerPluginStart; +} + +export class ConnectorEventsBridgePlugin + implements Plugin<{}, {}, ConnectorEventsBridgeSetupDeps, ConnectorEventsBridgeStartDeps> +{ + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + setup( + core: CoreSetup, + { actions }: ConnectorEventsBridgeSetupDeps + ) { + registerWorkflowsConnectorEventEmitter({ + actions, + getWorkflowsExtensionsStart: async () => { + const [, startPlugins] = await core.getStartServices(); + return startPlugins.workflowsExtensions; + }, + logger: this.logger.get('workflowsEmitter'), + }); + + return {}; + } + + start() { + return {}; + } +} diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts new file mode 100644 index 0000000000000..c613edf74bd69 --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; + +import { registerWorkflowsConnectorEventEmitter } from './register_workflows_connector_event_emitter'; + +describe('registerWorkflowsConnectorEventEmitter', () => { + it('registers an emitter that forwards to workflows emitEvent', async () => { + const actions = actionsMock.createSetup(); + const emitEvent = jest.fn().mockResolvedValue(undefined); + const getClient = jest.fn().mockResolvedValue({ emitEvent }); + + registerWorkflowsConnectorEventEmitter({ + actions, + getWorkflowsExtensionsStart: async () => ({ getClient } as never), + logger: loggingSystemMock.create().get(), + }); + + expect(actions.registerConnectorEventEmitter).toHaveBeenCalledTimes(1); + const emitter = actions.registerConnectorEventEmitter.mock.calls[0][0]; + + await emitter.emit({ + eventId: 'inboundWebhook.received', + payload: { connectorId: 'c1', body: {} }, + spaceId: 'default', + connectorId: 'c1', + connectorTypeId: '.inboundWebhook', + }); + + expect(getClient).toHaveBeenCalled(); + expect(emitEvent).toHaveBeenCalledWith('inboundWebhook.received', { + connectorId: 'c1', + body: {}, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts new file mode 100644 index 0000000000000..0acbf4feee5cf --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Logger } from '@kbn/core/server'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; +import { asSpaceId } from '@kbn/core-spaces-common'; +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; +import type { ConnectorEventEmitter } from '@kbn/actions-plugin/server'; +import type { WorkflowsExtensionsServerPluginStart } from '@kbn/workflows-extensions/server'; + +export function registerWorkflowsConnectorEventEmitter({ + actions, + getWorkflowsExtensionsStart, + logger, +}: { + actions: ActionsPluginSetupContract; + getWorkflowsExtensionsStart: () => Promise; + logger: Logger; +}): void { + const emitter: ConnectorEventEmitter = { + emit: async ({ eventId, payload, spaceId }) => { + const workflowsExtensions = await getWorkflowsExtensionsStart(); + if (!workflowsExtensions) { + logger.warn( + `Workflows extensions unavailable; skipping connector event emit for ${eventId}` + ); + return; + } + + const request = kibanaRequestFactory({ + headers: {}, + spaceId: asSpaceId(spaceId), + }); + + const workflowsClient = await workflowsExtensions.getClient(request); + await workflowsClient.emitEvent(eventId, payload); + }, + }; + + actions.registerConnectorEventEmitter(emitter); +} diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/tsconfig.json b/x-pack/platform/plugins/shared/connector_events_bridge/tsconfig.json new file mode 100644 index 0000000000000..e4689e50501e0 --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["../../../../../typings/**/*", "server/**/*"], + "exclude": ["target/**/*"], + "kbn_references": ["@kbn/core", "@kbn/actions-plugin", "@kbn/workflows-extensions"] +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/kibana.jsonc b/x-pack/platform/plugins/shared/stack_connectors/kibana.jsonc index 6d730466657cc..daeff2f55daac 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/kibana.jsonc +++ b/x-pack/platform/plugins/shared/stack_connectors/kibana.jsonc @@ -18,7 +18,7 @@ "licensing", "spaces" ], - "optionalPlugins": ["usageCollection", "cloud"], + "optionalPlugins": ["usageCollection", "cloud", "workflowsExtensions"], "extraPublicDirs": ["public/common"] } } diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx new file mode 100644 index 0000000000000..faaee08c91548 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { + ActionTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; + +export const getInboundWebhookConnectorType = (): ActionTypeModel< + Record, + Record, + Record +> => ({ + id: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + iconClass: 'link', + selectMessage: i18n.translate('stackConnectors.inboundWebhook.connectorSelectDescription', { + defaultMessage: 'Receive HTTP requests from external systems and trigger workflows.', + }), + actionTypeTitle: i18n.translate('stackConnectors.inboundWebhook.connectorTitle', { + defaultMessage: 'Inbound Webhook', + }), + validateParams: async (): Promise> => ({ errors: {} }), + actionParamsFields: lazy(async () => ({ default: () => null })), + actionConnectorFields: lazy(() => + import('./inbound_webhook_connector_fields').then(({ InboundWebhookConnectorFields }) => ({ + default: InboundWebhookConnectorFields, + })) + ), +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx new file mode 100644 index 0000000000000..856d9c54d4e71 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiCopy, EuiSpacer } from '@elastic/eui'; +import React, { useEffect } from 'react'; +import { HiddenField, TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { + UseField, + useFormContext, + useFormData, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { ActionConnectorFieldsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; + +const computeIngestTokenHash = async ({ + connectorId, + spaceId, + token, +}: { + connectorId: string; + spaceId: string; + token: string; +}): Promise => { + const bytes = new TextEncoder().encode(`${connectorId}|${spaceId}|${token}`); + const digest = await window.crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +}; + +export const InboundWebhookConnectorFields = ({ + isEdit, + readOnly, + registerPreSubmitValidator, +}: ActionConnectorFieldsProps) => { + const { http } = useKibana().services; + const { setFieldValue } = useFormContext(); + const [{ id, config }] = useFormData({ watch: ['id', 'config.webhookUrl'] }); + const webhookUrl = config?.webhookUrl as string | undefined; + + useEffect(() => { + registerPreSubmitValidator(async () => undefined); + }, [registerPreSubmitValidator]); + + useEffect(() => { + if (isEdit || webhookUrl || !id) { + return; + } + const createWebhook = async () => { + const token = + window.crypto.randomUUID().replaceAll('-', '') + + window.crypto.randomUUID().replaceAll('-', ''); + const path = http.basePath.prepend(`/api/events/v1/inboundWebhook/${id}?token=${token}`); + const spaceId = http.basePath.serverBasePath.includes('/s/') + ? http.basePath.serverBasePath.split('/s/')[1]?.split('/')[0] ?? 'default' + : 'default'; + setFieldValue('config.webhookUrl', `${window.location.origin}${path}`); + setFieldValue( + 'config.ingestTokenHash', + await computeIngestTokenHash({ + connectorId: String(id), + spaceId, + token, + }) + ); + }; + void createWebhook(); + }, [http.basePath, id, isEdit, setFieldValue, webhookUrl]); + + return ( + <> + + {!isEdit ? ( + <> + + + + {(copy) => ( + + + + )} + + + ) : null} + + ); +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts index f901ee39c1a08..088b5c73786de 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts @@ -43,6 +43,14 @@ export class StackConnectorsPublicPlugin validateEmailAddresses: actions.validateEmailAddresses, }, }); + + if (this.experimentalFeatures.connectorsFromSpecs) { + void import('./connector_types/inbound_webhook/inbound_webhook').then( + ({ getInboundWebhookConnectorType }) => { + triggersActionsUi.actionTypeRegistry.register(getInboundWebhookConnectorType()); + } + ); + } } public start() {} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts new file mode 100644 index 0000000000000..0a6368ce6415f --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { connectorsSpecs, toRegisteredConnectorEvent } from '@kbn/connector-specs'; +import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; + +export function registerConnectorEventTriggers( + workflowsExtensions?: WorkflowsExtensionsServerPluginSetup +): void { + if (!workflowsExtensions) { + return; + } + + for (const spec of Object.values(connectorsSpecs)) { + if (!spec.events) { + continue; + } + + for (const [eventKey, definition] of Object.entries(spec.events.definitions)) { + const event = toRegisteredConnectorEvent(spec.metadata, eventKey, definition); + workflowsExtensions.registerTriggerDefinition({ + id: event.eventId, + title: event.title, + description: event.description, + stability: event.stability ?? 'tech_preview', + eventSchema: event.eventSchema, + documentation: { + details: i18n.translate('stackConnectors.connectorEvents.triggerDocumentation', { + defaultMessage: + 'Subscribe with connector-id and optional KQL on event fields (e.g. event.body.eventType).', + }), + }, + snippets: { + condition: 'event.connectorId: "your-connector-id"', + }, + }); + } + } +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts index 97b593511c8d6..5d13ef819c72c 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts @@ -9,14 +9,25 @@ import { type PluginSetupContract as ActionsPluginSetupContract } from '@kbn/act import { connectorsSpecs } from '@kbn/connector-specs'; import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; +import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; + +import { registerInboundWebhookConnectorType } from './register_inbound_webhook_connector_type'; export function registerConnectorTypesFromSpecs({ actions, + getSpaceId, + getPublicBaseUrl, }: { actions: ActionsPluginSetupContract; + getSpaceId: (request: import('@kbn/core/server').KibanaRequest) => string; + getPublicBaseUrl: () => string; }) { - // Register connector specs + registerInboundWebhookConnectorType({ actions, getSpaceId, getPublicBaseUrl }); + for (const spec of Object.values(connectorsSpecs)) { + if (spec.metadata.id === INBOUND_WEBHOOK_CONNECTOR_TYPE_ID) { + continue; + } actions.registerType(createConnectorTypeFromSpec(spec, actions)); } } diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts new file mode 100644 index 0000000000000..044885f2abcb2 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { randomBytes } from 'node:crypto'; + +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; +import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; +import { + InboundWebhookConnector, + computeIngestTokenHash, + INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, +} from '@kbn/connector-specs'; +import type { KibanaRequest } from '@kbn/core/server'; + +export interface RegisterInboundWebhookConnectorTypeParams { + actions: ActionsPluginSetupContract; + getSpaceId: (request: KibanaRequest) => string; + getPublicBaseUrl: () => string; +} + +export function registerInboundWebhookConnectorType({ + actions, + getSpaceId, + getPublicBaseUrl, +}: RegisterInboundWebhookConnectorTypeParams): void { + const connectorType = createConnectorTypeFromSpec(InboundWebhookConnector, actions); + + actions.registerType({ + ...connectorType, + preSaveHook: async ({ connectorId, config, secrets, request, isUpdate }) => { + const spaceId = getSpaceId(request); + const configRecord = config as Record; + const secretsRecord = secrets as Record; + + let token = + typeof secretsRecord.ingestToken === 'string' && secretsRecord.ingestToken.length > 0 + ? secretsRecord.ingestToken + : undefined; + + if (!token && !isUpdate && typeof configRecord.ingestTokenHash !== 'string') { + token = randomBytes(32).toString('hex'); + } + + if (token) { + configRecord.ingestTokenHash = computeIngestTokenHash({ + connectorId, + spaceId, + token, + }); + secretsRecord.webhookUrl = `${getPublicBaseUrl()}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; + configRecord.webhookUrl = secretsRecord.webhookUrl; + delete secretsRecord.ingestToken; + } + }, + }); +} + +export { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 68fc0ba18ce74..4e111abe83270 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts @@ -15,6 +15,7 @@ import type { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-obje import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; +import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; import { registerInferenceConnectorsUsageCollector } from './usage/inference/inference_connectors_usage_collector'; import { registerConnectorTypes } from './connector_types'; import { @@ -26,11 +27,14 @@ import type { ExperimentalFeatures } from '../common/experimental_features'; import { parseExperimentalConfigValue } from '../common/experimental_features'; import type { ConfigSchema as StackConnectorsConfigType } from './config'; import { registerConnectorTypesFromSpecs } from './connector_types_from_spec'; +import { registerConnectorEventTriggers } from './connector_events/register_connector_event_triggers'; export interface ConnectorsPluginsSetup { actions: ActionsPluginSetupContract; usageCollection?: UsageCollectionSetup; cloud?: CloudSetup; + spaces?: SpacesPluginSetup; + workflowsExtensions?: WorkflowsExtensionsServerPluginSetup; } export interface ConnectorsPluginsStart { @@ -93,7 +97,12 @@ export class StackConnectorsPlugin }); if (this.experimentalFeatures.connectorsFromSpecs) { - registerConnectorTypesFromSpecs({ actions }); + registerConnectorTypesFromSpecs({ + actions, + getSpaceId: (request) => plugins.spaces?.spacesService.getSpaceId(request) ?? 'default', + getPublicBaseUrl: () => core.http.basePath.publicBaseUrl ?? '', + }); + registerConnectorEventTriggers(plugins.workflowsExtensions); } if (plugins.usageCollection) { diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 2cc86129e6337..057b9f3cdc1f9 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -109,7 +109,6 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.torq', 'actions:.webhook', 'actions:.workflows', - 'actions:.workflows-inbound-webhook', 'actions:.xmatters', 'actions:.xsoar', 'actions:connector_usage_reporting', @@ -272,7 +271,6 @@ export default function ({ getService }: FtrProviderContext) { 'workflow:resume', 'workflow:run', 'workflow:scheduled', - 'workflows:inbound-webhook-cleanup', ]); }); }); From 3a164c6c746714655e1fe99d819b6495f5bcf4e7 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 13 Jul 2026 15:41:13 +0200 Subject: [PATCH 02/19] =?UTF-8?q?Surface=20types=20+=20map=20RegisteredCon?= =?UTF-8?q?nectorEvent=20=E2=86=92=20surface=20(binding,=20KQL=20from=20ev?= =?UTF-8?q?entSchema)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packages/shared/kbn-workflows/index.ts | 1 + .../packages/shared/kbn-workflows/moon.yml | 1 + ...onnector_event_to_workflow_surface.test.ts | 89 +++++++++++++++++++ .../connector_event_to_workflow_surface.ts | 38 ++++++++ .../spec/workflow_surface/index.ts | 18 ++++ .../spec/workflow_surface/types.ts | 51 +++++++++++ .../shared/kbn-workflows/tsconfig.json | 3 +- 7 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/types.ts diff --git a/src/platform/packages/shared/kbn-workflows/index.ts b/src/platform/packages/shared/kbn-workflows/index.ts index ba1ddb8aa047c..8dc72eb194047 100644 --- a/src/platform/packages/shared/kbn-workflows/index.ts +++ b/src/platform/packages/shared/kbn-workflows/index.ts @@ -39,6 +39,7 @@ export { getWorkflowExamplesDir, } from './spec/examples'; export type { WorkflowExampleEntry } from './spec/examples'; +export * from './spec/workflow_surface'; export { StepCategory, StepCategories } from './spec/step_definition_types'; export type { BaseStepDefinition, StepDocumentation } from './spec/step_definition_types'; export * from './spec/deprecated_step_metadata'; diff --git a/src/platform/packages/shared/kbn-workflows/moon.yml b/src/platform/packages/shared/kbn-workflows/moon.yml index 241ea5c9f06d7..b503305d226a7 100644 --- a/src/platform/packages/shared/kbn-workflows/moon.yml +++ b/src/platform/packages/shared/kbn-workflows/moon.yml @@ -25,6 +25,7 @@ dependsOn: - '@kbn/repo-info' - '@kbn/human-readable-id' - '@kbn/std' + - '@kbn/connector-specs' tags: - shared-common - package diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts new file mode 100644 index 0000000000000..8b59413f24f7e --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { defineConnectorEvent, toRegisteredConnectorEvent } from '@kbn/connector-specs'; +import type { ConnectorMetadata } from '@kbn/connector-specs'; +import { z } from '@kbn/zod/v4'; +import { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; + +const inboundWebhookMetadata: ConnectorMetadata = { + id: '.inboundWebhook', + displayName: 'Inbound Webhook', + description: 'Receive HTTP requests from external systems', + minimumLicense: 'gold', + supportedFeatureIds: ['workflows'], +}; + +const inboundWebhookReceivedEventSchema = z.object({ + connectorId: z.string(), + connectorTypeId: z.string(), + method: z.string(), + headers: z.record(z.string(), z.union([z.string(), z.array(z.string())])), + query: z.record(z.string(), z.string()).optional(), + body: z.unknown(), + receivedAt: z.string(), +}); + +describe('connectorEventToWorkflowSurface', () => { + it('maps inboundWebhook.received to a trigger surface with required connector binding', () => { + const registered = toRegisteredConnectorEvent( + inboundWebhookMetadata, + 'received', + defineConnectorEvent({ + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + eventSchema: inboundWebhookReceivedEventSchema, + }) + ); + + const surface = connectorEventToWorkflowSurface(registered); + + expect(surface).toEqual({ + id: 'inboundWebhook.received', + kind: 'trigger', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + stability: 'tech_preview', + binding: { + connectorTypeId: '.inboundWebhook', + instanceRef: 'required', + }, + surfaces: { + input: inboundWebhookReceivedEventSchema, + filter: { + schema: inboundWebhookReceivedEventSchema, + language: 'kql', + yamlPath: 'on.condition', + }, + }, + source: { + type: 'connector-event', + connectorTypeId: '.inboundWebhook', + eventKey: 'received', + }, + }); + }); + + it('preserves explicit stability from the connector event', () => { + const registered = toRegisteredConnectorEvent( + inboundWebhookMetadata, + 'received', + defineConnectorEvent({ + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Stable webhook ingress.', + eventSchema: inboundWebhookReceivedEventSchema, + stability: 'beta', + }) + ); + + expect(connectorEventToWorkflowSurface(registered).stability).toBe('beta'); + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts new file mode 100644 index 0000000000000..02779784eae87 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { RegisteredConnectorEvent } from '@kbn/connector-specs'; +import type { WorkflowSurfaceDefinition } from './types'; + +export const connectorEventToWorkflowSurface = ( + event: RegisteredConnectorEvent +): WorkflowSurfaceDefinition => ({ + id: event.eventId, + kind: 'trigger', + title: event.title, + description: event.description, + stability: event.stability ?? 'tech_preview', + binding: { + connectorTypeId: event.connectorTypeId, + instanceRef: 'required', + }, + surfaces: { + input: event.eventSchema, + filter: { + schema: event.eventSchema, + language: 'kql', + yamlPath: 'on.condition', + }, + }, + source: { + type: 'connector-event', + connectorTypeId: event.connectorTypeId, + eventKey: event.eventKey, + }, +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts new file mode 100644 index 0000000000000..01b2577e8466f --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + InstanceRef, + WorkflowSurfaceBinding, + WorkflowSurfaceDefinition, + WorkflowSurfaceFilter, + WorkflowSurfaceKind, + WorkflowSurfaceSource, +} from './types'; +export { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/types.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/types.ts new file mode 100644 index 0000000000000..467f2cffe1a93 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/types.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { z } from '@kbn/zod/v4'; +import type { StabilityLevel } from '../../types/v1'; + +export type WorkflowSurfaceKind = 'step' | 'trigger'; + +export type InstanceRef = 'required' | 'optional' | 'none'; + +export interface WorkflowSurfaceBinding { + readonly connectorTypeId?: string; + readonly instanceRef: InstanceRef; +} + +export interface WorkflowSurfaceFilter { + readonly schema: z.ZodObject; + readonly language: 'kql'; + /** YAML path for filter expressions on this surface. */ + readonly yamlPath: 'on.condition'; +} + +export interface WorkflowSurfaceSource { + readonly type: 'connector-event'; + readonly connectorTypeId: string; + readonly eventKey: string; +} + +export interface WorkflowSurfaceDefinition { + readonly id: string; + readonly kind: WorkflowSurfaceKind; + readonly title: string; + readonly description: string; + readonly stability: StabilityLevel; + readonly binding: WorkflowSurfaceBinding; + readonly surfaces: { + readonly input?: z.ZodObject; + readonly config?: z.ZodObject; + readonly filter?: WorkflowSurfaceFilter; + }; + readonly documentation?: { readonly examples?: string[] }; + /** Workflows editor snippets (e.g. default KQL condition). Not set by connector specs. */ + readonly snippets?: { readonly condition?: string }; + readonly source?: WorkflowSurfaceSource; +} diff --git a/src/platform/packages/shared/kbn-workflows/tsconfig.json b/src/platform/packages/shared/kbn-workflows/tsconfig.json index c0929670145a6..6aa24d366f87f 100644 --- a/src/platform/packages/shared/kbn-workflows/tsconfig.json +++ b/src/platform/packages/shared/kbn-workflows/tsconfig.json @@ -13,7 +13,8 @@ "@kbn/core", "@kbn/repo-info", "@kbn/human-readable-id", - "@kbn/std" + "@kbn/std", + "@kbn/connector-specs" ], "exclude": ["target/**/*"] } From d15e6a5d68918d473f94e1ccfd02008aef0f978d Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 13 Jul 2026 16:42:17 +0200 Subject: [PATCH 03/19] Connector events via getConnectors + lazy emitEvent trigger resolve from ConnectorSpec --- .../connector_event_to_trigger_definition.ts | 30 +++++++++ .../spec/workflow_surface/index.ts | 5 ++ ...connector_event_trigger_definition.test.ts | 65 +++++++++++++++++++ ...olve_connector_event_trigger_definition.ts | 25 +++++++ .../shared/kbn-workflows/types/latest.ts | 1 + .../packages/shared/kbn-workflows/types/v1.ts | 10 +++ .../resolve_trigger_definition.test.ts | 55 ++++++++++++++++ .../resolve_trigger_definition.ts | 26 ++++++++ .../trigger_events/trigger_event_handler.ts | 5 +- .../api/lib/workflow_connectors.test.ts | 56 ++++++++++++++++ .../server/api/lib/workflow_connectors.ts | 10 ++- .../api/routes/examples/get_connectors.yaml | 18 +++++ .../api/routes/workflows/get_connectors.ts | 2 +- 13 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_trigger_definition.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts create mode 100644 src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts create mode 100644 src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_trigger_definition.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_trigger_definition.ts new file mode 100644 index 0000000000000..059160a65333a --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_trigger_definition.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { RegisteredConnectorEvent } from '@kbn/connector-specs'; +import type { z } from '@kbn/zod/v4'; +import type { StabilityLevel } from '../../types/v1'; + +export interface ConnectorEventTriggerDefinition { + readonly id: string; + readonly title: string; + readonly description: string; + readonly stability: StabilityLevel; + readonly eventSchema: EventSchema; +} + +export const connectorEventToTriggerDefinition = ( + event: RegisteredConnectorEvent +): ConnectorEventTriggerDefinition => ({ + id: event.eventId, + title: event.title, + description: event.description, + stability: event.stability ?? 'tech_preview', + eventSchema: event.eventSchema, +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts index 01b2577e8466f..6865ec7657c83 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts @@ -16,3 +16,8 @@ export type { WorkflowSurfaceSource, } from './types'; export { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; +export { + connectorEventToTriggerDefinition, + type ConnectorEventTriggerDefinition, +} from './connector_event_to_trigger_definition'; +export { resolveConnectorEventTriggerDefinition } from './resolve_connector_event_trigger_definition'; diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts new file mode 100644 index 0000000000000..707a5c283cd5a --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { defineConnectorEvent, toRegisteredConnectorEvent } from '@kbn/connector-specs'; +import type { ConnectorMetadata } from '@kbn/connector-specs'; +import { z } from '@kbn/zod/v4'; + +const inboundWebhookMetadata: ConnectorMetadata = { + id: '.inboundWebhook', + displayName: 'Inbound Webhook', + description: 'Receive HTTP requests from external systems', + minimumLicense: 'gold', + supportedFeatureIds: ['workflows'], +}; + +const mockInboundWebhookReceivedEventSchema = z.object({ + connectorId: z.string(), + body: z.unknown(), +}); + +const mockInboundWebhookReceivedEvent = toRegisteredConnectorEvent( + inboundWebhookMetadata, + 'received', + defineConnectorEvent({ + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + eventSchema: mockInboundWebhookReceivedEventSchema, + }) +); + +jest.mock('@kbn/connector-specs', () => { + const actual = jest.requireActual('@kbn/connector-specs'); + return { + ...actual, + resolveRegisteredConnectorEventByEventId: jest.fn((eventId: string) => + eventId === 'inboundWebhook.received' ? mockInboundWebhookReceivedEvent : undefined + ), + }; +}); + +import { resolveConnectorEventTriggerDefinition } from './resolve_connector_event_trigger_definition'; + +describe('resolveConnectorEventTriggerDefinition', () => { + it('returns a trigger definition derived from the connector spec event', () => { + const definition = resolveConnectorEventTriggerDefinition('inboundWebhook.received'); + + expect(definition).toMatchObject({ + id: 'inboundWebhook.received', + title: 'Webhook received', + stability: 'tech_preview', + eventSchema: mockInboundWebhookReceivedEventSchema, + }); + }); + + it('returns undefined when the eventId is not declared on any connector spec', () => { + expect(resolveConnectorEventTriggerDefinition('unknown.event')).toBeUndefined(); + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts new file mode 100644 index 0000000000000..3da44a13489ba --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; +import { + connectorEventToTriggerDefinition, + type ConnectorEventTriggerDefinition, +} from './connector_event_to_trigger_definition'; + +export const resolveConnectorEventTriggerDefinition = ( + triggerId: string +): ConnectorEventTriggerDefinition | undefined => { + const event = resolveRegisteredConnectorEventByEventId(triggerId); + if (!event) { + return undefined; + } + + return connectorEventToTriggerDefinition(event); +}; diff --git a/src/platform/packages/shared/kbn-workflows/types/latest.ts b/src/platform/packages/shared/kbn-workflows/types/latest.ts index a9df730fe43ca..5666217ee3314 100644 --- a/src/platform/packages/shared/kbn-workflows/types/latest.ts +++ b/src/platform/packages/shared/kbn-workflows/types/latest.ts @@ -59,6 +59,7 @@ export type { // connector types ConnectorSubAction, ConnectorInstance, + ConnectorEventInfo, ConnectorTypeInfo, ConnectorContractUnion, InternalConnectorContract, diff --git a/src/platform/packages/shared/kbn-workflows/types/v1.ts b/src/platform/packages/shared/kbn-workflows/types/v1.ts index 44484b133e992..5e3911632916a 100644 --- a/src/platform/packages/shared/kbn-workflows/types/v1.ts +++ b/src/platform/packages/shared/kbn-workflows/types/v1.ts @@ -573,6 +573,14 @@ export interface ConnectorInstanceConfig { taskType?: string; } +export interface ConnectorEventInfo { + readonly eventKey: string; + readonly eventId: string; + readonly title: string; + readonly description: string; + readonly stability?: StabilityLevel; +} + export interface ConnectorTypeInfo { actionTypeId: string; displayName: string; @@ -582,6 +590,8 @@ export interface ConnectorTypeInfo { enabledInLicense: boolean; minimumLicenseRequired: string; subActions: ConnectorSubAction[]; + /** Inbound connector events declared on this connector type (from ConnectorSpec.events). */ + events?: ConnectorEventInfo[]; } export type CompletionFn = () => Promise< diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts new file mode 100644 index 0000000000000..030f42d3cf3c2 --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; +import { resolveTriggerDefinition } from './resolve_trigger_definition'; + +jest.mock('@kbn/workflows', () => { + const actual = jest.requireActual('@kbn/workflows'); + return { + ...actual, + resolveConnectorEventTriggerDefinition: jest.fn(), + }; +}); + +const mockResolveConnectorEventTriggerDefinition = + resolveConnectorEventTriggerDefinition as jest.MockedFunction< + typeof resolveConnectorEventTriggerDefinition + >; + +describe('resolveTriggerDefinition', () => { + beforeEach(() => { + mockResolveConnectorEventTriggerDefinition.mockReset(); + }); + + it('prefers a trigger registered in workflows_extensions', () => { + const registered = { id: 'entityStore.riskScoreChanged' }; + const workflowsExtensions = { + getTriggerDefinition: jest.fn().mockReturnValue(registered), + } as any; + + expect(resolveTriggerDefinition('entityStore.riskScoreChanged', workflowsExtensions)).toBe( + registered + ); + expect(mockResolveConnectorEventTriggerDefinition).not.toHaveBeenCalled(); + }); + + it('falls back to connector spec events when the trigger is not registered', () => { + const fromSpec = { id: 'inboundWebhook.received' }; + mockResolveConnectorEventTriggerDefinition.mockReturnValue(fromSpec as any); + const workflowsExtensions = { + getTriggerDefinition: jest.fn().mockReturnValue(undefined), + } as any; + + expect(resolveTriggerDefinition('inboundWebhook.received', workflowsExtensions)).toBe(fromSpec); + expect(mockResolveConnectorEventTriggerDefinition).toHaveBeenCalledWith( + 'inboundWebhook.received' + ); + }); +}); diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts new file mode 100644 index 0000000000000..dc8016918351c --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; +import type { WorkflowsExtensionsServerPluginStart } from '@kbn/workflows-extensions/server'; +import type { ServerTriggerDefinition } from '@kbn/workflows-extensions/server/types'; + +export type ResolvedTriggerDefinition = ServerTriggerDefinition; + +export const resolveTriggerDefinition = ( + triggerId: string, + workflowsExtensions: WorkflowsExtensionsServerPluginStart +): ResolvedTriggerDefinition | undefined => { + const registered = workflowsExtensions.getTriggerDefinition(triggerId); + if (registered) { + return registered; + } + + return resolveConnectorEventTriggerDefinition(triggerId); +}; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/trigger_event_handler.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/trigger_event_handler.ts index 16e42231556ac..8fdfe53a6a536 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/trigger_event_handler.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/trigger_event_handler.ts @@ -30,6 +30,7 @@ import { initializeTriggerEventsClient, writeTriggerEvent } from './event_logs'; import type { TriggerEventsDataStreamClient } from './event_logs/trigger_events_data_stream'; import { classifyWorkflowTriggerMatch } from './filter_workflows_by_trigger_condition'; import { resolveWorkflowEventsModeFromOn } from './lib/resolve_workflow_events_mode_from_on'; +import { resolveTriggerDefinition } from './resolve_trigger_definition'; import { createEmptyTriggerResolutionStats, createEmptyTriggerScheduleStats, @@ -276,10 +277,10 @@ export class TriggerEventHandler { spaceId: string, event: Record ): void { - const definition = this.workflowsExtensions.getTriggerDefinition(triggerId); + const definition = resolveTriggerDefinition(triggerId, this.workflowsExtensions); if (!definition) { throw new Error( - `Trigger "${triggerId}" is not registered. Register it during plugin setup via registerTriggerDefinition.` + `Trigger "${triggerId}" is not registered. Register it during plugin setup via registerTriggerDefinition, or declare it on a ConnectorSpec.events definition.` ); } diff --git a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts index e41a162db1168..ec8dfc2b0e3a8 100644 --- a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts @@ -7,8 +7,22 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +jest.mock('@kbn/connector-specs', () => { + const actual = jest.requireActual('@kbn/connector-specs'); + return { + ...actual, + listConnectorEventInfosForType: jest.fn(() => []), + }; +}); + +import { listConnectorEventInfosForType } from '@kbn/connector-specs'; + import { getAvailableConnectors } from './workflow_connectors'; +const mockListConnectorEventInfosForType = listConnectorEventInfosForType as jest.MockedFunction< + typeof listConnectorEventInfosForType +>; + const mockActionType = (overrides: Record = {}) => ({ id: '.slack', name: 'Slack', @@ -32,6 +46,10 @@ const mockConnector = (overrides: Record = {}) => ({ describe('getAvailableConnectors', () => { const request = {} as any; + beforeEach(() => { + mockListConnectorEventInfosForType.mockReturnValue([]); + }); + it('groups connectors by type and returns the configured action-type metadata', async () => { const actionsClient = { getAll: jest.fn().mockResolvedValue([mockConnector()]) }; const actionsClientWithRequest = { @@ -191,4 +209,42 @@ describe('getAvailableConnectors', () => { expect(order.indexOf('getAll:start')).toBeLessThan(order.indexOf('listTypes:end')); expect(order.indexOf('listTypes:start')).toBeLessThan(order.indexOf('getAll:end')); }); + + it('includes connector events from ConnectorSpec when the type declares events', async () => { + mockListConnectorEventInfosForType.mockImplementation((actionTypeId) => + actionTypeId === '.inboundWebhook' + ? [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + }, + ] + : [] + ); + + const actionsClient = { getAll: jest.fn().mockResolvedValue([]) }; + const actionsClientWithRequest = { + listTypes: jest + .fn() + .mockResolvedValue([mockActionType({ id: '.inboundWebhook', name: 'Inbound Webhook' })]), + }; + + const result = await getAvailableConnectors({ + getActionsClient: jest.fn().mockResolvedValue(actionsClient), + getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientWithRequest), + spaceId: 'default', + request, + }); + + expect(result.connectorTypes['.inboundWebhook'].events).toEqual([ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + }, + ]); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts index 9dd1a5cace1a0..96ccc49a8cf42 100644 --- a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts +++ b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts @@ -10,9 +10,10 @@ import { WorkflowsConnectorFeatureId } from '@kbn/actions-plugin/common/connector_feature_config'; import type { ActionsClient, IUnsecuredActionsClient } from '@kbn/actions-plugin/server'; import type { FindActionResult } from '@kbn/actions-plugin/server/types'; +import { listConnectorEventInfosForType } from '@kbn/connector-specs'; import type { KibanaRequest } from '@kbn/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { ConnectorTypeInfo } from '@kbn/workflows'; +import type { ConnectorEventInfo, ConnectorTypeInfo } from '@kbn/workflows'; import type { ConnectorInstanceConfig, GetAvailableConnectorsResponse, @@ -29,6 +30,11 @@ const getConnectorInstanceConfig = ( return undefined; }; +const getConnectorEventsForType = (actionTypeId: string): ConnectorEventInfo[] | undefined => { + const events = listConnectorEventInfosForType(actionTypeId); + return events.length > 0 ? events : undefined; +}; + /** * Lists all available connector action types and their instances for the workflows feature. */ @@ -54,6 +60,7 @@ export const getAvailableConnectors = async (params: { actionTypes.forEach((actionType) => { const subActions = CONNECTOR_SUB_ACTIONS_MAP[actionType.id]; + const events = getConnectorEventsForType(actionType.id); connectorTypes[actionType.id] = { actionTypeId: actionType.id, @@ -64,6 +71,7 @@ export const getAvailableConnectors = async (params: { enabledInLicense: actionType.enabledInLicense, minimumLicenseRequired: actionType.minimumLicenseRequired, ...(subActions && { subActions }), + ...(events && { events }), }; }); diff --git a/src/platform/plugins/shared/workflows_management/server/api/routes/examples/get_connectors.yaml b/src/platform/plugins/shared/workflows_management/server/api/routes/examples/get_connectors.yaml index b70759d9fd8e2..9fc40cc0daabb 100644 --- a/src/platform/plugins/shared/workflows_management/server/api/routes/examples/get_connectors.yaml +++ b/src/platform/plugins/shared/workflows_management/server/api/routes/examples/get_connectors.yaml @@ -34,6 +34,24 @@ responses: subActions: - name: send displayName: Send + .inboundWebhook: + actionTypeId: .inboundWebhook + displayName: Inbound Webhook + instances: + - id: sales-ingress + name: sales-ingress + isPreconfigured: false + isDeprecated: false + enabled: true + enabledInConfig: true + enabledInLicense: true + minimumLicenseRequired: gold + events: + - eventKey: received + eventId: inboundWebhook.received + title: Webhook received + description: Fires when an authenticated request hits this connector endpoint. + stability: tech_preview totalConnectors: 1 x-codeSamples: - lang: curl diff --git a/src/platform/plugins/shared/workflows_management/server/api/routes/workflows/get_connectors.ts b/src/platform/plugins/shared/workflows_management/server/api/routes/workflows/get_connectors.ts index d859f078fd70b..d2c7070cf05b6 100644 --- a/src/platform/plugins/shared/workflows_management/server/api/routes/workflows/get_connectors.ts +++ b/src/platform/plugins/shared/workflows_management/server/api/routes/workflows/get_connectors.ts @@ -22,7 +22,7 @@ export function registerGetConnectorsRoute({ router, api, logger, spaces }: Rout security: WORKFLOW_READ_SECURITY, summary: 'Get available connectors', description: - 'Retrieve the Kibana action connectors that can be used in workflow steps, grouped by connector type. Each type includes its configured instances and availability status.', + 'Retrieve the Kibana action connectors that can be used in workflow steps and triggers, grouped by connector type. Each type includes its configured instances, availability status, and declared workflow events (when the connector supports inbound events).', options: { tags: [OAS_TAG], availability: AVAILABILITY, From ce81936665f8dd0b53f5896709623015ede895cb Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 13 Jul 2026 16:53:02 +0200 Subject: [PATCH 04/19] trigger schema from connectorTypes[*].events --- .../packages/shared/kbn-workflows/index.ts | 2 + ...a_from_connectors.connector_events.test.ts | 64 +++++++++++ .../generate_yaml_schema_from_connectors.ts | 33 +++++- .../collect_connector_events_from_types.ts | 25 +++++ .../connector_event_trigger_schema.test.ts | 100 ++++++++++++++++++ .../connector_event_trigger_schema.ts | 27 +++++ .../triggers/custom_trigger_on_schema.ts | 25 +++++ .../spec/schema/triggers/index.ts | 55 +++++----- .../schema/triggers/workflow_events_schema.ts | 16 +++ .../workflows_management/common/schema.ts | 15 ++- 10 files changed, 330 insertions(+), 32 deletions(-) create mode 100644 src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.connector_events.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_from_types.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/schema/triggers/custom_trigger_on_schema.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/schema/triggers/workflow_events_schema.ts diff --git a/src/platform/packages/shared/kbn-workflows/index.ts b/src/platform/packages/shared/kbn-workflows/index.ts index 8dc72eb194047..285a757a59620 100644 --- a/src/platform/packages/shared/kbn-workflows/index.ts +++ b/src/platform/packages/shared/kbn-workflows/index.ts @@ -86,6 +86,8 @@ export { ManualTriggerSchema, TriggerSchema, getTriggerSchema, + getTriggerSchemaFromConnectorEvents, + collectConnectorEventsFromTypes, TriggerTypes, WORKFLOW_EVENTS_VALUES_SET, WorkflowEventsSchema, diff --git a/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.connector_events.test.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.connector_events.test.ts new file mode 100644 index 0000000000000..c9e5e305f1d5a --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.connector_events.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { generateYamlSchemaFromConnectors } from '../..'; + +const inboundWebhookReceived = { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an inbound webhook request is received', +}; + +describe('generateYamlSchemaFromConnectors connector-event triggers', () => { + it('validates connector-id on connector-event triggers in strict mode', () => { + const schema = generateYamlSchemaFromConnectors([], { + connectorEvents: [inboundWebhookReceived], + }); + + expect( + schema.safeParse({ + name: 'test', + triggers: [ + { + type: 'inboundWebhook.received', + on: { condition: 'event.body.eventType: "order.created"' }, + }, + ], + steps: [{ name: 'wait-step', type: 'wait', with: { duration: '1s' } }], + }).success + ).toBe(false); + + const result = schema.safeParse({ + name: 'test', + triggers: [ + { + type: 'inboundWebhook.received', + 'connector-id': 'sales-ingress', + on: { condition: 'event.body.eventType: "order.created"' }, + }, + ], + steps: [{ name: 'wait-step', type: 'wait', with: { duration: '1s' } }], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual( + expect.objectContaining({ + triggers: [ + expect.objectContaining({ + type: 'inboundWebhook.received', + 'connector-id': 'sales-ingress', + }), + ], + }) + ); + } + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts b/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts index a34a453c37341..9cb4de4dc9948 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/lib/generate_yaml_schema_from_connectors.ts @@ -9,6 +9,7 @@ import { z } from '@kbn/zod/v4'; import { type ConnectorContractUnion } from '../..'; +import type { ConnectorEventInfo } from '../../types/latest'; import { getDeprecatedStepMessage, getStepDeprecationInfo } from '../deprecated_step_metadata'; import { KIBANA_TYPE_ALIASES } from '../kibana/aliases'; import { @@ -37,6 +38,24 @@ import { } from '../schema'; import { getTriggerSchema } from '../schema/triggers'; +export interface YamlSchemaTriggerInput { + customTriggerIds?: string[]; + connectorEvents?: ConnectorEventInfo[]; +} + +export const normalizeYamlSchemaTriggerInput = ( + input: string[] | YamlSchemaTriggerInput = [] +): Required => { + if (Array.isArray(input)) { + return { customTriggerIds: input, connectorEvents: [] }; + } + + return { + customTriggerIds: input.customTriggerIds ?? [], + connectorEvents: input.connectorEvents ?? [], + }; +}; + export function getStepId(stepName: string): string { // Using step name as is, don't do any escaping to match the workflow engine behavior // Leaving this function in case we'd to change behaviour in future. @@ -45,13 +64,14 @@ export function getStepId(stepName: string): string { export function generateYamlSchemaFromConnectors( connectors: ConnectorContractUnion[], - /** Registered custom trigger type ids for YAML schema validation (e.g. example.custom_trigger) */ - triggers: string[] = [], + /** Registered custom trigger ids and/or connector events for YAML schema validation */ + triggerInput: string[] | YamlSchemaTriggerInput = [], /** * @deprecated use WorkflowSchemaForAutocomplete instead */ loose: boolean = false ): z.ZodType { + const { customTriggerIds, connectorEvents } = normalizeYamlSchemaTriggerInput(triggerInput); const recursiveStepSchema = createRecursiveStepSchema(connectors, loose); if (loose) { @@ -66,7 +86,7 @@ export function generateYamlSchemaFromConnectors( })); } - const triggerSchema = getTriggerSchema(triggers); + const triggerSchema = getTriggerSchema(customTriggerIds, connectorEvents); const workflowBaseWithTriggers = WorkflowSchemaBase.extend({ triggers: z.array(triggerSchema).min(1), }); @@ -81,10 +101,13 @@ export function generateYamlSchemaFromConnectors( * Generates a schema for trusted workflow definitions that need the shared workflow envelope * validation without materializing the connector-expanded step union. */ -export function generateLightweightYamlSchema(triggers: string[] = []): z.ZodType { +export function generateLightweightYamlSchema( + triggerInput: string[] | YamlSchemaTriggerInput = [] +): z.ZodType { + const { customTriggerIds, connectorEvents } = normalizeYamlSchemaTriggerInput(triggerInput); // Trigger schemas are lightweight: custom IDs add literal trigger variants and do // not materialize connector or step-definition schemas. - const triggerSchema = getTriggerSchema(triggers); + const triggerSchema = getTriggerSchema(customTriggerIds, connectorEvents); const workflowBaseWithTriggers = WorkflowSchemaBase.extend({ triggers: z.array(triggerSchema).min(1), }); diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_from_types.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_from_types.ts new file mode 100644 index 0000000000000..482a9d7ecce02 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_from_types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ConnectorEventInfo, ConnectorTypeInfo } from '../../../types/latest'; + +/** Collects all connector events declared on connector types from `GET /api/workflows/connectors`. */ +export const collectConnectorEventsFromTypes = ( + connectorTypes: Record +): ConnectorEventInfo[] => { + const events: ConnectorEventInfo[] = []; + + for (const connectorType of Object.values(connectorTypes)) { + if (connectorType.events?.length) { + events.push(...connectorType.events); + } + } + + return events; +}; diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.test.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.test.ts new file mode 100644 index 0000000000000..94e6778dd5555 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { collectConnectorEventsFromTypes, getTriggerSchema } from '.'; +import type { ConnectorEventInfo } from '../../../types/latest'; + +const inboundWebhookReceived: ConnectorEventInfo = { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an inbound webhook request is received', +}; + +describe('connector-event trigger schema', () => { + const triggerSchema = getTriggerSchema(['cases.updated'], [inboundWebhookReceived]); + + it('requires connector-id for connector-event triggers', () => { + expect( + triggerSchema.safeParse({ + type: 'inboundWebhook.received', + on: { condition: 'event.body.eventType: "order.created"' }, + }).success + ).toBe(false); + + expect( + triggerSchema.safeParse({ + type: 'inboundWebhook.received', + 'connector-id': 'sales-ingress', + on: { condition: 'event.body.eventType: "order.created"' }, + }).success + ).toBe(true); + }); + + it('accepts optional on block on connector-event triggers', () => { + expect( + triggerSchema.safeParse({ + type: 'inboundWebhook.received', + 'connector-id': 'sales-ingress', + }).success + ).toBe(true); + + expect( + triggerSchema.safeParse({ + type: 'inboundWebhook.received', + 'connector-id': 'sales-ingress', + on: {}, + }).success + ).toBe(true); + }); + + it('does not require connector-id for plain custom triggers', () => { + expect(triggerSchema.safeParse({ type: 'cases.updated' }).success).toBe(true); + }); + + it('prefers connector-event schema when the same id is registered as a custom trigger', () => { + const schemaWithOverlap = getTriggerSchema( + ['inboundWebhook.received'], + [inboundWebhookReceived] + ); + + expect( + schemaWithOverlap.safeParse({ + type: 'inboundWebhook.received', + }).success + ).toBe(false); + + expect( + schemaWithOverlap.safeParse({ + type: 'inboundWebhook.received', + 'connector-id': 'sales-ingress', + }).success + ).toBe(true); + }); +}); + +describe('collectConnectorEventsFromTypes', () => { + it('flattens events from all connector types', () => { + expect( + collectConnectorEventsFromTypes({ + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + subActions: [], + events: [inboundWebhookReceived], + }, + }) + ).toEqual([inboundWebhookReceived]); + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.ts new file mode 100644 index 0000000000000..b9a9c738aa461 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/connector_event_trigger_schema.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import { CustomTriggerOnSchema } from './custom_trigger_on_schema'; +import type { ConnectorEventInfo } from '../../../types/latest'; + +export const createConnectorEventTriggerSchema = (event: ConnectorEventInfo) => + z.object({ + type: event.description + ? z.literal(event.eventId).describe(event.description) + : z.literal(event.eventId), + 'connector-id': z.string().describe('ID of the connector instance that receives this event'), + on: CustomTriggerOnSchema, + }); + +/** + * Returns Zod schemas for connector-event triggers (`type`, required `connector-id`, optional `on`). + */ +export const getTriggerSchemaFromConnectorEvents = (connectorEvents: ConnectorEventInfo[]) => + connectorEvents.map(createConnectorEventTriggerSchema); diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/custom_trigger_on_schema.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/custom_trigger_on_schema.ts new file mode 100644 index 0000000000000..9dbcfc74044a9 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/custom_trigger_on_schema.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import { WorkflowEventsSchema } from './workflow_events_schema'; + +/** Schema for the `on` block of custom triggers (KQL condition to filter when the workflow runs). */ +export const CustomTriggerOnSchema = z + .object({ + condition: z.string().optional(), + /** + * How this trigger responds when the event was emitted from a workflow-attributed chain: + * `ignore` — do not schedule; + * `avoid-loop` — schedule with cycle guard (default when omitted); + * `allow-all` — schedule without cycle guard (max chain depth still applies). + */ + workflowEvents: WorkflowEventsSchema.optional(), + }) + .optional(); diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts index 210e8cdf2efcb..55c63c9c503d8 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts @@ -9,8 +9,11 @@ import { z } from '@kbn/zod/v4'; import { AlertRuleTriggerSchema } from './alert_trigger_schema'; +import { getTriggerSchemaFromConnectorEvents } from './connector_event_trigger_schema'; +import { CustomTriggerOnSchema } from './custom_trigger_on_schema'; import { ManualTriggerSchema } from './manual_trigger_schema'; import { ScheduledTriggerSchema } from './scheduled_trigger_schema'; +import type { ConnectorEventInfo } from '../../../types/latest'; export { AlertRuleTriggerSchema } from './alert_trigger_schema'; export { ManualTriggerSchema } from './manual_trigger_schema'; @@ -19,6 +22,17 @@ export { SCHEDULED_INTERVAL_ERROR, SCHEDULED_INTERVAL_PATTERN, } from './scheduled_trigger_schema'; +export { + WORKFLOW_EVENTS_VALUES_SET, + WorkflowEventsSchema, + type WorkflowEventsValue, +} from './workflow_events_schema'; +export { CustomTriggerOnSchema } from './custom_trigger_on_schema'; +export { + createConnectorEventTriggerSchema, + getTriggerSchemaFromConnectorEvents, +} from './connector_event_trigger_schema'; +export { collectConnectorEventsFromTypes } from './collect_connector_events_from_types'; export const TriggerSchema = z.discriminatedUnion('type', [ AlertRuleTriggerSchema, @@ -28,46 +42,37 @@ export const TriggerSchema = z.discriminatedUnion('type', [ export type Trigger = z.infer; -/** Allowed values for `on.workflowEvents` on custom (event-driven) triggers. */ -const WORKFLOW_EVENTS_VALUES = ['ignore', 'allow-all', 'avoid-loop'] as const; -export type WorkflowEventsValue = (typeof WORKFLOW_EVENTS_VALUES)[number]; -export const WORKFLOW_EVENTS_VALUES_SET = new Set(WORKFLOW_EVENTS_VALUES); -export const WorkflowEventsSchema = z.enum(WORKFLOW_EVENTS_VALUES); - -/** Schema for the `on` block of custom triggers (KQL condition to filter when the workflow runs). */ -const CustomTriggerOnSchema = z - .object({ - condition: z.string().optional(), - /** - * How this trigger responds when the event was emitted from a workflow-attributed chain: - * `ignore` — do not schedule; - * `avoid-loop` — schedule with cycle guard (default when omitted); - * `allow-all` — schedule without cycle guard (max chain depth still applies). - */ - workflowEvents: WorkflowEventsSchema.optional(), - }) - .optional(); - /** - * Returns a trigger schema that includes built-in types plus optional registered trigger ids. + * Returns a trigger schema that includes built-in types plus optional registered trigger ids + * and connector-event triggers discovered from `GET /api/workflows/connectors`. * Used by the YAML editor so custom trigger types (e.g. example.custom_trigger) pass validation. * Custom triggers allow an `on.condition` clause for KQL filtering. + * Connector-event triggers require `connector-id` and allow the same optional `on` block. */ -export function getTriggerSchema(customTriggerIds: string[] = []): z.ZodType { - if (customTriggerIds.length === 0) { +export function getTriggerSchema( + customTriggerIds: string[] = [], + connectorEvents: ConnectorEventInfo[] = [] +): z.ZodType { + const connectorEventIds = new Set(connectorEvents.map((event) => event.eventId)); + const plainCustomTriggerIds = customTriggerIds.filter((id) => !connectorEventIds.has(id)); + + if (plainCustomTriggerIds.length === 0 && connectorEvents.length === 0) { return TriggerSchema; } - const customSchemas = customTriggerIds.map((id) => + + const plainCustomSchemas = plainCustomTriggerIds.map((id) => z.object({ type: z.literal(id), on: CustomTriggerOnSchema, }) ); + return z.discriminatedUnion('type', [ AlertRuleTriggerSchema, ScheduledTriggerSchema, ManualTriggerSchema, - ...customSchemas, + ...plainCustomSchemas, + ...getTriggerSchemaFromConnectorEvents(connectorEvents), ]); } diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/workflow_events_schema.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/workflow_events_schema.ts new file mode 100644 index 0000000000000..f1f3ab82fcbd3 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/workflow_events_schema.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; + +/** Allowed values for `on.workflowEvents` on custom (event-driven) triggers. */ +export const WORKFLOW_EVENTS_VALUES = ['ignore', 'allow-all', 'avoid-loop'] as const; +export type WorkflowEventsValue = (typeof WORKFLOW_EVENTS_VALUES)[number]; +export const WORKFLOW_EVENTS_VALUES_SET = new Set(WORKFLOW_EVENTS_VALUES); +export const WorkflowEventsSchema = z.enum(WORKFLOW_EVENTS_VALUES); diff --git a/src/platform/plugins/shared/workflows_management/common/schema.ts b/src/platform/plugins/shared/workflows_management/common/schema.ts index f02a9dfecbf24..10b5fa62b442d 100644 --- a/src/platform/plugins/shared/workflows_management/common/schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/schema.ts @@ -16,6 +16,7 @@ import type { } from '@kbn/workflows'; import { builtInStepDefinitions, + collectConnectorEventsFromTypes, DEPRECATED_STEP_METADATA, generateLightweightYamlSchema, generateYamlSchemaFromConnectors, @@ -459,17 +460,27 @@ export function getAllConnectorsWithDynamic( return getAllConnectorsWithDynamicInternal(dynamicConnectorTypes); } +const getWorkflowTriggerSchemaInput = ( + dynamicConnectorTypes: Record, + registeredTriggerIds: string[] = [] +) => ({ + customTriggerIds: registeredTriggerIds, + connectorEvents: collectConnectorEventsFromTypes(dynamicConnectorTypes), +}); + export const getWorkflowZodSchema = ( dynamicConnectorTypes: Record, registeredTriggerIds: string[] = [], options: WorkflowZodSchemaOptions = {} ): z.ZodType => { + const triggerInput = getWorkflowTriggerSchemaInput(dynamicConnectorTypes, registeredTriggerIds); + if (options.lightweight) { - return generateLightweightYamlSchema(registeredTriggerIds); + return generateLightweightYamlSchema(triggerInput); } const allConnectors = getAllConnectorsWithDynamicInternal(dynamicConnectorTypes); - return generateYamlSchemaFromConnectors(allConnectors, registeredTriggerIds); + return generateYamlSchemaFromConnectors(allConnectors, triggerInput); }; export const getWorkflowZodSchemaLoose = ( From da31bd6af00d24bd1edaf6bb7191d3a3cf40afde Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 13 Jul 2026 16:56:44 +0200 Subject: [PATCH 05/19] gates connector-event triggers on instance ID before KQL runs --- .../connector_event_trigger_gate.test.ts | 96 ++++++++++++++ .../connector_event_trigger_gate.ts | 55 ++++++++ ...ter_workflows_by_trigger_condition.test.ts | 121 +++++++++++++++++- .../filter_workflows_by_trigger_condition.ts | 14 +- 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts create mode 100644 src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts new file mode 100644 index 0000000000000..ddae844c6329c --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + connectorEventTriggerInstanceMatches, + isConnectorEventTriggerId, + readEventConnectorId, + readTriggerConnectorId, +} from './connector_event_trigger_gate'; + +jest.mock('@kbn/workflows', () => ({ + ...jest.requireActual('@kbn/workflows'), + resolveConnectorEventTriggerDefinition: jest.fn((triggerId: string) => + triggerId === 'inboundWebhook.received' ? { id: triggerId } : undefined + ), +})); + +describe('connector_event_trigger_gate', () => { + describe('isConnectorEventTriggerId', () => { + it('returns true for connector-event trigger ids', () => { + expect(isConnectorEventTriggerId('inboundWebhook.received')).toBe(true); + }); + + it('returns false for plain custom trigger ids', () => { + expect(isConnectorEventTriggerId('cases.updated')).toBe(false); + }); + }); + + describe('readTriggerConnectorId', () => { + it('reads connector-id from trigger blocks', () => { + expect( + readTriggerConnectorId({ type: 'inboundWebhook.received', 'connector-id': 'sales-ingress' }) + ).toBe('sales-ingress'); + }); + + it('returns undefined when connector-id is missing or blank', () => { + expect(readTriggerConnectorId({ type: 'inboundWebhook.received' })).toBeUndefined(); + expect( + readTriggerConnectorId({ type: 'inboundWebhook.received', 'connector-id': ' ' }) + ).toBeUndefined(); + }); + }); + + describe('readEventConnectorId', () => { + it('reads connectorId from emit payloads', () => { + expect(readEventConnectorId({ connectorId: 'sales-ingress', body: { ok: true } })).toBe( + 'sales-ingress' + ); + }); + + it('returns undefined when connectorId is missing or blank', () => { + expect(readEventConnectorId({ body: {} })).toBeUndefined(); + expect(readEventConnectorId({ connectorId: '' })).toBeUndefined(); + }); + }); + + describe('connectorEventTriggerInstanceMatches', () => { + it('matches when trigger connector-id equals payload connectorId', () => { + expect( + connectorEventTriggerInstanceMatches( + { type: 'inboundWebhook.received', 'connector-id': 'sales-ingress' }, + { connectorId: 'sales-ingress', body: {} } + ) + ).toBe(true); + }); + + it('does not match when ids differ or are missing', () => { + expect( + connectorEventTriggerInstanceMatches( + { type: 'inboundWebhook.received', 'connector-id': 'sales-ingress' }, + { connectorId: 'other-ingress', body: {} } + ) + ).toBe(false); + + expect( + connectorEventTriggerInstanceMatches( + { type: 'inboundWebhook.received', 'connector-id': 'sales-ingress' }, + { body: {} } + ) + ).toBe(false); + + expect( + connectorEventTriggerInstanceMatches( + { type: 'inboundWebhook.received' }, + { connectorId: 'sales-ingress', body: {} } + ) + ).toBe(false); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts new file mode 100644 index 0000000000000..ba9dc3a3eaf75 --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; + +export const isConnectorEventTriggerId = (triggerId: string): boolean => + resolveConnectorEventTriggerDefinition(triggerId) !== undefined; + +export const readTriggerConnectorId = (trigger: unknown): string | undefined => { + if (trigger == null || typeof trigger !== 'object' || !('connector-id' in trigger)) { + return undefined; + } + + const connectorId = trigger['connector-id']; + if (typeof connectorId !== 'string') { + return undefined; + } + + const trimmed = connectorId.trim(); + return trimmed === '' ? undefined : trimmed; +}; + +export const readEventConnectorId = (payload: Record): string | undefined => { + const connectorId = payload.connectorId; + if (typeof connectorId !== 'string') { + return undefined; + } + + const trimmed = connectorId.trim(); + return trimmed === '' ? undefined : trimmed; +}; + +/** + * Whether a connector-event trigger's subscribed instance matches the emitted event payload. + * Both YAML `connector-id` and emit payload `connectorId` must be present and equal. + */ +export const connectorEventTriggerInstanceMatches = ( + trigger: unknown, + payload: Record +): boolean => { + const triggerConnectorId = readTriggerConnectorId(trigger); + const eventConnectorId = readEventConnectorId(payload); + + return ( + triggerConnectorId !== undefined && + eventConnectorId !== undefined && + triggerConnectorId === eventConnectorId + ); +}; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts index 2a943da4555df..6563b17c5403c 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts @@ -14,9 +14,16 @@ import { workflowMatchesTriggerCondition, } from './filter_workflows_by_trigger_condition'; +jest.mock('@kbn/workflows', () => ({ + ...jest.requireActual('@kbn/workflows'), + resolveConnectorEventTriggerDefinition: jest.fn((triggerId: string) => + triggerId === 'inboundWebhook.received' ? { id: triggerId } : undefined + ), +})); + /** Definition overrides for tests; allows custom trigger types (e.g. cases.updated). */ interface TestDefinitionOverrides { - triggers?: Array<{ type: string; on?: { condition?: string } }>; + triggers?: Array<{ type: string; 'connector-id'?: string; on?: { condition?: string } }>; steps?: unknown[]; } @@ -269,3 +276,115 @@ describe('classifyWorkflowTriggerMatch', () => { expect(classifyWorkflowTriggerMatch(workflow, 'cases.updated', {}, mockLogger)).toBe('matched'); }); }); + +describe('connector-event trigger connector-id gate', () => { + const mockLogger: Logger = { + warn: jest.fn(), + } as unknown as Logger; + + const createConnectorEventWorkflow = (connectorId: string, condition?: string) => + createMockWorkflow({ + definition: { + triggers: [ + { + type: 'inboundWebhook.received', + 'connector-id': connectorId, + ...(condition ? { on: { condition } } : {}), + }, + ], + steps: [], + }, + }); + + it('matches when trigger connector-id equals payload connectorId', () => { + const workflow = createConnectorEventWorkflow('sales-ingress'); + expect( + workflowMatchesTriggerCondition( + workflow, + 'inboundWebhook.received', + { connectorId: 'sales-ingress', body: { eventType: 'order.created' } }, + mockLogger + ) + ).toBe(true); + }); + + it('does not match when connector ids differ', () => { + const workflow = createConnectorEventWorkflow('sales-ingress'); + expect( + workflowMatchesTriggerCondition( + workflow, + 'inboundWebhook.received', + { connectorId: 'other-ingress', body: { eventType: 'order.created' } }, + mockLogger + ) + ).toBe(false); + }); + + it('does not match when payload connectorId is missing', () => { + const workflow = createConnectorEventWorkflow('sales-ingress'); + expect( + workflowMatchesTriggerCondition( + workflow, + 'inboundWebhook.received', + { body: { eventType: 'order.created' } }, + mockLogger + ) + ).toBe(false); + }); + + it('applies connector-id gate before KQL evaluation', () => { + const workflow = createConnectorEventWorkflow( + 'sales-ingress', + 'event.body.eventType: "order.created"' + ); + + expect( + workflowMatchesTriggerCondition( + workflow, + 'inboundWebhook.received', + { + connectorId: 'other-ingress', + body: { eventType: 'order.created' }, + }, + mockLogger + ) + ).toBe(false); + + expect( + workflowMatchesTriggerCondition( + workflow, + 'inboundWebhook.received', + { + connectorId: 'sales-ingress', + body: { eventType: 'order.created' }, + }, + mockLogger + ) + ).toBe(true); + + expect( + workflowMatchesTriggerCondition( + workflow, + 'inboundWebhook.received', + { + connectorId: 'sales-ingress', + body: { eventType: 'order.cancelled' }, + }, + mockLogger + ) + ).toBe(false); + }); + + it('does not apply connector-id gate to plain custom triggers', () => { + const workflow = createMockWorkflow({ + definition: { + triggers: [{ type: 'cases.updated', on: { condition: 'event.severity: "high"' } }], + steps: [], + }, + }); + + expect( + workflowMatchesTriggerCondition(workflow, 'cases.updated', { severity: 'high' }, mockLogger) + ).toBe(true); + }); +}); diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.ts index 71fbbcad969db..a0035abdf8698 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.ts @@ -10,6 +10,10 @@ import type { Logger } from '@kbn/core/server'; import { evaluateKql } from '@kbn/eval-kql'; import type { WorkflowDetailDto } from '@kbn/workflows'; +import { + connectorEventTriggerInstanceMatches, + isConnectorEventTriggerId, +} from './connector_event_trigger_gate'; /** * Why a subscribed workflow did or did not match an emitted trigger event (for funnel telemetry). @@ -17,7 +21,8 @@ import type { WorkflowDetailDto } from '@kbn/workflows'; export type WorkflowTriggerMatchOutcome = 'matched' | 'disabled' | 'kql_false' | 'kql_error'; /** - * Classifies a workflow for a given trigger id and event payload (enabled gate, trigger block, KQL). + * Classifies a workflow for a given trigger id and event payload (enabled gate, trigger block, + * connector-instance gate for connector events, then KQL). */ export function classifyWorkflowTriggerMatch( workflow: WorkflowDetailDto, @@ -39,6 +44,13 @@ export function classifyWorkflowTriggerMatch( return 'kql_false'; } + if ( + isConnectorEventTriggerId(triggerId) && + !connectorEventTriggerInstanceMatches(matchingTrigger, payload) + ) { + return 'kql_false'; + } + const onBlock = matchingTrigger && 'on' in matchingTrigger ? matchingTrigger.on : undefined; const condition = onBlock && typeof onBlock === 'object' && onBlock !== null && 'condition' in onBlock From c15edc31aaab9217d3a6b86c06b391fa100d4f11 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 13 Jul 2026 17:05:30 +0200 Subject: [PATCH 06/19] editor resolver + autocomplete --- .../spec/workflow_surface/index.ts | 1 + ...e_connector_event_workflow_surface.test.ts | 67 +++++++++++++++ ...esolve_connector_event_workflow_surface.ts | 24 ++++++ .../autocomplete/context/triggers_utils.ts | 25 ++++++ ...registered_trigger_condition_definition.ts | 15 +--- .../get_connector_id_suggestions.test.ts | 82 +++++++++++++++++- .../get_connector_id_suggestions.ts | 21 +++-- .../connector_id_provider.test.ts | 58 +++++++++++++ .../workflow_surface/connector_id_provider.ts | 47 +++++++++++ .../kql_filter_provider.test.ts | 82 ++++++++++++++++++ .../workflow_surface/kql_filter_provider.ts | 54 ++++++++++++ .../resolve_surface_at_path.test.ts | 84 +++++++++++++++++++ .../resolve_surface_at_path.ts | 72 ++++++++++++++++ 13 files changed, 614 insertions(+), 18 deletions(-) create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts index 6865ec7657c83..14891a1ff3706 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts @@ -21,3 +21,4 @@ export { type ConnectorEventTriggerDefinition, } from './connector_event_to_trigger_definition'; export { resolveConnectorEventTriggerDefinition } from './resolve_connector_event_trigger_definition'; +export { resolveConnectorEventWorkflowSurface } from './resolve_connector_event_workflow_surface'; diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts new file mode 100644 index 0000000000000..d2aca05034dce --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; + +const mockInboundWebhookReceivedEventSchema = z.object({ + connectorId: z.string(), + body: z.unknown(), +}); + +const mockInboundWebhookReceivedEvent = { + eventId: 'inboundWebhook.received', + eventKey: 'received', + connectorTypeId: '.inboundWebhook', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + eventSchema: mockInboundWebhookReceivedEventSchema, +}; + +jest.mock('@kbn/connector-specs', () => { + const actual = jest.requireActual('@kbn/connector-specs'); + return { + ...actual, + resolveRegisteredConnectorEventByEventId: jest.fn((eventId: string) => + eventId === 'inboundWebhook.received' ? mockInboundWebhookReceivedEvent : undefined + ), + }; +}); + +import { resolveConnectorEventWorkflowSurface } from './resolve_connector_event_workflow_surface'; + +describe('resolveConnectorEventWorkflowSurface', () => { + it('returns a trigger surface with required connector binding and KQL filter schema', () => { + const surface = resolveConnectorEventWorkflowSurface('inboundWebhook.received'); + + expect(surface).toMatchObject({ + id: 'inboundWebhook.received', + kind: 'trigger', + binding: { + connectorTypeId: '.inboundWebhook', + instanceRef: 'required', + }, + surfaces: { + filter: { + language: 'kql', + yamlPath: 'on.condition', + }, + }, + source: { + type: 'connector-event', + connectorTypeId: '.inboundWebhook', + eventKey: 'received', + }, + }); + expect(surface?.surfaces.filter?.schema).toBe(mockInboundWebhookReceivedEventSchema); + }); + + it('returns undefined for unknown event ids', () => { + expect(resolveConnectorEventWorkflowSurface('unknown.event')).toBeUndefined(); + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts new file mode 100644 index 0000000000000..5e93500e72ba2 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; +import { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; +import type { WorkflowSurfaceDefinition } from './types'; + +/** Resolves a connector-event trigger id to its workflow surface (from ConnectorSpec.events). */ +export const resolveConnectorEventWorkflowSurface = ( + eventId: string +): WorkflowSurfaceDefinition | undefined => { + const event = resolveRegisteredConnectorEventByEventId(eventId); + if (!event) { + return undefined; + } + + return connectorEventToWorkflowSurface(event); +}; diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/context/triggers_utils.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/context/triggers_utils.ts index de1347691789e..e3f7bd520bff5 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/context/triggers_utils.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/context/triggers_utils.ts @@ -28,6 +28,31 @@ export function getTriggerConditionBlockIndex(path: (string | number)[]): number return path[1]; } +/** + * When the YAML path is `triggers[i].connector-id`, returns `i`. Otherwise `null`. + */ +export function getTriggerConnectorIdBlockIndex(path: (string | number)[]): number | null { + if ( + path.length >= 3 && + path[0] === 'triggers' && + typeof path[1] === 'number' && + path[2] === 'connector-id' + ) { + return path[1]; + } + return null; +} + +/** + * When the YAML path is under `triggers[i]`, returns `i`. Otherwise `null`. + */ +export function getTriggerBlockIndex(path: (string | number)[]): number | null { + if (path.length >= 2 && path[0] === 'triggers' && typeof path[1] === 'number') { + return path[1]; + } + return null; +} + /** * Reads `triggers[triggerIndex].type` from the parsed YAML document. */ diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/get_registered_trigger_condition_definition.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/get_registered_trigger_condition_definition.ts index 7eb94f444d913..fc4ae54f92284 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/get_registered_trigger_condition_definition.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/get_registered_trigger_condition_definition.ts @@ -9,24 +9,15 @@ import type { Document } from 'yaml'; import type { PublicTriggerDefinition } from '@kbn/workflows-extensions/public'; -import { getTriggerConditionBlockIndex, getTriggerTypeAtIndex } from './context/triggers_utils'; -import { triggerSchemas } from '../../../../trigger_schemas'; +import { getTriggerConditionDefinition } from '../../../../workflow_surface/kql_filter_provider'; /** * When `path` is `triggers[i].on.condition` and `triggers[i].type` resolves to a trigger registered - * in workflows extensions, returns its public definition; otherwise `undefined`. + * in workflows extensions or a connector-event trigger, returns its public definition; otherwise `undefined`. */ export function getRegisteredTriggerConditionDefinition( yamlDocument: Document, path: (string | number)[] ): PublicTriggerDefinition | undefined { - const blockIndex = getTriggerConditionBlockIndex(path); - if (blockIndex === null) { - return undefined; - } - const triggerType = getTriggerTypeAtIndex(yamlDocument, blockIndex); - if (triggerType === null) { - return undefined; - } - return triggerSchemas.getTriggerDefinition(triggerType); + return getTriggerConditionDefinition(yamlDocument, path); } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts index fc3c65472c586..82d5c1c70ff3b 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts @@ -7,12 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { parseDocument } from 'yaml'; import type { ConnectorTypeInfo } from '@kbn/workflows'; import { parseLineForCompletion } from '@kbn/workflows-yaml'; import { getConnectorIdSuggestions } from './get_connector_id_suggestions'; +import { resolveSurfaceAtPath } from '../../../../../../workflow_surface/resolve_surface_at_path'; import type { AutocompleteContext } from '../../context/autocomplete.types'; +jest.mock('../../../../../../workflow_surface/resolve_surface_at_path', () => ({ + resolveSurfaceAtPath: jest.fn(), +})); + +const mockResolveSurfaceAtPath = resolveSurfaceAtPath as jest.MockedFunction< + typeof resolveSurfaceAtPath +>; + describe('getConnectorIdSuggestions', () => { + beforeEach(() => { + mockResolveSurfaceAtPath.mockReturnValue(undefined); + }); + const fakeConnectorTypes: Record = { '.slack': { actionTypeId: '.slack', @@ -47,8 +61,10 @@ describe('getConnectorIdSuggestions', () => { lineParseResult: null, range: { startLineNumber: 1, endLineNumber: 1, startColumn: 1, endColumn: 1 }, focusedStepInfo: null, + path: [], + yamlDocument: parseDocument(''), dynamicConnectorTypes: null, - } as AutocompleteContext); + } as unknown as AutocompleteContext); expect(result).toEqual([]); }); @@ -61,6 +77,7 @@ describe('getConnectorIdSuggestions', () => { focusedStepInfo: { stepType: 'slack' }, focusedYamlPair: null, path: ['steps', 0, 'connector-id'], + yamlDocument: parseDocument('steps:\n - type: slack\n connector-id: '), dynamicConnectorTypes: fakeConnectorTypes, } as unknown as AutocompleteContext); @@ -84,10 +101,73 @@ describe('getConnectorIdSuggestions', () => { path: ['with', 'channels', 'slack', 'connector-id'], }, path: ['steps', 0, 'with', 'channels', 'slack', 'connector-id'], + yamlDocument: parseDocument('steps:\n - type: waitForApproval\n'), dynamicConnectorTypes: fakeConnectorTypes, } as unknown as AutocompleteContext); expect(result).toHaveLength(3); expect(result[0].insertText).toBe('public-slack'); }); + + it('should suggest connector instances for connector-event trigger connector-id fields', () => { + mockResolveSurfaceAtPath.mockReturnValue({ + role: 'connector-id', + surface: { + id: 'inboundWebhook.received', + kind: 'trigger', + title: 'Webhook received', + description: 'desc', + stability: 'tech_preview', + binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, + surfaces: {}, + }, + }); + + const fakeConnectorTypesWithInboundWebhook: Record = { + ...fakeConnectorTypes, + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + subActions: [], + instances: [ + { + id: 'sales-ingress', + name: 'Sales Ingress', + isPreconfigured: false, + isDeprecated: false, + }, + ], + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + }, + ], + }, + }; + + const line = ' connector-id: '; + const yamlDocument = parseDocument(`triggers: + - type: inboundWebhook.received + connector-id: `); + + const result = getConnectorIdSuggestions({ + line, + lineParseResult: parseLineForCompletion(line), + range: { startLineNumber: 1, endLineNumber: 1, startColumn: 1, endColumn: line.length + 1 }, + focusedStepInfo: null, + focusedYamlPair: null, + path: ['triggers', 0, 'connector-id'], + yamlDocument, + dynamicConnectorTypes: fakeConnectorTypesWithInboundWebhook, + } as unknown as AutocompleteContext); + + expect(result.some((item) => item.insertText === 'sales-ingress')).toBe(true); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts index 8fa2e6e11e0ff..38d8f57d7f1e4 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts @@ -8,7 +8,7 @@ */ import { getConnectorIdSuggestionsItems } from './get_connector_id_suggestions_items'; -import { resolveConnectorIdStepType } from './resolve_connector_id_step_type'; +import { resolveConnectorIdActionTypeId } from '../../../../../../workflow_surface/connector_id_provider'; import type { AutocompleteContext } from '../../context/autocomplete.types'; export function getConnectorIdSuggestions({ @@ -18,18 +18,25 @@ export function getConnectorIdSuggestions({ focusedStepInfo, focusedYamlPair, path, + yamlDocument, dynamicConnectorTypes, }: AutocompleteContext) { - const stepConnectorType = resolveConnectorIdStepType(focusedStepInfo, path, focusedYamlPair); + const connectorActionTypeId = resolveConnectorIdActionTypeId({ + yamlDocument, + path, + focusedStepInfo, + focusedYamlPair, + }); if ( - !stepConnectorType || + !connectorActionTypeId || !lineParseResult || lineParseResult.matchType !== 'connector-id' || !dynamicConnectorTypes ) { return []; } + // If the user has typed part of the connector-id, we replace from the start of the value to the end of the line if (lineParseResult.fullKey !== '') { const replaceRange = { @@ -37,8 +44,12 @@ export function getConnectorIdSuggestions({ startColumn: lineParseResult.valueStartIndex + 1, endColumn: line.length + 1, }; - return getConnectorIdSuggestionsItems(stepConnectorType, replaceRange, dynamicConnectorTypes); + return getConnectorIdSuggestionsItems( + connectorActionTypeId, + replaceRange, + dynamicConnectorTypes + ); } - return getConnectorIdSuggestionsItems(stepConnectorType, range, dynamicConnectorTypes); + return getConnectorIdSuggestionsItems(connectorActionTypeId, range, dynamicConnectorTypes); } diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts new file mode 100644 index 0000000000000..b068abe517983 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { parseDocument } from 'yaml'; +import { resolveConnectorIdActionTypeId } from './connector_id_provider'; +import type { StepInfo } from '../entities/workflows/store'; + +jest.mock('./resolve_surface_at_path', () => ({ + resolveSurfaceAtPath: jest.fn((_, path: (string | number)[]) => + path[0] === 'triggers' && path[2] === 'connector-id' + ? { + role: 'connector-id', + surface: { + binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, + }, + } + : undefined + ), +})); + +describe('resolveConnectorIdActionTypeId', () => { + it('uses connector-event surface binding for trigger connector-id fields', () => { + const yamlDocument = parseDocument(`triggers: + - type: inboundWebhook.received + connector-id: `); + + expect( + resolveConnectorIdActionTypeId({ + yamlDocument, + path: ['triggers', 0, 'connector-id'], + focusedStepInfo: null, + focusedYamlPair: null, + }) + ).toBe('.inboundWebhook'); + }); + + it('falls back to step connector type resolution', () => { + const yamlDocument = parseDocument(`steps: + - name: notify + type: slack + connector-id: `); + + expect( + resolveConnectorIdActionTypeId({ + yamlDocument, + path: ['steps', 0, 'connector-id'], + focusedStepInfo: { stepType: 'slack' } as StepInfo, + focusedYamlPair: null, + }) + ).toBe('slack'); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts new file mode 100644 index 0000000000000..2ca396479d071 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Document } from 'yaml'; +import type { WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { resolveSurfaceAtPath } from './resolve_surface_at_path'; +import type { StepInfo, StepPropInfo } from '../entities/workflows/store'; +import { resolveConnectorIdStepType } from '../widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/resolve_connector_id_step_type'; + +export interface ConnectorIdProviderContext { + readonly yamlDocument: Document; + readonly path: ReadonlyArray; + readonly focusedStepInfo: StepInfo | null; + readonly focusedYamlPair: StepPropInfo | null; +} + +/** + * Resolves the action type id used to filter connector instances for `connector-id` autocomplete. + * Prefers connector-event trigger surfaces; falls back to legacy step connector resolution. + */ +export const resolveConnectorIdActionTypeId = ({ + yamlDocument, + path, + focusedStepInfo, + focusedYamlPair, +}: ConnectorIdProviderContext): string | null => { + if (path.length > 0) { + const resolvedSurface = resolveSurfaceAtPath(yamlDocument, [...path]); + const connectorTypeId = resolvedSurface?.surface.binding.connectorTypeId; + if (connectorTypeId) { + return connectorTypeId; + } + } + + return resolveConnectorIdStepType(focusedStepInfo, path, focusedYamlPair); +}; + +export const resolveConnectorIdSurface = ( + yamlDocument: Document, + path: ReadonlyArray +): WorkflowSurfaceDefinition | undefined => resolveSurfaceAtPath(yamlDocument, [...path])?.surface; diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts new file mode 100644 index 0000000000000..0cc0c3e023055 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { parseDocument } from 'yaml'; +import { z } from '@kbn/zod/v4'; +import { getTriggerConditionDefinition } from './kql_filter_provider'; +import { triggerSchemas } from '../trigger_schemas'; + +const CONDITION_PATH = ['triggers', 0, 'on', 'condition'] as const; + +const mockConnectorEventSchema = z.object({ + connectorId: z.string(), + body: z.unknown(), +}); + +jest.mock('@kbn/workflows', () => { + const actual = jest.requireActual('@kbn/workflows'); + return { + ...actual, + resolveConnectorEventTriggerDefinition: jest.fn((triggerId: string) => + triggerId === 'inboundWebhook.received' + ? { + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + stability: 'tech_preview', + eventSchema: mockConnectorEventSchema, + } + : undefined + ), + }; +}); + +describe('getTriggerConditionDefinition', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns connector-event trigger definitions with eventSchema for KQL autocomplete', () => { + jest.spyOn(triggerSchemas, 'getTriggerDefinition').mockReturnValue(undefined); + + const doc = parseDocument(`triggers: + - type: inboundWebhook.received + connector-id: sales-ingress + on: + condition: "event.body.eventType: *" +`); + + expect(getTriggerConditionDefinition(doc, [...CONDITION_PATH])).toEqual({ + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + stability: 'tech_preview', + eventSchema: mockConnectorEventSchema, + }); + }); + + it('prefers workflows_extensions trigger definitions', () => { + const registered = { + id: 'cases.updated', + stability: 'tech_preview' as const, + title: 'Cases updated', + description: 'Cases updated', + eventSchema: z.object({ severity: z.string() }), + }; + jest.spyOn(triggerSchemas, 'getTriggerDefinition').mockReturnValue(registered); + + const doc = parseDocument(`triggers: + - type: cases.updated + on: + condition: "event.severity: *" +`); + + expect(getTriggerConditionDefinition(doc, [...CONDITION_PATH])).toBe(registered); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts new file mode 100644 index 0000000000000..f13dd80404e08 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Document } from 'yaml'; +import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; +import type { PublicTriggerDefinition } from '@kbn/workflows-extensions/public'; +import { triggerSchemas } from '../trigger_schemas'; +import { + getTriggerConditionBlockIndex, + getTriggerTypeAtIndex, +} from '../widgets/workflow_yaml_editor/lib/autocomplete/context/triggers_utils'; + +/** + * Returns the trigger definition used for KQL autocomplete on `triggers[i].on.condition`. + * Checks workflows_extensions first, then connector-event triggers from ConnectorSpec.events. + */ +export const getTriggerConditionDefinition = ( + yamlDocument: Document, + path: (string | number)[] +): PublicTriggerDefinition | undefined => { + const blockIndex = getTriggerConditionBlockIndex(path); + if (blockIndex === null) { + return undefined; + } + + const triggerType = getTriggerTypeAtIndex(yamlDocument, blockIndex); + if (!triggerType) { + return undefined; + } + + const registered = triggerSchemas.getTriggerDefinition(triggerType); + if (registered) { + return registered; + } + + const connectorEventDefinition = resolveConnectorEventTriggerDefinition(triggerType); + if (!connectorEventDefinition) { + return undefined; + } + + return { + id: connectorEventDefinition.id, + title: connectorEventDefinition.title, + description: connectorEventDefinition.description, + stability: connectorEventDefinition.stability, + eventSchema: connectorEventDefinition.eventSchema, + }; +}; diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts new file mode 100644 index 0000000000000..9256ce40f75cd --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { parseDocument } from 'yaml'; +import { z } from '@kbn/zod/v4'; + +const mockInboundWebhookReceivedEventSchema = z.object({ + connectorId: z.string(), + body: z.unknown(), +}); + +jest.mock('@kbn/workflows', () => { + const actual = jest.requireActual('@kbn/workflows'); + return { + ...actual, + resolveConnectorEventWorkflowSurface: jest.fn((eventId: string) => + eventId === 'inboundWebhook.received' + ? { + id: 'inboundWebhook.received', + kind: 'trigger', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + stability: 'tech_preview', + binding: { + connectorTypeId: '.inboundWebhook', + instanceRef: 'required', + }, + surfaces: { + filter: { + schema: mockInboundWebhookReceivedEventSchema, + language: 'kql', + yamlPath: 'on.condition', + }, + }, + } + : undefined + ), + }; +}); + +import { resolveSurfaceAtPath } from './resolve_surface_at_path'; + +describe('resolveSurfaceAtPath', () => { + const yaml = `triggers: + - type: inboundWebhook.received + connector-id: sales-ingress + on: + condition: 'event.body.eventType: "order.created"' +`; + + it('resolves connector-id role on triggers[i].connector-id', () => { + const doc = parseDocument(yaml); + expect(resolveSurfaceAtPath(doc, ['triggers', 0, 'connector-id'])).toEqual({ + role: 'connector-id', + surface: expect.objectContaining({ + id: 'inboundWebhook.received', + binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, + }), + }); + }); + + it('resolves kql-filter role on triggers[i].on.condition', () => { + const doc = parseDocument(yaml); + expect(resolveSurfaceAtPath(doc, ['triggers', 0, 'on', 'condition'])).toEqual({ + role: 'kql-filter', + surface: expect.objectContaining({ id: 'inboundWebhook.received' }), + }); + }); + + it('returns undefined for plain custom triggers', () => { + const doc = parseDocument(`triggers: + - type: cases.updated + on: + condition: 'event.severity: "high"' +`); + expect(resolveSurfaceAtPath(doc, ['triggers', 0, 'on', 'condition'])).toBeUndefined(); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts new file mode 100644 index 0000000000000..1b2312b0b2b11 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Document } from 'yaml'; +import type { WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; +import { + getTriggerBlockIndex, + getTriggerConditionBlockIndex, + getTriggerConnectorIdBlockIndex, + getTriggerTypeAtIndex, +} from '../widgets/workflow_yaml_editor/lib/autocomplete/context/triggers_utils'; + +export type SurfaceCursorRole = 'trigger' | 'connector-id' | 'kql-filter'; + +export interface ResolvedSurfaceAtPath { + readonly surface: WorkflowSurfaceDefinition; + readonly role: SurfaceCursorRole; +} + +const resolveSurfaceRole = (path: (string | number)[]): SurfaceCursorRole | undefined => { + if (getTriggerConnectorIdBlockIndex(path) !== null) { + return 'connector-id'; + } + if (getTriggerConditionBlockIndex(path) !== null) { + return 'kql-filter'; + } + if (getTriggerBlockIndex(path) !== null) { + return 'trigger'; + } + return undefined; +}; + +/** + * Resolves the workflow surface for a connector-event trigger at the YAML cursor path. + * v1: trigger blocks only; step surfaces fall back to legacy connector contracts. + */ +export const resolveSurfaceAtPath = ( + yamlDocument: Document, + path: (string | number)[] +): ResolvedSurfaceAtPath | undefined => { + const role = resolveSurfaceRole(path); + if (!role) { + return undefined; + } + + const triggerIndex = + getTriggerConnectorIdBlockIndex(path) ?? + getTriggerConditionBlockIndex(path) ?? + getTriggerBlockIndex(path); + if (triggerIndex === null) { + return undefined; + } + + const triggerType = getTriggerTypeAtIndex(yamlDocument, triggerIndex); + if (!triggerType) { + return undefined; + } + + const surface = resolveConnectorEventWorkflowSurface(triggerType); + if (!surface) { + return undefined; + } + + return { surface, role }; +}; From f91b54709a8e4165e3a4ed7c4d9499b2a725e7b7 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 13 Jul 2026 17:37:34 +0200 Subject: [PATCH 07/19] cleaning spec connectors plugin --- .../connector_event_to_workflow_surface.test.ts | 17 +++++------------ ...lve_connector_event_workflow_surface.test.ts | 7 ++----- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts index 8b59413f24f7e..ffa96b37ae1b8 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts @@ -7,9 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { defineConnectorEvent, toRegisteredConnectorEvent } from '@kbn/connector-specs'; +import { + defineConnectorEvent, + inboundWebhookReceivedEventSchema, + toRegisteredConnectorEvent, +} from '@kbn/connector-specs'; import type { ConnectorMetadata } from '@kbn/connector-specs'; -import { z } from '@kbn/zod/v4'; import { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; const inboundWebhookMetadata: ConnectorMetadata = { @@ -20,16 +23,6 @@ const inboundWebhookMetadata: ConnectorMetadata = { supportedFeatureIds: ['workflows'], }; -const inboundWebhookReceivedEventSchema = z.object({ - connectorId: z.string(), - connectorTypeId: z.string(), - method: z.string(), - headers: z.record(z.string(), z.union([z.string(), z.array(z.string())])), - query: z.record(z.string(), z.string()).optional(), - body: z.unknown(), - receivedAt: z.string(), -}); - describe('connectorEventToWorkflowSurface', () => { it('maps inboundWebhook.received to a trigger surface with required connector binding', () => { const registered = toRegisteredConnectorEvent( diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts index d2aca05034dce..62e3d1a140872 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts @@ -7,12 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { z } from '@kbn/zod/v4'; +import { inboundWebhookReceivedEventSchema } from '@kbn/connector-specs'; -const mockInboundWebhookReceivedEventSchema = z.object({ - connectorId: z.string(), - body: z.unknown(), -}); +const mockInboundWebhookReceivedEventSchema = inboundWebhookReceivedEventSchema; const mockInboundWebhookReceivedEvent = { eventId: 'inboundWebhook.received', From bc09716c5bd2e18ae609e738d78140d4731ce213 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Mon, 13 Jul 2026 17:59:01 +0200 Subject: [PATCH 08/19] connector-id required for connectorEventTriggers --- .../packages/shared/kbn-workflows/index.ts | 3 + ...onnector_events_for_trigger_schema.test.ts | 99 +++++++++++++++++++ ...ect_connector_events_for_trigger_schema.ts | 81 +++++++++++++++ .../spec/schema/triggers/index.ts | 5 + .../common/trigger_registry/types.ts | 8 ++ .../lib/map_registered_triggers_for_schema.ts | 27 +++++ .../workflows_management/common/schema.ts | 14 ++- .../workflows/store/workflow_detail/slice.ts | 6 +- .../thunks/load_connectors_thunk.ts | 3 +- ...kflow_change_history_preview_validation.ts | 6 +- .../model/use_workflow_json_schema.ts | 7 +- .../get_trigger_type_suggestions.ts | 1 + .../snippets/generate_trigger_snippet.test.ts | 50 ++++++++++ .../lib/snippets/generate_trigger_snippet.ts | 27 ++++- .../lib/snippets/insert_trigger_snippet.ts | 4 +- .../ui/workflow_yaml_editor.tsx | 3 +- .../server/services/workflow_crud_service.ts | 10 +- .../services/workflow_validation_service.ts | 7 +- 18 files changed, 338 insertions(+), 23 deletions(-) create mode 100644 src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts create mode 100644 src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts diff --git a/src/platform/packages/shared/kbn-workflows/index.ts b/src/platform/packages/shared/kbn-workflows/index.ts index 285a757a59620..2631d61682cd2 100644 --- a/src/platform/packages/shared/kbn-workflows/index.ts +++ b/src/platform/packages/shared/kbn-workflows/index.ts @@ -88,6 +88,9 @@ export { getTriggerSchema, getTriggerSchemaFromConnectorEvents, collectConnectorEventsFromTypes, + collectConnectorEventsForTriggerSchema, + type RegisteredTriggerForSchema, + type RegisteredTriggerSchemaArg, TriggerTypes, WORKFLOW_EVENTS_VALUES_SET, WorkflowEventsSchema, diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.test.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.test.ts new file mode 100644 index 0000000000000..2b9001652b62a --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getTriggerSchema } from '.'; +import { collectConnectorEventsForTriggerSchema } from './collect_connector_events_for_trigger_schema'; +import type { ConnectorEventInfo } from '../../../types/latest'; + +const inboundWebhookReceived: ConnectorEventInfo = { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an inbound webhook request is received', +}; + +describe('collectConnectorEventsForTriggerSchema', () => { + it('merges connector events from registered triggers that require connector-id', () => { + const result = collectConnectorEventsForTriggerSchema({}, [ + { + id: 'exampleInboundWebhook.received', + title: 'Example webhook received', + description: 'Dev-only trigger', + stability: 'tech_preview', + requiresConnectorId: true, + }, + ]); + + expect(result.connectorEvents).toEqual([ + { + eventKey: 'received', + eventId: 'exampleInboundWebhook.received', + title: 'Example webhook received', + description: 'Dev-only trigger', + stability: 'tech_preview', + }, + ]); + }); + + it('requires connector-id in the trigger schema for extension-registered connector events', () => { + const { connectorEvents, customTriggerIds } = collectConnectorEventsForTriggerSchema({}, [ + { + id: 'exampleInboundWebhook.received', + title: 'Example webhook received', + description: 'Dev-only trigger', + requiresConnectorId: true, + }, + ]); + + const triggerSchema = getTriggerSchema(customTriggerIds, connectorEvents); + + expect( + triggerSchema.safeParse({ + type: 'exampleInboundWebhook.received', + on: { condition: 'event.body: *' }, + }).success + ).toBe(false); + + expect( + triggerSchema.safeParse({ + type: 'exampleInboundWebhook.received', + 'connector-id': 'example-inbound-webhook', + on: { condition: 'event.body: *' }, + }).success + ).toBe(true); + }); + + it('does not duplicate connector events already discovered from connector types', () => { + const result = collectConnectorEventsForTriggerSchema( + { + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + subActions: [], + events: [inboundWebhookReceived], + }, + }, + [ + { + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Duplicate registration', + requiresConnectorId: true, + }, + ] + ); + + expect(result.connectorEvents).toEqual([inboundWebhookReceived]); + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts new file mode 100644 index 0000000000000..3acc7a700845a --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; +import { collectConnectorEventsFromTypes } from './collect_connector_events_from_types'; +import type { ConnectorEventInfo, ConnectorTypeInfo, StabilityLevel } from '../../../types/latest'; + +export interface RegisteredTriggerForSchema { + readonly id: string; + readonly title: string; + readonly description: string; + readonly stability?: StabilityLevel; + readonly requiresConnectorId?: boolean; +} + +export type RegisteredTriggerSchemaArg = string | RegisteredTriggerForSchema; + +const toRegisteredTriggerForSchema = ( + trigger: RegisteredTriggerSchemaArg +): RegisteredTriggerForSchema => + typeof trigger === 'string' + ? { + id: trigger, + title: trigger, + description: '', + } + : trigger; + +const eventKeyFromTriggerId = (triggerId: string): string => { + const separatorIndex = triggerId.lastIndexOf('.'); + return separatorIndex === -1 ? triggerId : triggerId.slice(separatorIndex + 1); +}; + +/** + * Builds trigger schema input by merging connector events from connector types, + * registered connector specs, and extension triggers that require connector binding. + */ +export const collectConnectorEventsForTriggerSchema = ( + connectorTypes: Record, + registeredTriggers: RegisteredTriggerSchemaArg[] = [] +): { customTriggerIds: string[]; connectorEvents: ConnectorEventInfo[] } => { + const normalizedTriggers = registeredTriggers.map(toRegisteredTriggerForSchema); + const connectorEvents = [...collectConnectorEventsFromTypes(connectorTypes)]; + const connectorEventIds = new Set(connectorEvents.map((event) => event.eventId)); + + for (const trigger of normalizedTriggers) { + if (!connectorEventIds.has(trigger.id)) { + const fromSpec = resolveRegisteredConnectorEventByEventId(trigger.id); + if (fromSpec) { + connectorEvents.push({ + eventKey: fromSpec.eventKey, + eventId: fromSpec.eventId, + title: fromSpec.title, + description: fromSpec.description, + stability: fromSpec.stability, + }); + connectorEventIds.add(fromSpec.eventId); + } else if (trigger.requiresConnectorId) { + connectorEvents.push({ + eventKey: eventKeyFromTriggerId(trigger.id), + eventId: trigger.id, + title: trigger.title, + description: trigger.description, + stability: trigger.stability, + }); + connectorEventIds.add(trigger.id); + } + } + } + + return { + customTriggerIds: normalizedTriggers.map((trigger) => trigger.id), + connectorEvents, + }; +}; diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts index 55c63c9c503d8..0ebab574ad0e7 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/index.ts @@ -33,6 +33,11 @@ export { getTriggerSchemaFromConnectorEvents, } from './connector_event_trigger_schema'; export { collectConnectorEventsFromTypes } from './collect_connector_events_from_types'; +export { + collectConnectorEventsForTriggerSchema, + type RegisteredTriggerForSchema, + type RegisteredTriggerSchemaArg, +} from './collect_connector_events_for_trigger_schema'; export const TriggerSchema = z.discriminatedUnion('type', [ AlertRuleTriggerSchema, diff --git a/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts b/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts index ab93ec35e455a..47f7a394b0c4c 100644 --- a/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts +++ b/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts @@ -34,6 +34,10 @@ export interface TriggerSnippets { * Must be valid KQL and only reference properties from the event schema (validated at registration). */ condition?: string; + /** + * Connector instance id pre-filled in `connector-id` for connector-event triggers. + */ + connectorId?: string; } /** @@ -71,6 +75,10 @@ export interface CommonTriggerDefinition +): RegisteredTriggerForSchema[] => + triggers.map((trigger) => ({ + id: trigger.id, + title: trigger.title, + description: trigger.description, + stability: trigger.stability, + requiresConnectorId: trigger.requiresConnectorId, + })); diff --git a/src/platform/plugins/shared/workflows_management/common/schema.ts b/src/platform/plugins/shared/workflows_management/common/schema.ts index 10b5fa62b442d..60b999f58c8f4 100644 --- a/src/platform/plugins/shared/workflows_management/common/schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/schema.ts @@ -11,12 +11,13 @@ import type { BaseConnectorContract, ConnectorContractUnion, ConnectorTypeInfo, + RegisteredTriggerSchemaArg, StepDeprecationInfo, StepPropertyHandler, } from '@kbn/workflows'; import { builtInStepDefinitions, - collectConnectorEventsFromTypes, + collectConnectorEventsForTriggerSchema, DEPRECATED_STEP_METADATA, generateLightweightYamlSchema, generateYamlSchemaFromConnectors, @@ -462,18 +463,15 @@ export function getAllConnectorsWithDynamic( const getWorkflowTriggerSchemaInput = ( dynamicConnectorTypes: Record, - registeredTriggerIds: string[] = [] -) => ({ - customTriggerIds: registeredTriggerIds, - connectorEvents: collectConnectorEventsFromTypes(dynamicConnectorTypes), -}); + registeredTriggers: RegisteredTriggerSchemaArg[] = [] +) => collectConnectorEventsForTriggerSchema(dynamicConnectorTypes, registeredTriggers); export const getWorkflowZodSchema = ( dynamicConnectorTypes: Record, - registeredTriggerIds: string[] = [], + registeredTriggers: RegisteredTriggerSchemaArg[] = [], options: WorkflowZodSchemaOptions = {} ): z.ZodType => { - const triggerInput = getWorkflowTriggerSchemaInput(dynamicConnectorTypes, registeredTriggerIds); + const triggerInput = getWorkflowTriggerSchemaInput(dynamicConnectorTypes, registeredTriggers); if (options.lightweight) { return generateLightweightYamlSchema(triggerInput); diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts index 9361fbe5c5cc3..8d7feb3142856 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/slice.ts @@ -13,6 +13,7 @@ import { WORKFLOW_GRAPH_FOCUS_TRIGGER } from '@kbn/workflows'; import type { ActiveTab, ComputedData, LineColumnPosition, WorkflowDetailState } from './types'; import { addLoadingStateReducers, initialLoadingState } from './utils/loading_states'; import { resolveFocusForLine } from './utils/trigger_finder'; +import { mapRegisteredTriggersForSchema } from '../../../../../common/lib/map_registered_triggers_for_schema'; import { getWorkflowZodSchema } from '../../../../../common/schema'; import { triggerSchemas } from '../../../../trigger_schemas'; import type { WorkflowsResponse } from '../../model/types'; @@ -40,7 +41,10 @@ const initialState: WorkflowDetailState = { activeTab: undefined, connectors: undefined, workflows: initialWorkflowsState, - schema: getWorkflowZodSchema({}, triggerSchemas.getRegisteredIds()), + schema: getWorkflowZodSchema( + {}, + mapRegisteredTriggersForSchema(triggerSchemas.getTriggerDefinitions()) + ), cursorPosition: undefined, focusedStepId: undefined, focusedTriggerId: undefined, diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_connectors_thunk.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_connectors_thunk.ts index 0a1d1ac3a0445..80ec6ccfccc2c 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_connectors_thunk.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/load_connectors_thunk.ts @@ -10,6 +10,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { i18n } from '@kbn/i18n'; import { WorkflowApi } from '@kbn/workflows-ui'; +import { mapRegisteredTriggersForSchema } from '../../../../../../common/lib/map_registered_triggers_for_schema'; import { addDynamicConnectorsToCache, getWorkflowZodSchema } from '../../../../../../common/schema'; import { triggerSchemas } from '../../../../../trigger_schemas'; import type { WorkflowsServices } from '../../../../../types'; @@ -47,7 +48,7 @@ export const loadConnectorsThunk = createAsyncThunk< const schema = getWorkflowZodSchema( currentConnectorTypes, - triggerSchemas.getRegisteredIds() + mapRegisteredTriggersForSchema(triggerSchemas.getTriggerDefinitions()) ); dispatch(_setGeneratedSchemaInternal(schema)); } diff --git a/src/platform/plugins/shared/workflows_management/public/features/change_history/use_workflow_change_history_preview_validation.ts b/src/platform/plugins/shared/workflows_management/public/features/change_history/use_workflow_change_history_preview_validation.ts index fe2fda65fae19..d2461a8b62544 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/change_history/use_workflow_change_history_preview_validation.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/change_history/use_workflow_change_history_preview_validation.ts @@ -38,6 +38,7 @@ import { import { waitForPreviewYamlSchemaMarkers } from './wait_for_yaml_schema_markers_after_update'; import { WORKFLOW_CHANGE_HISTORY_VALIDATION_MARKER_REUSE_MAX_WAIT_MS } from './workflow_change_history_preview_constants'; import type { WorkflowChangeHistoryCompareMode } from './workflow_change_history_preview_settings_popover'; +import { mapRegisteredTriggersForSchema } from '../../../common/lib/map_registered_triggers_for_schema'; import { getWorkflowZodSchema } from '../../../common/schema'; import { useAvailableConnectors } from '../../entities/connectors/model/use_available_connectors'; import { triggerSchemas } from '../../trigger_schemas'; @@ -113,7 +114,10 @@ export const useWorkflowChangeHistoryPreviewValidation = ({ const validationContextRef = useWorkflowYamlValidationContextRef(); const workflowZodSchema = useMemo( () => - getWorkflowZodSchema(connectorsData?.connectorTypes ?? {}, triggerSchemas.getRegisteredIds()), + getWorkflowZodSchema( + connectorsData?.connectorTypes ?? {}, + mapRegisteredTriggersForSchema(triggerSchemas.getTriggerDefinitions()) + ), [connectorsData?.connectorTypes] ); const workflowZodSchemaRef = useRef(workflowZodSchema); diff --git a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/use_workflow_json_schema.ts b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/use_workflow_json_schema.ts index 702aebcbf96bd..a7e9aea93a902 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/use_workflow_json_schema.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/validate_workflow_yaml/model/use_workflow_json_schema.ts @@ -12,6 +12,7 @@ import { useMemo } from 'react'; import { getWorkflowJsonSchema } from '@kbn/workflows'; import type { z } from '@kbn/zod/v4'; +import { mapRegisteredTriggersForSchema } from '../../../../common/lib/map_registered_triggers_for_schema'; import { getWorkflowZodSchema, getWorkflowZodSchemaLoose } from '../../../../common/schema'; import { useAvailableConnectors } from '../../../entities/connectors/model/use_available_connectors'; import { triggerSchemas } from '../../../trigger_schemas'; @@ -53,10 +54,12 @@ export const useWorkflowJsonSchema = ({ : WorkflowSchemaUriStrictWithDynamicConnectors; } const connectorTypes = connectorsData?.connectorTypes ?? {}; - const registeredTriggerIds = triggerSchemas.getRegisteredIds(); + const registeredTriggers = mapRegisteredTriggersForSchema( + triggerSchemas.getTriggerDefinitions() + ); const zodSchema = loose ? getWorkflowZodSchemaLoose(connectorTypes) - : getWorkflowZodSchema(connectorTypes, registeredTriggerIds); // TODO: remove this once we move the schema generation up to detail page or some wrapper component + : getWorkflowZodSchema(connectorTypes, registeredTriggers); // TODO: remove this once we move the schema generation up to detail page or some wrapper component const jsonSchema = getWorkflowJsonSchema(zodSchema); return { diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts index 58a1481872deb..e1dd8031d7e16 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts @@ -75,6 +75,7 @@ export function getTriggerTypeSuggestions( const triggerDef = triggerSchemas.getTriggerDefinition(triggerType.type); const snippetText = generateTriggerSnippet(triggerType.type, { defaultCondition: triggerDef?.snippets?.condition, + defaultConnectorId: triggerDef?.snippets?.connectorId, }); // Extended range for multi-line insertion diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts index 92b6c69a51daf..53c62d6a1fc57 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts @@ -7,9 +7,30 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +jest.mock('../../../../trigger_schemas', () => ({ + triggerSchemas: { + getTriggerDefinition: jest.fn(), + }, +})); + +jest.mock('@kbn/workflows', () => { + const actual = jest.requireActual('@kbn/workflows'); + return { + ...actual, + resolveConnectorEventWorkflowSurface: jest.fn(), + }; +}); + +import { resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; import { generateTriggerSnippet } from './generate_trigger_snippet'; +import { triggerSchemas } from '../../../../trigger_schemas'; describe('generateTriggerSnippet', () => { + beforeEach(() => { + jest.clearAllMocks(); + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue(undefined); + (resolveConnectorEventWorkflowSurface as jest.Mock).mockReturnValue(undefined); + }); describe('built-in trigger types (alert, manual, scheduled)', () => { it('should not include on.condition for alert, manual or scheduled', () => { const builtInTypes = ['alert', 'manual', 'scheduled'] as const; @@ -39,4 +60,33 @@ describe('generateTriggerSnippet', () => { expect(snippet).not.toContain('event.source:ui'); }); }); + + describe('connector-event triggers', () => { + it('includes connector-id when the trigger requires connector binding', () => { + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ + id: 'exampleInboundWebhook.received', + requiresConnectorId: true, + snippets: { + connectorId: 'example-inbound-webhook', + condition: 'event.body: *', + }, + }); + + const snippet = generateTriggerSnippet('exampleInboundWebhook.received', { full: true }); + + expect(snippet).toContain('connector-id: example-inbound-webhook'); + expect(snippet).toContain('condition: "event.body: *"'); + }); + + it('includes connector-id when resolved from connector-event workflow surface', () => { + (resolveConnectorEventWorkflowSurface as jest.Mock).mockReturnValue({ + binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, + }); + + const snippet = generateTriggerSnippet('inboundWebhook.received', { full: true }); + + expect(snippet).toContain('connector-id: '); + expect(snippet).toContain('condition:'); + }); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts index d95874c5d74ae..9d39b8651f8c0 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts @@ -9,7 +9,8 @@ import type { ToStringOptions } from 'yaml'; import { stringify } from 'yaml'; -import { isTriggerType } from '@kbn/workflows'; +import { isTriggerType, resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; +import { triggerSchemas } from '../../../../trigger_schemas'; /** Comment added above condition in custom trigger snippets to explain KQL and event.* usage. */ const CUSTOM_TRIGGER_CONDITION_COMMENT = @@ -21,6 +22,8 @@ interface GenerateTriggerSnippetOptions { withTriggersSection?: boolean; /** Default KQL condition for custom triggers (used when inserting trigger from UI). */ defaultCondition?: string; + /** Default connector instance id for connector-event triggers. */ + defaultConnectorId?: string; } /** @@ -39,11 +42,23 @@ export function generateTriggerSnippet( monacoSuggestionFormat, withTriggersSection, defaultCondition, + defaultConnectorId, }: GenerateTriggerSnippetOptions = {} ): string { const stringifyOptions: ToStringOptions = { indent: 2 }; let parameters: Record; // eslint-disable-line @typescript-eslint/no-explicit-any + const triggerDefinition = triggerSchemas.getTriggerDefinition(triggerType); + const connectorEventSurface = resolveConnectorEventWorkflowSurface(triggerType); + const requiresConnectorId = + connectorEventSurface?.binding.instanceRef === 'required' || + triggerDefinition?.requiresConnectorId === true; + const resolvedConnectorId = + defaultConnectorId ?? + triggerDefinition?.snippets?.connectorId ?? + (monacoSuggestionFormat ? '${1:}' : ''); + const resolvedCondition = defaultCondition ?? triggerDefinition?.snippets?.condition ?? ''; + switch (triggerType) { case 'alert': parameters = {}; @@ -80,9 +95,17 @@ export function generateTriggerSnippet( break; default: + if (requiresConnectorId) { + parameters = { + 'connector-id': resolvedConnectorId, + on: { condition: resolvedCondition }, + }; + break; + } + // Custom triggers: include on/condition so users can add a KQL filter (use defaultCondition when provided) parameters = { - on: { condition: defaultCondition ?? '' }, + on: { condition: resolvedCondition }, }; break; } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts index ec30646140870..322ef4f353968 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts @@ -31,7 +31,8 @@ export function insertTriggerSnippet( yamlDocument: Document | null, triggerType: string, editor?: monaco.editor.IStandaloneCodeEditor, - defaultCondition?: string + defaultCondition?: string, + defaultConnectorId?: string ) { let document: Document; try { @@ -110,6 +111,7 @@ export function insertTriggerSnippet( monacoSuggestionFormat: false, withTriggersSection: insertTriggersSection, defaultCondition, + defaultConnectorId, }); // Create separate undo boundary for each snippet insertion diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index 579b67d7c95be..a5643c5320eb4 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -656,7 +656,8 @@ export const WorkflowYAMLEditor = ({ yamlDocumentCurrent, action.id, editor, - triggerDefinition?.snippets?.condition + triggerDefinition?.snippets?.condition, + triggerDefinition?.snippets?.connectorId ); } else { insertStepSnippet(model, yamlDocumentCurrent, action.id, cursorPosition, editor); diff --git a/src/platform/plugins/shared/workflows_management/server/services/workflow_crud_service.ts b/src/platform/plugins/shared/workflows_management/server/services/workflow_crud_service.ts index 291fec8656446..97c4631556f4a 100644 --- a/src/platform/plugins/shared/workflows_management/server/services/workflow_crud_service.ts +++ b/src/platform/plugins/shared/workflows_management/server/services/workflow_crud_service.ts @@ -38,6 +38,7 @@ import type { WorkflowDocumentGetOptions, WriteWorkflowDocumentWithOccParams, } from './workflow_occ_types'; +import { mapRegisteredTriggersForSchema } from '../../common/lib/map_registered_triggers_for_schema'; import { WORKFLOW_CHANGE_HISTORY_OBJECT_TYPE, WorkflowChangeHistoryAction, @@ -356,11 +357,12 @@ export class WorkflowCrudService { request?: KibanaRequest; yaml: string; }): Promise<{ id: string; workflowData: WorkflowProperties; definition?: WorkflowYaml }> { - const registeredTriggerIds = - this.deps.workflowsExtensions?.getAllTriggerDefinitions().map((t) => t.id) ?? []; + const registeredTriggers = mapRegisteredTriggersForSchema( + this.deps.workflowsExtensions?.getAllTriggerDefinitions() ?? [] + ); let zodSchema: z.ZodType; if (params.lightweightValidation) { - zodSchema = getWorkflowZodSchema({}, registeredTriggerIds, { lightweight: true }); + zodSchema = getWorkflowZodSchema({}, registeredTriggers, { lightweight: true }); } else if (params.request) { zodSchema = await this.deps.validationService.getWorkflowZodSchema( { loose: false }, @@ -368,7 +370,7 @@ export class WorkflowCrudService { params.request ); } else { - zodSchema = getWorkflowZodSchema({}, registeredTriggerIds); + zodSchema = getWorkflowZodSchema({}, registeredTriggers); } const triggerDefinitions = params.lightweightValidation ? undefined diff --git a/src/platform/plugins/shared/workflows_management/server/services/workflow_validation_service.ts b/src/platform/plugins/shared/workflows_management/server/services/workflow_validation_service.ts index 3edcc85de805f..3c91f0ca369e6 100644 --- a/src/platform/plugins/shared/workflows_management/server/services/workflow_validation_service.ts +++ b/src/platform/plugins/shared/workflows_management/server/services/workflow_validation_service.ts @@ -14,6 +14,7 @@ import type { ServerTriggerDefinition } from '@kbn/workflows-extensions/server'; import type { z } from '@kbn/zod/v4'; import type { WorkflowValidationDeps } from './types'; +import { mapRegisteredTriggersForSchema } from '../../common/lib/map_registered_triggers_for_schema'; import { validateWorkflowYaml } from '../../common/lib/validate_workflow_yaml'; import { getWorkflowZodSchema } from '../../common/schema'; import { getAvailableConnectors } from '../api/lib/workflow_connectors'; @@ -53,7 +54,9 @@ export class WorkflowValidationService { request: KibanaRequest ): Promise { const { connectorTypes } = await this.getAvailableConnectors(spaceId, request); - const registeredTriggerIds = this.getRegisteredCustomTriggerDefinitions().map((t) => t.id); - return getWorkflowZodSchema(connectorTypes, registeredTriggerIds); + const registeredTriggers = mapRegisteredTriggersForSchema( + this.getRegisteredCustomTriggerDefinitions() + ); + return getWorkflowZodSchema(connectorTypes, registeredTriggers); } } From a048827d9abfae7b2db76f9ef719d595fb90e9b5 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Tue, 14 Jul 2026 12:44:52 +0200 Subject: [PATCH 09/19] snippets + actions menu --- ...ld_connector_event_trigger_options.test.ts | 135 ++++++++++++++++++ .../build_connector_event_trigger_options.ts | 82 +++++++++++ .../lib/get_action_options.test.ts | 99 ++++++++++++- .../lib/get_action_options.ts | 23 ++- .../ui/actions_menu.test.tsx | 4 + .../get_trigger_type_suggestions.ts | 1 - .../snippets/generate_surface_snippet.test.ts | 34 +++++ .../lib/snippets/generate_surface_snippet.ts | 35 +++++ .../snippets/generate_trigger_snippet.test.ts | 18 ++- .../lib/snippets/generate_trigger_snippet.ts | 4 +- .../ui/workflow_yaml_editor.tsx | 15 +- 11 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts diff --git a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.test.ts b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.test.ts new file mode 100644 index 0000000000000..a2a0757b2170b --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { EuiThemeComputed } from '@elastic/eui'; +import type { ConnectorTypeInfo } from '@kbn/workflows'; +import { buildConnectorEventTriggerOptions } from './build_connector_event_trigger_options'; +import { isActionConnectorOption, isActionOption } from '../types'; + +describe('buildConnectorEventTriggerOptions', () => { + const mockEuiTheme = { + colors: { + vis: { + euiColorVis6: '#color6', + }, + }, + } as unknown as EuiThemeComputed<{}>; + + const inboundWebhookType: ConnectorTypeInfo = { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + subActions: [], + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'When an HTTP request is received on the connector endpoint.', + stability: 'tech_preview', + }, + ], + }; + + it('returns connector-event trigger options with connector type icons', () => { + const result = buildConnectorEventTriggerOptions( + { '.inboundWebhook': inboundWebhookType }, + new Set(), + mockEuiTheme + ); + + expect(result).toHaveLength(1); + const option = result[0]; + expect(option?.id).toBe('inboundWebhook.received'); + expect(option?.label).toBe('Webhook received'); + if (option && isActionConnectorOption(option)) { + expect(option.connectorType).toBe('.inboundWebhook'); + expect(option.stability).toBe('tech_preview'); + } else { + throw new Error('Expected connector-event trigger option'); + } + }); + + it('skips events already registered via workflows_extensions', () => { + const result = buildConnectorEventTriggerOptions( + { '.inboundWebhook': inboundWebhookType }, + new Set(['inboundWebhook.received']), + mockEuiTheme + ); + + expect(result).toHaveLength(0); + }); + + it('returns an empty list when connector types declare no events', () => { + const result = buildConnectorEventTriggerOptions( + { + '.slack': { + actionTypeId: '.slack', + displayName: 'Slack', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + subActions: [], + }, + }, + new Set(), + mockEuiTheme + ); + + expect(result).toHaveLength(0); + }); + + it('groups multiple events from the same namespace', () => { + const connectorTypeWithMultipleEvents: ConnectorTypeInfo = { + ...inboundWebhookType, + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Inbound Webhook - Webhook received', + description: 'When a webhook is received.', + stability: 'tech_preview', + }, + { + eventKey: 'failed', + eventId: 'inboundWebhook.failed', + title: 'Inbound Webhook - Webhook failed', + description: 'When webhook processing fails.', + stability: 'tech_preview', + }, + ], + }; + + const result = buildConnectorEventTriggerOptions( + { '.inboundWebhook': connectorTypeWithMultipleEvents }, + new Set(), + mockEuiTheme + ); + + expect(result).toHaveLength(1); + const group = result[0]; + expect(group?.id).toBe('triggers.inboundWebhook'); + if (group && 'options' in group) { + expect(group.options).toHaveLength(2); + for (const option of group.options) { + if (isActionConnectorOption(option)) { + expect(option.connectorType).toBe('.inboundWebhook'); + } else if (isActionOption(option)) { + throw new Error('Expected nested connector-event options to use connector icons'); + } + } + } + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.ts b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.ts new file mode 100644 index 0000000000000..400c98f354586 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/build_connector_event_trigger_options.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { UseEuiTheme } from '@elastic/eui'; +import type { ConnectorTypeInfo } from '@kbn/workflows'; +import type { PublicTriggerDefinition } from '@kbn/workflows-extensions/public'; +import { z } from '@kbn/zod/v4'; +import { buildRegisteredTriggerOptions } from './build_trigger_options'; +import type { ActionOptionData } from '../types'; +import { isActionGroup } from '../types'; + +const mockEventSchema = z.object({}); + +/** + * Builds trigger menu options from connector types that declare inbound events + * (ConnectorSpec.events), excluding ids already registered via workflows_extensions. + */ +export function buildConnectorEventTriggerOptions( + connectorTypes: Record, + registeredTriggerIds: ReadonlySet, + euiTheme: UseEuiTheme['euiTheme'] +): ActionOptionData[] { + const connectorTypeByEventId = new Map(); + const pseudoTriggers: PublicTriggerDefinition[] = []; + + for (const connectorType of Object.values(connectorTypes)) { + for (const event of connectorType.events ?? []) { + if (!registeredTriggerIds.has(event.eventId)) { + connectorTypeByEventId.set(event.eventId, connectorType.actionTypeId); + pseudoTriggers.push({ + id: event.eventId, + title: event.title, + description: event.description, + stability: event.stability ?? 'tech_preview', + eventSchema: mockEventSchema, + }); + } + } + } + + if (pseudoTriggers.length === 0) { + return []; + } + + return mapOptionsToConnectorEvents( + buildRegisteredTriggerOptions(pseudoTriggers, euiTheme), + connectorTypeByEventId + ); +} + +function mapOptionsToConnectorEvents( + options: ActionOptionData[], + connectorTypeByEventId: ReadonlyMap +): ActionOptionData[] { + return options.map((option) => { + if (isActionGroup(option)) { + return { + ...option, + options: mapOptionsToConnectorEvents(option.options, connectorTypeByEventId), + }; + } + + const connectorType = connectorTypeByEventId.get(option.id); + if (!connectorType) { + return option; + } + + return { + id: option.id, + label: option.label, + description: option.description, + connectorType, + stability: option.stability, + }; + }); +} diff --git a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.test.ts b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.test.ts index d7b1fd3b949ff..9110804b84f94 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.test.ts @@ -8,18 +8,24 @@ */ import type { EuiThemeComputed } from '@elastic/eui'; +import type { ConnectorTypeInfo } from '@kbn/workflows'; import { isDynamicConnector, StepCategory } from '@kbn/workflows'; import type { WorkflowsExtensionsPublicPluginStart } from '@kbn/workflows-extensions/public'; import { workflowsExtensionsMock } from '@kbn/workflows-extensions/public/mocks'; import { z } from '@kbn/zod/v4'; import { flattenOptions, getActionOptions } from './get_action_options'; -import { getAllConnectors, isDeprecatedStepType } from '../../../../common/schema'; +import { + getAllConnectors, + getCachedDynamicConnectorTypes, + isDeprecatedStepType, +} from '../../../../common/schema'; import { triggerSchemas } from '../../../trigger_schemas'; import type { ActionOptionData } from '../types'; -import { isActionGroup, isActionOption } from '../types'; +import { isActionConnectorOption, isActionGroup, isActionOption } from '../types'; jest.mock('../../../../common/schema', () => ({ getAllConnectors: jest.fn(), + getCachedDynamicConnectorTypes: jest.fn(() => ({})), isDeprecatedStepType: jest.fn(() => false), })); jest.mock('../../../trigger_schemas', () => ({ @@ -56,6 +62,7 @@ describe('getActionOptions', () => { mockWorkflowsExtensions = workflowsExtensionsMock.createStart(); (getAllConnectors as jest.Mock).mockReturnValue([]); + (getCachedDynamicConnectorTypes as jest.Mock).mockReturnValue({}); (isDeprecatedStepType as jest.Mock).mockReturnValue(false); (isDynamicConnector as jest.MockedFunction).mockImplementation( () => false @@ -86,6 +93,94 @@ describe('getActionOptions', () => { } }); + it('should include connector-event triggers from dynamic connector types when not registered', () => { + const connectorTypes: Record = { + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + subActions: [], + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'When an HTTP request is received on the connector endpoint.', + stability: 'tech_preview', + }, + ], + }, + }; + + (getCachedDynamicConnectorTypes as jest.Mock).mockReturnValueOnce(connectorTypes); + + const result = getActionOptions(mockEuiTheme, mockWorkflowsExtensions); + const triggersGroup = result.find((group) => group.id === 'triggers'); + + expect(triggersGroup).toBeDefined(); + if (triggersGroup && isActionGroup(triggersGroup)) { + const webhookOption = triggersGroup.options.find( + (opt) => opt.id === 'inboundWebhook.received' + ); + expect(webhookOption).toBeDefined(); + if (webhookOption && isActionConnectorOption(webhookOption)) { + expect(webhookOption.label).toBe('Webhook received'); + expect(webhookOption.connectorType).toBe('.inboundWebhook'); + } else { + throw new Error('Expected connector-event trigger option'); + } + } + }); + + it('should not duplicate connector-event triggers already registered via extensions', () => { + (triggerSchemas.getTriggerDefinitions as jest.Mock).mockReturnValueOnce([ + { + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Registered extension trigger.', + stability: 'tech_preview', + }, + ]); + + const connectorTypes: Record = { + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + subActions: [], + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'When an HTTP request is received on the connector endpoint.', + stability: 'tech_preview', + }, + ], + }, + }; + + (getCachedDynamicConnectorTypes as jest.Mock).mockReturnValueOnce(connectorTypes); + + const result = getActionOptions(mockEuiTheme, mockWorkflowsExtensions); + const triggersGroup = result.find((group) => group.id === 'triggers'); + + expect(triggersGroup).toBeDefined(); + if (triggersGroup && isActionGroup(triggersGroup)) { + expect( + triggersGroup.options.filter((opt) => opt.id === 'inboundWebhook.received') + ).toHaveLength(1); + } + }); + it('should set stability tech_preview for registered event-driven trigger options', () => { (triggerSchemas.getTriggerDefinitions as jest.Mock).mockReturnValueOnce([ { diff --git a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.ts b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.ts index 90727c379ec35..8cdc4a5d8572b 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/lib/get_action_options.ts @@ -13,8 +13,13 @@ import { i18n } from '@kbn/i18n'; import { getBuiltInStepDefinition, isDynamicConnector, StepCategory } from '@kbn/workflows'; import type { WorkflowsExtensionsPublicPluginStart } from '@kbn/workflows-extensions/public'; import { ParallelIcon } from '@kbn/workflows-ui'; +import { buildConnectorEventTriggerOptions } from './build_connector_event_trigger_options'; import { buildBuiltInTriggerOptions, buildRegisteredTriggerOptions } from './build_trigger_options'; -import { getAllConnectors, isDeprecatedStepType } from '../../../../common/schema'; +import { + getAllConnectors, + getCachedDynamicConnectorTypes, + isDeprecatedStepType, +} from '../../../../common/schema'; import { triggerSchemas } from '../../../trigger_schemas'; import type { ActionConnectorGroup, ActionGroup, ActionOptionData } from '../types'; import { isActionGroup } from '../types'; @@ -52,9 +57,17 @@ export function getActionOptions( workflowsExtensions: WorkflowsExtensionsPublicPluginStart ): ActionOptionData[] { const connectors = getAllConnectors(); + const connectorTypes = getCachedDynamicConnectorTypes() ?? {}; const builtInTriggerOptions = buildBuiltInTriggerOptions(euiTheme); + const registeredTriggerDefinitions = triggerSchemas.getTriggerDefinitions(); + const registeredTriggerIds = new Set(registeredTriggerDefinitions.map((trigger) => trigger.id)); const registeredTriggerOptions = buildRegisteredTriggerOptions( - triggerSchemas.getTriggerDefinitions(), + registeredTriggerDefinitions, + euiTheme + ); + const connectorEventTriggerOptions = buildConnectorEventTriggerOptions( + connectorTypes, + registeredTriggerIds, euiTheme ); const triggersGroup: ActionOptionData = { @@ -67,7 +80,11 @@ export function getActionOptions( description: i18n.translate('workflows.actionsMenu.triggersDescription', { defaultMessage: 'Choose which event starts a workflow', }), - options: [...builtInTriggerOptions, ...registeredTriggerOptions], + options: [ + ...builtInTriggerOptions, + ...registeredTriggerOptions, + ...connectorEventTriggerOptions, + ], }; const kibanaCasesGroup: ActionGroup = { diff --git a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/ui/actions_menu.test.tsx b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/ui/actions_menu.test.tsx index fe15fc7e99366..902ba15b7c364 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/ui/actions_menu.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/actions_menu_popover/ui/actions_menu.test.tsx @@ -15,6 +15,10 @@ import type { ActionGroup, ActionOption, ActionOptionData } from '../types'; jest.mock('../../../hooks/use_kibana'); +jest.mock('../../../entities/connectors/model/use_available_connectors', () => ({ + useAvailableConnectors: jest.fn(() => ({ connectorTypes: {} })), +})); + const mockLeafOption: ActionOption = { id: 'manual', label: 'Manual', diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts index e1dd8031d7e16..58a1481872deb 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_type/get_trigger_type_suggestions.ts @@ -75,7 +75,6 @@ export function getTriggerTypeSuggestions( const triggerDef = triggerSchemas.getTriggerDefinition(triggerType.type); const snippetText = generateTriggerSnippet(triggerType.type, { defaultCondition: triggerDef?.snippets?.condition, - defaultConnectorId: triggerDef?.snippets?.connectorId, }); // Extended range for multi-line insertion diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts new file mode 100644 index 0000000000000..aee7876dec230 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +jest.mock('./generate_trigger_snippet', () => ({ + generateTriggerSnippet: jest.fn(() => 'mock-snippet'), +})); + +import { generateSurfaceSnippet, generateSurfaceSnippetFromSurface } from './generate_surface_snippet'; +import { generateTriggerSnippet } from './generate_trigger_snippet'; + +describe('generateSurfaceSnippet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to generateTriggerSnippet using the surface id', () => { + const options = { full: true, defaultConnectorId: 'sales-ingress' }; + + expect(generateSurfaceSnippet('inboundWebhook.received', options)).toBe('mock-snippet'); + expect(generateTriggerSnippet).toHaveBeenCalledWith('inboundWebhook.received', options); + }); + + it('delegates from a resolved workflow surface definition', () => { + generateSurfaceSnippetFromSurface({ id: 'inboundWebhook.received' }, { full: true }); + + expect(generateTriggerSnippet).toHaveBeenCalledWith('inboundWebhook.received', { full: true }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts new file mode 100644 index 0000000000000..7cfb31c1764c6 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { generateTriggerSnippet } from './generate_trigger_snippet'; + +export interface GenerateSurfaceSnippetOptions { + full?: boolean; + monacoSuggestionFormat?: boolean; + withTriggersSection?: boolean; + defaultCondition?: string; + defaultConnectorId?: string; +} + +/** Generates a YAML trigger snippet for a connector-event workflow surface id. */ +export function generateSurfaceSnippet( + surfaceId: string, + options?: GenerateSurfaceSnippetOptions +): string { + return generateTriggerSnippet(surfaceId, options); +} + +/** Generates a YAML trigger snippet from a resolved workflow surface definition. */ +export function generateSurfaceSnippetFromSurface( + surface: Pick, + options?: GenerateSurfaceSnippetOptions +): string { + return generateSurfaceSnippet(surface.id, options); +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts index 53c62d6a1fc57..162a06b04dac7 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts @@ -62,22 +62,34 @@ describe('generateTriggerSnippet', () => { }); describe('connector-event triggers', () => { - it('includes connector-id when the trigger requires connector binding', () => { + it('includes connector-id placeholder when the trigger requires connector binding', () => { (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ id: 'exampleInboundWebhook.received', requiresConnectorId: true, snippets: { - connectorId: 'example-inbound-webhook', condition: 'event.body: *', }, }); const snippet = generateTriggerSnippet('exampleInboundWebhook.received', { full: true }); - expect(snippet).toContain('connector-id: example-inbound-webhook'); + expect(snippet).toContain('connector-id: '); expect(snippet).toContain('condition: "event.body: *"'); }); + it('uses an explicit defaultConnectorId when provided', () => { + (resolveConnectorEventWorkflowSurface as jest.Mock).mockReturnValue({ + binding: { connectorTypeId: '.exampleInboundWebhook', instanceRef: 'required' }, + }); + + const snippet = generateTriggerSnippet('exampleInboundWebhook.received', { + full: true, + defaultConnectorId: 'example-inbound-webhook', + }); + + expect(snippet).toContain('connector-id: example-inbound-webhook'); + }); + it('includes connector-id when resolved from connector-event workflow surface', () => { (resolveConnectorEventWorkflowSurface as jest.Mock).mockReturnValue({ binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts index 9d39b8651f8c0..e0fa917117134 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts @@ -54,9 +54,7 @@ export function generateTriggerSnippet( connectorEventSurface?.binding.instanceRef === 'required' || triggerDefinition?.requiresConnectorId === true; const resolvedConnectorId = - defaultConnectorId ?? - triggerDefinition?.snippets?.connectorId ?? - (monacoSuggestionFormat ? '${1:}' : ''); + defaultConnectorId ?? (monacoSuggestionFormat ? '${1:}' : ''); const resolvedCondition = defaultCondition ?? triggerDefinition?.snippets?.condition ?? ''; switch (triggerType) { diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index a5643c5320eb4..ef58c42ff7cb4 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -18,7 +18,11 @@ import type YAML from 'yaml'; import { monaco, YAML_LANG_ID } from '@kbn/code-editor'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { isTriggerType, WORKFLOWS_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/workflows'; +import { + isTriggerType, + resolveConnectorEventWorkflowSurface, + WORKFLOWS_EXPERIMENTAL_FEATURES_SETTING_ID, +} from '@kbn/workflows'; import { useWorkflowsMonacoTheme, WORKFLOW_MONACO_LAYOUT_OPTIONS } from '@kbn/workflows-ui'; import type { z } from '@kbn/zod/v4'; import { ActionsMenuButton } from './actions_menu_button'; @@ -649,15 +653,18 @@ export const WorkflowYAMLEditor = ({ if (!model || !editor) { return; } - if (isTriggerType(action.id) || triggerSchemas.isRegisteredTriggerId(action.id)) { + if ( + isTriggerType(action.id) || + triggerSchemas.isRegisteredTriggerId(action.id) || + resolveConnectorEventWorkflowSurface(action.id) !== undefined + ) { const triggerDefinition = triggerSchemas.getTriggerDefinition(action.id); insertTriggerSnippet( model, yamlDocumentCurrent, action.id, editor, - triggerDefinition?.snippets?.condition, - triggerDefinition?.snippets?.connectorId + triggerDefinition?.snippets?.condition ); } else { insertStepSnippet(model, yamlDocumentCurrent, action.id, cursorPosition, editor); From f9752211df22e0df063dd327d371b7b96bf8f5d7 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Tue, 14 Jul 2026 13:01:27 +0200 Subject: [PATCH 10/19] connector-id autocomplete --- .../server/triggers/index.ts | 4 + .../common/trigger_registry/types.ts | 4 - .../get_connector_id_suggestions.test.ts | 5 + .../get_connector_id_suggestions.ts | 14 +- ...get_connector_id_suggestions_items.test.ts | 93 ++++++++-- .../get_connector_id_suggestions_items.ts | 62 ++----- .../connector_id_binding.test.ts | 163 ++++++++++++++++++ .../workflow_surface/connector_id_binding.ts | 111 ++++++++++++ .../connector_id_provider.test.ts | 32 +++- .../workflow_surface/connector_id_provider.ts | 64 +++++-- .../resolve_surface_at_path.ts | 121 +++++++++++-- 11 files changed, 572 insertions(+), 101 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts diff --git a/examples/workflows_extensions_example/server/triggers/index.ts b/examples/workflows_extensions_example/server/triggers/index.ts index a27fd10c16768..4a94dc05a5a2e 100644 --- a/examples/workflows_extensions_example/server/triggers/index.ts +++ b/examples/workflows_extensions_example/server/triggers/index.ts @@ -10,8 +10,12 @@ import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; import { commonCustomTriggerDefinition } from '../../common/triggers/custom_trigger'; import { commonLoopTriggerDefinition } from '../../common/triggers/loop_trigger'; +import { commonExampleInboundWebhookReceivedTriggerDefinition } from '../../common/connectors/example_inbound_webhook'; export const registerTriggers = (workflowsExtensions: WorkflowsExtensionsServerPluginSetup) => { workflowsExtensions.registerTriggerDefinition(commonCustomTriggerDefinition); workflowsExtensions.registerTriggerDefinition(commonLoopTriggerDefinition); + workflowsExtensions.registerTriggerDefinition( + commonExampleInboundWebhookReceivedTriggerDefinition + ); }; diff --git a/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts b/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts index 47f7a394b0c4c..89cc01f3046a8 100644 --- a/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts +++ b/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts @@ -34,10 +34,6 @@ export interface TriggerSnippets { * Must be valid KQL and only reference properties from the event schema (validated at registration). */ condition?: string; - /** - * Connector instance id pre-filled in `connector-id` for connector-event triggers. - */ - connectorId?: string; } /** diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts index 82d5c1c70ff3b..64efbdd430e47 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.test.ts @@ -120,6 +120,11 @@ describe('getConnectorIdSuggestions', () => { stability: 'tech_preview', binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, surfaces: {}, + source: { + type: 'connector-event', + connectorTypeId: '.inboundWebhook', + eventKey: 'received', + }, }, }); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts index 38d8f57d7f1e4..62a96b27f1566 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts @@ -8,7 +8,7 @@ */ import { getConnectorIdSuggestionsItems } from './get_connector_id_suggestions_items'; -import { resolveConnectorIdActionTypeId } from '../../../../../../workflow_surface/connector_id_provider'; +import { resolveConnectorIdBinding } from '../../../../../../workflow_surface/connector_id_provider'; import type { AutocompleteContext } from '../../context/autocomplete.types'; export function getConnectorIdSuggestions({ @@ -21,7 +21,7 @@ export function getConnectorIdSuggestions({ yamlDocument, dynamicConnectorTypes, }: AutocompleteContext) { - const connectorActionTypeId = resolveConnectorIdActionTypeId({ + const binding = resolveConnectorIdBinding({ yamlDocument, path, focusedStepInfo, @@ -29,7 +29,7 @@ export function getConnectorIdSuggestions({ }); if ( - !connectorActionTypeId || + !binding || !lineParseResult || lineParseResult.matchType !== 'connector-id' || !dynamicConnectorTypes @@ -44,12 +44,8 @@ export function getConnectorIdSuggestions({ startColumn: lineParseResult.valueStartIndex + 1, endColumn: line.length + 1, }; - return getConnectorIdSuggestionsItems( - connectorActionTypeId, - replaceRange, - dynamicConnectorTypes - ); + return getConnectorIdSuggestionsItems(binding, replaceRange, dynamicConnectorTypes); } - return getConnectorIdSuggestionsItems(connectorActionTypeId, range, dynamicConnectorTypes); + return getConnectorIdSuggestionsItems(binding, range, dynamicConnectorTypes); } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts index 40e68463f5af8..c1399bed1b079 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts @@ -116,6 +116,12 @@ const createMockDynamicConnectorTypes = (): Record => }, }); +const createStepBinding = (lookupKey: string) => ({ + connectorTypeId: `.${lookupKey.replace(/^\./, '').split('.')[0]}`, + requireConnectorTypeEvents: false, + lookupKey, +}); + describe('getConnectorIdSuggestionsItems', () => { beforeEach(() => { jest.clearAllMocks(); @@ -128,7 +134,11 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); expect(suggestions).toHaveLength(3); expect(suggestions[0].insertText).toBe('slack-001'); @@ -140,7 +150,11 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); const deprecatedSuggestion = suggestions.find((s) => s.insertText === 'slack-003'); expect(deprecatedSuggestion).toBeDefined(); @@ -153,7 +167,11 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); const preconfiguredSuggestion = suggestions.find((s) => s.insertText === 'slack-002'); expect(preconfiguredSuggestion).toBeDefined(); @@ -164,7 +182,11 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); const activeSortText = suggestions.find((s) => s.insertText === 'slack-001')?.sortText ?? ''; const deprecatedSortText = @@ -175,7 +197,7 @@ describe('getConnectorIdSuggestionsItems', () => { it('should return empty array when no dynamic connector types are provided', () => { const range = createMockRange(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, undefined); + const suggestions = getConnectorIdSuggestionsItems(createStepBinding('slack'), range, undefined); expect(suggestions).toEqual([]); }); @@ -194,7 +216,11 @@ describe('getConnectorIdSuggestionsItems', () => { }, }; - const suggestions = getConnectorIdSuggestionsItems('empty', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('empty'), + range, + dynamicConnectorTypes + ); expect(suggestions).toEqual([]); }); @@ -203,7 +229,11 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); const createSuggestion = suggestions.find((s) => s.insertText === ''); expect(createSuggestion).toBeDefined(); @@ -217,7 +247,11 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); const createSuggestion = suggestions.find((s) => s.insertText === ''); expect(createSuggestion).toBeUndefined(); @@ -227,7 +261,11 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); suggestions.forEach((s) => { if (s.insertText !== '') { @@ -240,12 +278,47 @@ describe('getConnectorIdSuggestionsItems', () => { const range = createMockRange(); const dynamicConnectorTypes = createMockDynamicConnectorTypes(); - const suggestions = getConnectorIdSuggestionsItems('slack', range, dynamicConnectorTypes); + const suggestions = getConnectorIdSuggestionsItems( + createStepBinding('slack'), + range, + dynamicConnectorTypes + ); const firstSuggestion = suggestions[0]; expect(firstSuggestion.filterText).toContain('slack-001'); expect(firstSuggestion.filterText).toContain('Engineering Slack'); }); + + it('returns no suggestions when connector-event binding requires declared events', () => { + const range = createMockRange(); + const binding = { + connectorTypeId: '.inboundWebhook', + requireConnectorTypeEvents: true, + lookupKey: '.inboundWebhook', + }; + + const suggestions = getConnectorIdSuggestionsItems(binding, range, { + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + subActions: [], + instances: [ + { + id: 'sales-ingress', + name: 'Sales Ingress', + isPreconfigured: false, + isDeprecated: false, + }, + ], + }, + }); + + expect(suggestions).toEqual([]); + }); }); describe('getConnectorInstancesForType', () => { diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.ts index 05d2314215e52..131daed3a1794 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.ts @@ -17,75 +17,47 @@ import { } from '../../../../../../shared/lib/action_type_utils'; import { getConnectorTypesFromStepType, - getCustomStepConnectorIdSelectionHandler, - getInferenceConnectorTaskTypeFromSubAction, isCreateConnectorEnabledForStepType, } from '../../../../../../shared/lib/connectors_utils'; +import { listConnectorInstancesForBinding } from '../../../../../../workflow_surface/connector_id_binding'; +import type { ConnectorIdBinding } from '../../../../../../workflow_surface/connector_id_binding'; /** - * Generate connector-id suggestions for a specific connector type + * Generate connector-id suggestions from a resolved workflow-surface binding. */ export function getConnectorIdSuggestionsItems( - stepType: string, + binding: ConnectorIdBinding, range: monaco.IRange | monaco.languages.CompletionItemRanges, dynamicConnectorTypes?: Record ): monaco.languages.CompletionItem[] { const suggestions: monaco.languages.CompletionItem[] = []; - const instances = getConnectorInstancesForType(stepType, dynamicConnectorTypes); + const instances = listConnectorInstancesForBinding(binding, dynamicConnectorTypes ?? {}); instances.forEach((instance) => - suggestions.push(createConnectorSuggestion(instance, stepType, range)) + suggestions.push(createConnectorSuggestion(instance, binding.lookupKey, range)) ); - if (isCreateConnectorEnabledForStepType(stepType)) { - const connectorType = getConnectorTypesFromStepType(stepType)[0]; + if (isCreateConnectorEnabledForStepType(binding.lookupKey)) { + const connectorType = getConnectorTypesFromStepType(binding.lookupKey)[0]; suggestions.push(createConnectorCreationSuggestion(connectorType, range)); } return suggestions; } -/** - * Get connector instances for a list of connector types - */ +/** @deprecated Use {@link getConnectorIdSuggestionsItems} with a {@link ConnectorIdBinding}. */ export function getConnectorInstancesForType( stepType: string, dynamicConnectorTypes?: Record ): Array { - if (!dynamicConnectorTypes) { - return []; - } - const customStepSelectionHandler = getCustomStepConnectorIdSelectionHandler(stepType); - const connectorTypes = customStepSelectionHandler?.connectorTypes ?? [stepType]; - - return connectorTypes.flatMap((connectorType) => { - // Remove the leading dot just in case. e.g. .inference.completion -> inference.completion - const cleanStepType = connectorType.startsWith('.') ? connectorType.slice(1) : connectorType; - // Split base connector type and sub action e.g. inference.completion -> inference, completion - const [baseConnectorType, subAction] = cleanStepType.split('.'); - // Use the exact action type ID to lookup the connector e.g. ['.inference'] - const actionTypeId = getActionTypeIdFromStepType(baseConnectorType); - const connectorTypeInfo = dynamicConnectorTypes[actionTypeId]; - - if (connectorTypeInfo?.instances?.length > 0) { - let instances = connectorTypeInfo.instances; - // Apply extra filtering for inference connectors based on the sub action - if (baseConnectorType === 'inference' && subAction) { - const taskType = getInferenceConnectorTaskTypeFromSubAction(subAction); - if (taskType) { - instances = instances.filter(({ config }) => config?.taskType === taskType); - } - } - - // Return the connector instances for the specific action type ID - return instances.map((instance) => ({ - ...instance, - connectorType: connectorTypeInfo.actionTypeId, - })); - } - - return []; - }); + return listConnectorInstancesForBinding( + { + connectorTypeId: getActionTypeIdFromStepType(stepType), + requireConnectorTypeEvents: false, + lookupKey: stepType, + }, + dynamicConnectorTypes ?? {} + ); } /** diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts new file mode 100644 index 0000000000000..575bfca8be9c1 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ConnectorTypeInfo, WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { + connectorTypeHasDeclaredEvents, + listConnectorInstancesForBinding, + resolveConnectorIdBindingFromStepType, + resolveConnectorIdBindingFromSurface, +} from './connector_id_binding'; + +describe('connector_id_binding', () => { + const inboundWebhookType: ConnectorTypeInfo = { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [ + { + id: 'sales-ingress', + name: 'Sales Ingress', + isPreconfigured: false, + isDeprecated: false, + }, + ], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + subActions: [], + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Inbound webhook event.', + }, + ], + }; + + const slackType: ConnectorTypeInfo = { + actionTypeId: '.slack', + displayName: 'Slack', + instances: [ + { + id: 'team-slack', + name: 'Team Slack', + isPreconfigured: false, + isDeprecated: false, + }, + ], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + subActions: [], + }; + + const connectorEventSurface: WorkflowSurfaceDefinition = { + id: 'inboundWebhook.received', + kind: 'trigger', + title: 'Webhook received', + description: 'desc', + stability: 'tech_preview', + binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, + surfaces: {}, + source: { + type: 'connector-event', + connectorTypeId: '.inboundWebhook', + eventKey: 'received', + }, + }; + + describe('resolveConnectorIdBindingFromSurface', () => { + it('requires connector types with declared events for connector-event surfaces', () => { + expect(resolveConnectorIdBindingFromSurface(connectorEventSurface)).toEqual({ + connectorTypeId: '.inboundWebhook', + requireConnectorTypeEvents: true, + lookupKey: '.inboundWebhook', + }); + }); + + it('does not require events for step surfaces', () => { + const stepSurface: WorkflowSurfaceDefinition = { + id: 'slack.postMessage', + kind: 'step', + title: 'Slack', + description: 'Post message', + stability: 'stable', + binding: { connectorTypeId: '.slack', instanceRef: 'required' }, + surfaces: {}, + }; + + expect(resolveConnectorIdBindingFromSurface(stepSurface)).toEqual({ + connectorTypeId: '.slack', + requireConnectorTypeEvents: false, + lookupKey: '.slack', + }); + }); + }); + + describe('listConnectorInstancesForBinding', () => { + it('returns instances for connector-event surfaces when the type declares events', () => { + const binding = resolveConnectorIdBindingFromSurface(connectorEventSurface); + expect(binding).toBeDefined(); + if (!binding) { + throw new Error('Expected connector-event binding'); + } + + const instances = listConnectorInstancesForBinding(binding, { + '.inboundWebhook': inboundWebhookType, + }); + + expect(instances.map((instance) => instance.id)).toEqual(['sales-ingress']); + }); + + it('returns no instances for connector-event surfaces when the type has no events', () => { + const binding = resolveConnectorIdBindingFromSurface(connectorEventSurface); + expect(binding).toBeDefined(); + if (!binding) { + throw new Error('Expected connector-event binding'); + } + + const instances = listConnectorInstancesForBinding(binding, { + '.inboundWebhook': { ...inboundWebhookType, events: undefined }, + }); + + expect(instances).toEqual([]); + }); + + it('returns step connector instances without requiring events', () => { + const binding = resolveConnectorIdBindingFromStepType('slack'); + + const instances = listConnectorInstancesForBinding(binding, { + '.slack': slackType, + }); + + expect(instances.map((instance) => instance.id)).toEqual(['team-slack']); + }); + }); + + describe('connectorTypeHasDeclaredEvents', () => { + it('returns true when events are declared on the connector type', () => { + expect( + connectorTypeHasDeclaredEvents('.inboundWebhook', { + '.inboundWebhook': inboundWebhookType, + }) + ).toBe(true); + }); + + it('returns false when events are missing', () => { + expect( + connectorTypeHasDeclaredEvents('.slack', { + '.slack': slackType, + }) + ).toBe(false); + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts new file mode 100644 index 0000000000000..3798b37ee0577 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { + ConnectorInstance, + ConnectorTypeInfo, + WorkflowSurfaceDefinition, +} from '@kbn/workflows'; +import { getActionTypeIdFromStepType } from '../shared/lib/action_type_utils'; +import { + getCustomStepConnectorIdSelectionHandler, + getInferenceConnectorTaskTypeFromSubAction, +} from '../shared/lib/connectors_utils'; + +/** + * Resolved WHO binding for `connector-id` autocomplete — from a workflow surface or step fallback. + */ +export interface ConnectorIdBinding { + readonly connectorTypeId: string; + /** When true, only connector types that declare ConnectorSpec.events are eligible. */ + readonly requireConnectorTypeEvents: boolean; + /** Lookup key passed to connector instance resolution (step type or action type id). */ + readonly lookupKey: string; +} + +export function resolveConnectorIdBindingFromSurface( + surface: WorkflowSurfaceDefinition +): ConnectorIdBinding | undefined { + const { connectorTypeId, instanceRef } = surface.binding; + if (!connectorTypeId || instanceRef === 'none') { + return undefined; + } + + return { + connectorTypeId, + requireConnectorTypeEvents: surface.source?.type === 'connector-event', + lookupKey: connectorTypeId, + }; +} + +export function resolveConnectorIdBindingFromStepType(stepType: string): ConnectorIdBinding { + return { + connectorTypeId: getActionTypeIdFromStepType(stepType), + requireConnectorTypeEvents: false, + lookupKey: stepType, + }; +} + +export function connectorTypeHasDeclaredEvents( + connectorTypeId: string, + connectorTypes: Record +): boolean { + const connectorTypeInfo = connectorTypes[connectorTypeId]; + return (connectorTypeInfo?.events?.length ?? 0) > 0; +} + +/** + * Lists connector instances eligible for the resolved binding. + * Connector-event surfaces only return instances when the bound type declares events. + */ +export function listConnectorInstancesForBinding( + binding: ConnectorIdBinding, + connectorTypes: Record +): Array { + if ( + binding.requireConnectorTypeEvents && + !connectorTypeHasDeclaredEvents(binding.connectorTypeId, connectorTypes) + ) { + return []; + } + + return listConnectorInstancesForLookupKey(binding.lookupKey, connectorTypes); +} + +function listConnectorInstancesForLookupKey( + lookupKey: string, + connectorTypes: Record +): Array { + const customStepSelectionHandler = getCustomStepConnectorIdSelectionHandler(lookupKey); + const connectorTypesToQuery = customStepSelectionHandler?.connectorTypes ?? [lookupKey]; + + return connectorTypesToQuery.flatMap((connectorType) => { + const cleanStepType = connectorType.startsWith('.') ? connectorType.slice(1) : connectorType; + const [baseConnectorType, subAction] = cleanStepType.split('.'); + const actionTypeId = getActionTypeIdFromStepType(baseConnectorType); + const connectorTypeInfo = connectorTypes[actionTypeId]; + + if (!connectorTypeInfo?.instances?.length) { + return []; + } + + let instances = connectorTypeInfo.instances; + if (baseConnectorType === 'inference' && subAction) { + const taskType = getInferenceConnectorTaskTypeFromSubAction(subAction); + if (taskType) { + instances = instances.filter(({ config }) => config?.taskType === taskType); + } + } + + return instances.map((instance) => ({ + ...instance, + connectorType: connectorTypeInfo.actionTypeId, + })); + }); +} diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts index b068abe517983..4d09ed46f0530 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts @@ -8,7 +8,7 @@ */ import { parseDocument } from 'yaml'; -import { resolveConnectorIdActionTypeId } from './connector_id_provider'; +import { resolveConnectorIdActionTypeId, resolveConnectorIdBinding } from './connector_id_provider'; import type { StepInfo } from '../entities/workflows/store'; jest.mock('./resolve_surface_at_path', () => ({ @@ -17,7 +17,18 @@ jest.mock('./resolve_surface_at_path', () => ({ ? { role: 'connector-id', surface: { + id: 'inboundWebhook.received', + kind: 'trigger', + title: 'Webhook received', + description: 'desc', + stability: 'tech_preview', binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, + surfaces: {}, + source: { + type: 'connector-event', + connectorTypeId: '.inboundWebhook', + eventKey: 'received', + }, }, } : undefined @@ -40,6 +51,25 @@ describe('resolveConnectorIdActionTypeId', () => { ).toBe('.inboundWebhook'); }); + it('requires connector types with declared events for connector-event surfaces', () => { + const yamlDocument = parseDocument(`triggers: + - type: inboundWebhook.received + connector-id: `); + + expect( + resolveConnectorIdBinding({ + yamlDocument, + path: ['triggers', 0, 'connector-id'], + focusedStepInfo: null, + focusedYamlPair: null, + }) + ).toEqual({ + connectorTypeId: '.inboundWebhook', + requireConnectorTypeEvents: true, + lookupKey: '.inboundWebhook', + }); + }); + it('falls back to step connector type resolution', () => { const yamlDocument = parseDocument(`steps: - name: notify diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts index 2ca396479d071..d45b52ea14a38 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts @@ -9,6 +9,11 @@ import type { Document } from 'yaml'; import type { WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { + type ConnectorIdBinding, + resolveConnectorIdBindingFromStepType, + resolveConnectorIdBindingFromSurface, +} from './connector_id_binding'; import { resolveSurfaceAtPath } from './resolve_surface_at_path'; import type { StepInfo, StepPropInfo } from '../entities/workflows/store'; import { resolveConnectorIdStepType } from '../widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/resolve_connector_id_step_type'; @@ -21,27 +26,50 @@ export interface ConnectorIdProviderContext { } /** - * Resolves the action type id used to filter connector instances for `connector-id` autocomplete. - * Prefers connector-event trigger surfaces; falls back to legacy step connector resolution. + * Resolves the workflow surface for connector-id autocomplete at the cursor path. */ -export const resolveConnectorIdActionTypeId = ({ - yamlDocument, - path, - focusedStepInfo, - focusedYamlPair, -}: ConnectorIdProviderContext): string | null => { - if (path.length > 0) { - const resolvedSurface = resolveSurfaceAtPath(yamlDocument, [...path]); - const connectorTypeId = resolvedSurface?.surface.binding.connectorTypeId; - if (connectorTypeId) { - return connectorTypeId; +export const resolveConnectorIdSurface = ( + yamlDocument: Document, + path: ReadonlyArray, + focusedStepInfo: StepInfo | null = null, + focusedYamlPair: StepPropInfo | null = null +): WorkflowSurfaceDefinition | undefined => + resolveSurfaceAtPath(yamlDocument, [...path], { focusedStepInfo, focusedYamlPair })?.surface; + +/** + * Resolves connector-id binding through workflow surfaces first, then legacy step fallback. + */ +export const resolveConnectorIdBinding = ( + context: ConnectorIdProviderContext +): ConnectorIdBinding | undefined => { + const surface = resolveConnectorIdSurface( + context.yamlDocument, + context.path, + context.focusedStepInfo, + context.focusedYamlPair + ); + if (surface) { + const binding = resolveConnectorIdBindingFromSurface(surface); + if (binding) { + return binding; } } - return resolveConnectorIdStepType(focusedStepInfo, path, focusedYamlPair); + const stepType = resolveConnectorIdStepType( + context.focusedStepInfo, + context.path, + context.focusedYamlPair + ); + if (!stepType) { + return undefined; + } + + return resolveConnectorIdBindingFromStepType(stepType); }; -export const resolveConnectorIdSurface = ( - yamlDocument: Document, - path: ReadonlyArray -): WorkflowSurfaceDefinition | undefined => resolveSurfaceAtPath(yamlDocument, [...path])?.surface; +/** + * @deprecated Prefer {@link resolveConnectorIdBinding} — kept for callers that only need the lookup key. + */ +export const resolveConnectorIdActionTypeId = ( + context: ConnectorIdProviderContext +): string | null => resolveConnectorIdBinding(context)?.lookupKey ?? null; diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts index 1b2312b0b2b11..bedcbadb46739 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts @@ -8,14 +8,18 @@ */ import type { Document } from 'yaml'; -import type { WorkflowSurfaceDefinition } from '@kbn/workflows'; -import { resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; +import type { InstanceRef, WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { isDynamicConnector, resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; +import { getCachedAllConnectorsMap } from '../../common/schema'; +import type { StepInfo, StepPropInfo } from '../entities/workflows/store'; +import { getActionTypeIdFromStepType } from '../shared/lib/action_type_utils'; import { getTriggerBlockIndex, getTriggerConditionBlockIndex, getTriggerConnectorIdBlockIndex, getTriggerTypeAtIndex, } from '../widgets/workflow_yaml_editor/lib/autocomplete/context/triggers_utils'; +import { resolveConnectorIdStepType } from '../widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/resolve_connector_id_step_type'; export type SurfaceCursorRole = 'trigger' | 'connector-id' | 'kql-filter'; @@ -24,8 +28,16 @@ export interface ResolvedSurfaceAtPath { readonly role: SurfaceCursorRole; } +export interface ResolveSurfaceAtPathOptions { + readonly focusedStepInfo?: StepInfo | null; + readonly focusedYamlPair?: StepPropInfo | null; +} + +const isStepConnectorIdPath = (path: (string | number)[]): boolean => + path.length > 0 && path[0] === 'steps' && path[path.length - 1] === 'connector-id'; + const resolveSurfaceRole = (path: (string | number)[]): SurfaceCursorRole | undefined => { - if (getTriggerConnectorIdBlockIndex(path) !== null) { + if (getTriggerConnectorIdBlockIndex(path) !== null || isStepConnectorIdPath(path)) { return 'connector-id'; } if (getTriggerConditionBlockIndex(path) !== null) { @@ -37,19 +49,11 @@ const resolveSurfaceRole = (path: (string | number)[]): SurfaceCursorRole | unde return undefined; }; -/** - * Resolves the workflow surface for a connector-event trigger at the YAML cursor path. - * v1: trigger blocks only; step surfaces fall back to legacy connector contracts. - */ -export const resolveSurfaceAtPath = ( +const resolveTriggerSurfaceAtPath = ( yamlDocument: Document, - path: (string | number)[] + path: (string | number)[], + role: SurfaceCursorRole ): ResolvedSurfaceAtPath | undefined => { - const role = resolveSurfaceRole(path); - if (!role) { - return undefined; - } - const triggerIndex = getTriggerConnectorIdBlockIndex(path) ?? getTriggerConditionBlockIndex(path) ?? @@ -70,3 +74,92 @@ export const resolveSurfaceAtPath = ( return { surface, role }; }; + +const resolveStepConnectorIdSurface = ( + path: (string | number)[], + focusedStepInfo: StepInfo | null, + focusedYamlPair: StepPropInfo | null +): ResolvedSurfaceAtPath | undefined => { + const lookupKey = resolveConnectorIdStepType(focusedStepInfo, path, focusedYamlPair); + if (!lookupKey) { + return undefined; + } + + const connector = getCachedAllConnectorsMap()?.get(lookupKey); + let connectorTypeId = lookupKey.startsWith('.') + ? lookupKey + : getActionTypeIdFromStepType(lookupKey); + let instanceRef: InstanceRef = 'required'; + let title = lookupKey; + let description = lookupKey; + let stability: WorkflowSurfaceDefinition['stability'] = 'stable'; + + if (connector && isDynamicConnector(connector)) { + connectorTypeId = connector.actionTypeId; + title = connector.displayName; + description = connector.description ?? lookupKey; + if (connector.stability) { + stability = connector.stability; + } + if (connector.hasConnectorId === 'optional') { + instanceRef = 'optional'; + } else if (connector.hasConnectorId === false) { + return undefined; + } + } else if (focusedStepInfo?.stepType) { + connectorTypeId = getActionTypeIdFromStepType(focusedStepInfo.stepType); + const stepContract = getCachedAllConnectorsMap()?.get(focusedStepInfo.stepType); + if (stepContract && isDynamicConnector(stepContract)) { + connectorTypeId = stepContract.actionTypeId; + title = stepContract.displayName; + description = stepContract.description ?? focusedStepInfo.stepType; + if (stepContract.stability) { + stability = stepContract.stability; + } + } + } + + return { + role: 'connector-id', + surface: { + id: lookupKey, + kind: 'step', + title, + description, + stability, + binding: { + connectorTypeId, + instanceRef, + }, + surfaces: {}, + }, + }; +}; + +/** + * Resolves the workflow surface at the YAML cursor path for triggers and step connector-id fields. + */ +export const resolveSurfaceAtPath = ( + yamlDocument: Document, + path: (string | number)[], + options?: ResolveSurfaceAtPathOptions +): ResolvedSurfaceAtPath | undefined => { + const role = resolveSurfaceRole(path); + if (!role) { + return undefined; + } + + if (path[0] === 'triggers') { + return resolveTriggerSurfaceAtPath(yamlDocument, path, role); + } + + if (path[0] === 'steps' && role === 'connector-id') { + return resolveStepConnectorIdSurface( + path, + options?.focusedStepInfo ?? null, + options?.focusedYamlPair ?? null + ); + } + + return undefined; +}; From f5ccd919c7826021d00642dc8f36803d176115c9 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Tue, 14 Jul 2026 13:10:11 +0200 Subject: [PATCH 11/19] autocomple connector-id --- .../get_connector_id_suggestions.ts | 1 + ...get_connector_id_suggestions_items.test.ts | 4 +- .../connector_id_binding.test.ts | 14 ++- .../workflow_surface/connector_id_binding.ts | 6 +- .../connector_id_provider.test.ts | 6 ++ .../workflow_surface/connector_id_provider.ts | 15 ++- ...onnector_event_surface_for_trigger.test.ts | 100 ++++++++++++++++++ ...lve_connector_event_surface_for_trigger.ts | 84 +++++++++++++++ .../resolve_surface_at_path.ts | 19 +++- 9 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts index 62a96b27f1566..a84799b1b37e2 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions.ts @@ -26,6 +26,7 @@ export function getConnectorIdSuggestions({ path, focusedStepInfo, focusedYamlPair, + connectorTypes: dynamicConnectorTypes ?? undefined, }); if ( diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts index c1399bed1b079..3dc08a6643bd8 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_id/get_connector_id_suggestions_items.test.ts @@ -289,7 +289,7 @@ describe('getConnectorIdSuggestionsItems', () => { expect(firstSuggestion.filterText).toContain('Engineering Slack'); }); - it('returns no suggestions when connector-event binding requires declared events', () => { + it('returns instances for connector-event surfaces when API omits events[]', () => { const range = createMockRange(); const binding = { connectorTypeId: '.inboundWebhook', @@ -317,7 +317,7 @@ describe('getConnectorIdSuggestionsItems', () => { }, }); - expect(suggestions).toEqual([]); + expect(suggestions.some((item) => item.insertText === 'sales-ingress')).toBe(true); }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts index 575bfca8be9c1..bb98fc5864192 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts @@ -118,7 +118,7 @@ describe('connector_id_binding', () => { expect(instances.map((instance) => instance.id)).toEqual(['sales-ingress']); }); - it('returns no instances for connector-event surfaces when the type has no events', () => { + it('returns instances for connector-event surfaces when API omits events[] but type exists', () => { const binding = resolveConnectorIdBindingFromSurface(connectorEventSurface); expect(binding).toBeDefined(); if (!binding) { @@ -129,7 +129,17 @@ describe('connector_id_binding', () => { '.inboundWebhook': { ...inboundWebhookType, events: undefined }, }); - expect(instances).toEqual([]); + expect(instances.map((instance) => instance.id)).toEqual(['sales-ingress']); + }); + + it('returns no instances when connector-event binding type is missing from API', () => { + const binding = resolveConnectorIdBindingFromSurface(connectorEventSurface); + expect(binding).toBeDefined(); + if (!binding) { + throw new Error('Expected connector-event binding'); + } + + expect(listConnectorInstancesForBinding(binding, {})).toEqual([]); }); it('returns step connector instances without requiring events', () => { diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts index 3798b37ee0577..39b92fa907f5a 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts @@ -68,10 +68,8 @@ export function listConnectorInstancesForBinding( binding: ConnectorIdBinding, connectorTypes: Record ): Array { - if ( - binding.requireConnectorTypeEvents && - !connectorTypeHasDeclaredEvents(binding.connectorTypeId, connectorTypes) - ) { + const connectorTypeInfo = connectorTypes[binding.connectorTypeId]; + if (binding.requireConnectorTypeEvents && !connectorTypeInfo) { return []; } diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts index 4d09ed46f0530..1b41ba077622c 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts @@ -11,6 +11,12 @@ import { parseDocument } from 'yaml'; import { resolveConnectorIdActionTypeId, resolveConnectorIdBinding } from './connector_id_provider'; import type { StepInfo } from '../entities/workflows/store'; +jest.mock('../trigger_schemas', () => ({ + triggerSchemas: { + getTriggerDefinition: jest.fn(() => undefined), + }, +})); + jest.mock('./resolve_surface_at_path', () => ({ resolveSurfaceAtPath: jest.fn((_, path: (string | number)[]) => path[0] === 'triggers' && path[2] === 'connector-id' diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts index d45b52ea14a38..75968383e3174 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.ts @@ -8,7 +8,7 @@ */ import type { Document } from 'yaml'; -import type { WorkflowSurfaceDefinition } from '@kbn/workflows'; +import type { ConnectorTypeInfo, WorkflowSurfaceDefinition } from '@kbn/workflows'; import { type ConnectorIdBinding, resolveConnectorIdBindingFromStepType, @@ -23,6 +23,7 @@ export interface ConnectorIdProviderContext { readonly path: ReadonlyArray; readonly focusedStepInfo: StepInfo | null; readonly focusedYamlPair: StepPropInfo | null; + readonly connectorTypes?: Record; } /** @@ -32,9 +33,14 @@ export const resolveConnectorIdSurface = ( yamlDocument: Document, path: ReadonlyArray, focusedStepInfo: StepInfo | null = null, - focusedYamlPair: StepPropInfo | null = null + focusedYamlPair: StepPropInfo | null = null, + connectorTypes: Record = {} ): WorkflowSurfaceDefinition | undefined => - resolveSurfaceAtPath(yamlDocument, [...path], { focusedStepInfo, focusedYamlPair })?.surface; + resolveSurfaceAtPath(yamlDocument, [...path], { + focusedStepInfo, + focusedYamlPair, + connectorTypes, + })?.surface; /** * Resolves connector-id binding through workflow surfaces first, then legacy step fallback. @@ -46,7 +52,8 @@ export const resolveConnectorIdBinding = ( context.yamlDocument, context.path, context.focusedStepInfo, - context.focusedYamlPair + context.focusedYamlPair, + context.connectorTypes ); if (surface) { const binding = resolveConnectorIdBindingFromSurface(surface); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts new file mode 100644 index 0000000000000..5d8275173b035 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ConnectorTypeInfo } from '@kbn/workflows'; +import { + findConnectorTypeIdForEventTrigger, + inferConnectorTypeIdFromEventId, + resolveConnectorEventSurfaceForTriggerId, +} from './resolve_connector_event_surface_for_trigger'; + +jest.mock('@kbn/workflows', () => { + const actual = jest.requireActual('@kbn/workflows'); + return { + ...actual, + resolveConnectorEventWorkflowSurface: jest.fn(() => undefined), + }; +}); + +jest.mock('@kbn/connector-specs', () => ({ + resolveRegisteredConnectorEventByEventId: jest.fn(() => undefined), +})); + +describe('resolve_connector_event_surface_for_trigger', () => { + const extensionTrigger = { + id: 'exampleInboundWebhook.received', + title: 'Example webhook received', + description: 'Dev trigger', + stability: 'tech_preview' as const, + requiresConnectorId: true, + eventSchema: {} as never, + }; + + const connectorTypes: Record = { + '.exampleInboundWebhook': { + actionTypeId: '.exampleInboundWebhook', + displayName: 'Example Inbound Webhook', + instances: [ + { + id: 'example-inbound-webhook', + name: 'Example Inbound Webhook', + isPreconfigured: true, + isDeprecated: false, + }, + ], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + subActions: [], + events: [ + { + eventKey: 'received', + eventId: 'exampleInboundWebhook.received', + title: 'Example webhook received', + description: 'Dev trigger', + }, + ], + }, + }; + + it('infers connector type id from trigger event id namespace', () => { + expect(inferConnectorTypeIdFromEventId('exampleInboundWebhook.received')).toBe( + '.exampleInboundWebhook' + ); + }); + + it('finds connector type id from dynamic connector types events', () => { + expect( + findConnectorTypeIdForEventTrigger('exampleInboundWebhook.received', connectorTypes) + ).toBe('.exampleInboundWebhook'); + }); + + it('builds a connector-event surface from extension trigger metadata and connector types', () => { + const surface = resolveConnectorEventSurfaceForTriggerId( + 'exampleInboundWebhook.received', + connectorTypes, + extensionTrigger + ); + + expect(surface).toMatchObject({ + id: 'exampleInboundWebhook.received', + kind: 'trigger', + binding: { + connectorTypeId: '.exampleInboundWebhook', + instanceRef: 'required', + }, + source: { + type: 'connector-event', + connectorTypeId: '.exampleInboundWebhook', + eventKey: 'received', + }, + }); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts new file mode 100644 index 0000000000000..4cff7d1866b56 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; +import type { ConnectorTypeInfo, WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { + connectorEventToWorkflowSurface, + resolveConnectorEventWorkflowSurface, +} from '@kbn/workflows'; +import type { PublicTriggerDefinition } from '@kbn/workflows-extensions/public'; + +export const inferConnectorTypeIdFromEventId = (eventId: string): string | undefined => { + const dotIndex = eventId.indexOf('.'); + if (dotIndex <= 0) { + return undefined; + } + return `.${eventId.slice(0, dotIndex)}`; +}; + +export const findConnectorTypeIdForEventTrigger = ( + triggerId: string, + connectorTypes: Record +): string | undefined => { + for (const connectorType of Object.values(connectorTypes)) { + if (connectorType.events?.some((event) => event.eventId === triggerId)) { + return connectorType.actionTypeId; + } + } + + return inferConnectorTypeIdFromEventId(triggerId); +}; + +/** + * Resolves a connector-event workflow surface for a trigger id using ConnectorSpec, + * dynamic connector types from the API, or extension trigger metadata. + */ +export const resolveConnectorEventSurfaceForTriggerId = ( + triggerId: string, + connectorTypes: Record, + extensionTrigger?: PublicTriggerDefinition +): WorkflowSurfaceDefinition | undefined => { + const fromRegisteredSpec = resolveConnectorEventWorkflowSurface(triggerId); + if (fromRegisteredSpec) { + return fromRegisteredSpec; + } + + const registeredEvent = resolveRegisteredConnectorEventByEventId(triggerId); + if (registeredEvent) { + return connectorEventToWorkflowSurface(registeredEvent); + } + + const connectorTypeId = findConnectorTypeIdForEventTrigger(triggerId, connectorTypes); + const requiresConnectorId = extensionTrigger?.requiresConnectorId === true; + if (!connectorTypeId || !requiresConnectorId) { + return undefined; + } + + const eventKeyDotIndex = triggerId.indexOf('.'); + const eventKey = eventKeyDotIndex === -1 ? triggerId : triggerId.slice(eventKeyDotIndex + 1); + + return { + id: triggerId, + kind: 'trigger', + title: extensionTrigger.title, + description: extensionTrigger.description, + stability: extensionTrigger.stability, + binding: { + connectorTypeId, + instanceRef: 'required', + }, + surfaces: {}, + source: { + type: 'connector-event', + connectorTypeId, + eventKey, + }, + }; +}; diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts index bedcbadb46739..9366f17eef4ee 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts @@ -8,11 +8,13 @@ */ import type { Document } from 'yaml'; -import type { InstanceRef, WorkflowSurfaceDefinition } from '@kbn/workflows'; -import { isDynamicConnector, resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; +import type { ConnectorTypeInfo, InstanceRef, WorkflowSurfaceDefinition } from '@kbn/workflows'; +import { isDynamicConnector } from '@kbn/workflows'; +import { resolveConnectorEventSurfaceForTriggerId } from './resolve_connector_event_surface_for_trigger'; import { getCachedAllConnectorsMap } from '../../common/schema'; import type { StepInfo, StepPropInfo } from '../entities/workflows/store'; import { getActionTypeIdFromStepType } from '../shared/lib/action_type_utils'; +import { triggerSchemas } from '../trigger_schemas'; import { getTriggerBlockIndex, getTriggerConditionBlockIndex, @@ -31,6 +33,7 @@ export interface ResolvedSurfaceAtPath { export interface ResolveSurfaceAtPathOptions { readonly focusedStepInfo?: StepInfo | null; readonly focusedYamlPair?: StepPropInfo | null; + readonly connectorTypes?: Record; } const isStepConnectorIdPath = (path: (string | number)[]): boolean => @@ -52,7 +55,8 @@ const resolveSurfaceRole = (path: (string | number)[]): SurfaceCursorRole | unde const resolveTriggerSurfaceAtPath = ( yamlDocument: Document, path: (string | number)[], - role: SurfaceCursorRole + role: SurfaceCursorRole, + connectorTypes: Record ): ResolvedSurfaceAtPath | undefined => { const triggerIndex = getTriggerConnectorIdBlockIndex(path) ?? @@ -67,7 +71,12 @@ const resolveTriggerSurfaceAtPath = ( return undefined; } - const surface = resolveConnectorEventWorkflowSurface(triggerType); + const extensionTrigger = triggerSchemas.getTriggerDefinition(triggerType); + const surface = resolveConnectorEventSurfaceForTriggerId( + triggerType, + connectorTypes, + extensionTrigger + ); if (!surface) { return undefined; } @@ -150,7 +159,7 @@ export const resolveSurfaceAtPath = ( } if (path[0] === 'triggers') { - return resolveTriggerSurfaceAtPath(yamlDocument, path, role); + return resolveTriggerSurfaceAtPath(yamlDocument, path, role, options?.connectorTypes ?? {}); } if (path[0] === 'steps' && role === 'connector-id') { From 206bfec73284f6e0616bd5b10d9b6c6ee5452d10 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Tue, 14 Jul 2026 14:53:07 +0200 Subject: [PATCH 12/19] allowing unknown schemas --- ...ct_open_field_prefixes_from_schema.test.ts | 33 +++++++ ...extract_open_field_prefixes_from_schema.ts | 85 +++++++++++++++++++ .../kbn-workflows/common/utils/index.ts | 2 + .../normalize_kql_field_path.test.ts | 21 +++++ .../normalize_kql_field_path.ts | 20 +++++ .../validate_kql_against_schema.test.ts | 62 ++++++++++++++ .../validate_kql_against_schema.ts | 20 ++++- .../event_schema_to_stub_data_view.test.ts | 12 +++ 8 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.ts create mode 100644 src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.test.ts create mode 100644 src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.ts diff --git a/src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.test.ts b/src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.test.ts new file mode 100644 index 0000000000000..b7893fdb46bf2 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { inboundWebhookReceivedEventSchema } from '@kbn/connector-specs'; +import { z } from '@kbn/zod/v4'; +import { extractOpenFieldPrefixesFromSchema } from './extract_open_field_prefixes_from_schema'; + +describe('extractOpenFieldPrefixesFromSchema', () => { + it('returns body for inbound webhook received schema', () => { + expect(extractOpenFieldPrefixesFromSchema(inboundWebhookReceivedEventSchema)).toEqual(['body']); + }); + + it('returns record paths for dynamic maps', () => { + const schema = z.object({ + headers: z.record(z.string(), z.string()), + }); + expect(extractOpenFieldPrefixesFromSchema(schema)).toEqual(['headers']); + }); + + it('returns empty for strictly typed schemas', () => { + const schema = z.object({ + severity: z.string(), + nested: z.object({ id: z.string() }), + }); + expect(extractOpenFieldPrefixesFromSchema(schema)).toEqual([]); + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.ts b/src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.ts new file mode 100644 index 0000000000000..d8a8485328c61 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/common/utils/extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ZodType } from '@kbn/zod/v4'; +import { isZod, z } from '@kbn/zod/v4'; + +function unwrapOptionalNullable(schema: ZodType): ZodType { + let current: ZodType = schema; + let def = current.def as { type?: string } | undefined; + while (def?.type === 'optional' || def?.type === 'nullable') { + current = (current as z.ZodOptional).unwrap(); + def = current.def as { type?: string } | undefined; + } + return current; +} + +function isOpenSchemaType(schema: ZodType): boolean { + const core = unwrapOptionalNullable(schema); + return core instanceof z.ZodUnknown || core instanceof z.ZodAny || core instanceof z.ZodRecord; +} + +function extractOpenFieldPrefixesRecursive( + zodSchema: ZodType, + prefix: string, + out: Set +): void { + if (!zodSchema || typeof zodSchema !== 'object') { + return; + } + + if (zodSchema instanceof z.ZodObject) { + const shape = zodSchema.shape; + for (const [key, value] of Object.entries(shape)) { + const currentPath = prefix ? `${prefix}.${key}` : key; + const valueSchema = value as ZodType; + if (isOpenSchemaType(valueSchema)) { + out.add(currentPath); + } else { + extractOpenFieldPrefixesRecursive(valueSchema, currentPath, out); + } + } + return; + } + + if (zodSchema.def?.type === 'record') { + if (prefix) { + out.add(prefix); + } + const valueType = (zodSchema as z.ZodRecord).valueType as ZodType | undefined; + if (valueType && !isOpenSchemaType(valueType)) { + extractOpenFieldPrefixesRecursive(valueType, prefix, out); + } + return; + } + + if (zodSchema.def && (zodSchema.def.type === 'optional' || zodSchema.def.type === 'nullable')) { + extractOpenFieldPrefixesRecursive((zodSchema as z.ZodOptional).unwrap(), prefix, out); + return; + } + + if (zodSchema instanceof z.ZodArray) { + const elementType = zodSchema.element as ZodType; + extractOpenFieldPrefixesRecursive(elementType, prefix, out); + } +} + +/** + * Returns schema paths whose values accept arbitrary nested KQL field paths. + * Used for inbound webhook `body: z.unknown()` and other dynamic payload shapes. + */ +export function extractOpenFieldPrefixesFromSchema(zodSchema: unknown): string[] { + if (!isZod(zodSchema)) { + return []; + } + + const prefixes = new Set(); + extractOpenFieldPrefixesRecursive(zodSchema as ZodType, '', prefixes); + return [...prefixes].sort(); +} diff --git a/src/platform/packages/shared/kbn-workflows/common/utils/index.ts b/src/platform/packages/shared/kbn-workflows/common/utils/index.ts index da16d22f54177..13595732abbb5 100644 --- a/src/platform/packages/shared/kbn-workflows/common/utils/index.ts +++ b/src/platform/packages/shared/kbn-workflows/common/utils/index.ts @@ -17,6 +17,8 @@ export { type ExtractedSchemaPropertyPath, type ExtractSchemaPropertyPathsOptions, } from './extract_schema_property_paths/extract_schema_property_paths'; +export { extractOpenFieldPrefixesFromSchema } from './extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema'; +export { normalizeKqlFieldPath } from './normalize_kql_field_path/normalize_kql_field_path'; export { parseJsPropertyAccess } from './parse_js_property_access/parse_js_property_access'; export { extractPropertyPathsFromKql } from './extract_property_paths_from_kql/extract_property_paths_from_kql'; export { diff --git a/src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.test.ts b/src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.test.ts new file mode 100644 index 0000000000000..054d1e0adb11a --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { normalizeKqlFieldPath } from './normalize_kql_field_path'; + +describe('normalizeKqlFieldPath', () => { + it('converts bracket notation to dot notation', () => { + expect(normalizeKqlFieldPath("event.body['eventType']")).toBe('event.body.eventType'); + expect(normalizeKqlFieldPath('event.body["eventType"]')).toBe('event.body.eventType'); + }); + + it('leaves dot notation unchanged', () => { + expect(normalizeKqlFieldPath('event.body.eventType')).toBe('event.body.eventType'); + }); +}); diff --git a/src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.ts b/src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.ts new file mode 100644 index 0000000000000..c92f368932341 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/common/utils/normalize_kql_field_path/normalize_kql_field_path.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Normalizes KQL field paths for schema validation. + * Converts bracket notation (`event.body['eventType']`) to dot notation (`event.body.eventType`). + */ +export function normalizeKqlFieldPath(field: string): string { + return field + .replace(/\[(['"]?)([^\]]+)\1\]/g, '.$2') + .replace(/\.+/g, '.') + .replace(/^\./, '') + .replace(/\.$/, ''); +} diff --git a/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.test.ts b/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.test.ts index 0657240262779..6f765a760eaa1 100644 --- a/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.test.ts +++ b/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.test.ts @@ -196,4 +196,66 @@ describe('validateKqlAgainstSchema', () => { if (!result.valid) expect(result.error).toContain('other.*'); }); }); + + describe('open dynamic fields (z.unknown / z.record)', () => { + const inboundWebhookEventSchema = z.object({ + connectorId: z.string(), + body: z.unknown(), + receivedAt: z.string(), + }); + + it('allows nested paths under z.unknown body', () => { + expect( + validateKqlAgainstSchema( + 'event.body.eventType: "order.created"', + inboundWebhookEventSchema, + { + fieldPrefix: EVENT_FIELD_PREFIX, + } + ) + ).toEqual({ valid: true }); + + expect( + validateKqlAgainstSchema( + 'event.body.fields.priority.name: "Highest"', + inboundWebhookEventSchema, + { + fieldPrefix: EVENT_FIELD_PREFIX, + } + ) + ).toEqual({ valid: true }); + }); + + it('allows bracket notation under open fields', () => { + expect( + validateKqlAgainstSchema( + "event.body['eventType']: 'Ihor action'", + inboundWebhookEventSchema, + { + fieldPrefix: EVENT_FIELD_PREFIX, + } + ) + ).toEqual({ valid: true }); + }); + + it('allows event.body.* wildcard', () => { + expect( + validateKqlAgainstSchema('event.body.*: *', inboundWebhookEventSchema, { + fieldPrefix: EVENT_FIELD_PREFIX, + }) + ).toEqual({ valid: true }); + }); + + it('still rejects fields outside the schema and open prefixes', () => { + const result = validateKqlAgainstSchema( + 'event.unknownField: "x"', + inboundWebhookEventSchema, + { + fieldPrefix: EVENT_FIELD_PREFIX, + } + ); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('event.unknownField'); + }); + }); }); diff --git a/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.ts b/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.ts index 3b7f023cf3572..9349ac0219ea6 100644 --- a/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.ts +++ b/src/platform/packages/shared/kbn-workflows/common/utils/validate_kql_against_schema/validate_kql_against_schema.ts @@ -11,7 +11,9 @@ import { fromKueryExpression, getKqlFieldNames } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { isZod } from '@kbn/zod/v4'; import type { z } from '@kbn/zod/v4'; +import { extractOpenFieldPrefixesFromSchema } from '../extract_open_field_prefixes_from_schema/extract_open_field_prefixes_from_schema'; import { extractSchemaPropertyPaths } from '../extract_schema_property_paths/extract_schema_property_paths'; +import { normalizeKqlFieldPath } from '../normalize_kql_field_path/normalize_kql_field_path'; export type ValidateKqlAgainstSchemaResult = { valid: true } | { valid: false; error: string }; @@ -97,14 +99,26 @@ export function validateKqlAgainstSchema( allowedSet.add(fullPath); } + const openFieldPrefixes = new Set(); + for (const openPrefix of extractOpenFieldPrefixesFromSchema(schema)) { + const fullPath = fieldPrefix ? `${fieldPrefix}${openPrefix}` : openPrefix; + openFieldPrefixes.add(fullPath); + allowedSet.add(fullPath); + } + const kqlFieldPaths = getKqlFieldNames(ast); const normalizedFields = kqlFieldPaths.map(normalizeFieldPath).filter(Boolean); for (const field of normalizedFields) { - const isWildcard = field.endsWith('.*'); - const pathToCheck = isWildcard ? field.slice(0, -2) : field; + const normalizedField = normalizeKqlFieldPath(field); + const isWildcard = normalizedField.endsWith('.*'); + const pathToCheck = isWildcard ? normalizedField.slice(0, -2) : normalizedField; + + const isUnderOpenPrefix = [...openFieldPrefixes].some( + (openPrefix) => pathToCheck === openPrefix || pathToCheck.startsWith(`${openPrefix}.`) + ); - if (!allowedSet.has(pathToCheck)) { + if (!allowedSet.has(pathToCheck) && !isUnderOpenPrefix) { return { valid: false, error: fieldPrefix diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_condition/event_schema_to_stub_data_view.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_condition/event_schema_to_stub_data_view.test.ts index 64a5327fe2b72..6029095e172bf 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_condition/event_schema_to_stub_data_view.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/trigger_condition/event_schema_to_stub_data_view.test.ts @@ -63,6 +63,18 @@ describe('event_schema_to_stub_data_view', () => { expect(names).toEqual(['event.items.id']); }); + it('eventSchemaPropertiesToFieldSpecs includes open dynamic paths as leaf fields only', () => { + const schema = z.object({ + connectorId: z.string(), + body: z.unknown(), + receivedAt: z.string(), + }); + const names = eventSchemaPropertiesToFieldSpecs(schema) + .map((s) => s.name) + .sort(); + expect(names).toEqual(['event.body', 'event.connectorId', 'event.receivedAt']); + }); + it('getOrCreateStubDataViewForTriggerEventSchema returns the same DataView for the same schema reference', () => { const fieldFormats = fieldFormatsServiceMock.createStartContract(); const first = getOrCreateStubDataViewForTriggerEventSchema(eventSchema, fieldFormats); From bb2c1c16a50397d12dd253bf9072630a39c5f422 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Wed, 15 Jul 2026 14:53:57 +0200 Subject: [PATCH 13/19] glueing to workflows --- .../server/triggers/index.ts | 4 - .../hooks/use_action_type_model.test.tsx | 2 +- .../utils/action_type_model_utils.test.ts | 2 +- .../shared/kbn-connector-specs/index.ts | 11 --- .../shared/kbn-connector-specs/server.ts | 8 -- .../kbn-connector-specs/server/index.ts | 19 ++++ .../src/lib/ears_experimental_utils.ts | 14 +-- .../inbound_webhook/inbound_webhook.test.ts | 3 +- .../specs/inbound_webhook/inbound_webhook.ts | 17 ---- .../src/to_registered_connector_event.test.ts | 2 +- .../shared/kbn-workflows/server/index.ts | 7 ++ ...ect_connector_events_for_trigger_schema.ts | 31 ++---- ...onnector_event_to_workflow_surface.test.ts | 2 +- .../spec/workflow_surface/index.ts | 2 - ...connector_event_trigger_definition.test.ts | 4 +- ...olve_connector_event_trigger_definition.ts | 2 +- ...e_connector_event_workflow_surface.test.ts | 4 +- ...esolve_connector_event_workflow_surface.ts | 2 +- .../server/step/connector_step.ts | 2 +- .../connector_event_trigger_gate.test.ts | 4 +- .../connector_event_trigger_gate.ts | 2 +- ...ter_workflows_by_trigger_condition.test.ts | 4 +- .../resolve_trigger_definition.test.ts | 13 +-- .../resolve_trigger_definition.ts | 2 +- .../common/connector_action_schema.ts | 2 +- .../common/connector_sub_actions_map.ts | 2 +- .../lib/is_connector_event_trigger_id.test.ts | 55 +++++++++++ .../lib/is_connector_event_trigger_id.ts | 29 ++++++ .../lib/map_registered_triggers_for_schema.ts | 4 +- .../ui/step_icons/step_icon.stories.tsx | 3 +- .../snippets/generate_trigger_snippet.test.ts | 34 ++++--- .../lib/snippets/generate_trigger_snippet.ts | 7 +- .../ui/workflow_yaml_editor.tsx | 4 +- .../kql_filter_provider.test.ts | 28 ++---- .../workflow_surface/kql_filter_provider.ts | 21 +--- ...onnector_event_surface_for_trigger.test.ts | 12 --- ...lve_connector_event_surface_for_trigger.ts | 98 +++++++++++++++---- .../resolve_surface_at_path.test.ts | 94 +++++++++++------- .../api/lib/workflow_connectors.test.ts | 6 +- .../server/api/lib/workflow_connectors.ts | 2 +- .../connector/methods/create/create.test.ts | 4 +- .../connector/methods/create/create.ts | 23 +++-- .../get_connector_spec/get_connector_spec.ts | 2 +- .../connector/methods/update/update.ts | 19 ++-- .../server/inbound/handle_inbound_request.ts | 3 +- .../server/inbound/load_inbound_connector.ts | 3 +- .../inbound/verify_ingress_auth.test.ts | 2 +- .../server/inbound/verify_ingress_auth.ts | 2 +- .../integration_tests/connector_types.test.ts | 2 +- .../agent_builder/server/routes/utils.ts | 3 +- .../execute_connector_sub_action.test.ts | 8 +- .../execute_connector_sub_action.ts | 3 +- .../server/attachment_types/connector.test.ts | 2 +- .../server/attachment_types/connector.ts | 2 +- .../attachment_types/connector_setup.test.ts | 2 +- .../attachment_types/connector_setup.ts | 2 +- .../connector_lifecycle_handler.test.ts | 2 +- .../connector_authoring.test.ts | 2 +- .../list_connector_types.ts | 2 +- .../skills/connector_authoring/utils.ts | 2 +- .../server/sml_types/connector.ts | 2 +- .../register_connector_event_triggers.ts | 54 ++++++++++ .../inbound_webhook/inbound_webhook.tsx | 24 +++++ .../inbound_webhook_connector_fields.tsx | 5 + .../shared/stack_connectors/public/plugin.ts | 12 ++- .../register_connector_event_triggers.ts | 4 +- .../server/connector_types_from_spec/index.ts | 4 +- ...register_inbound_webhook_connector_type.ts | 43 ++++---- .../stack_connectors/server/plugin.test.ts | 2 +- .../shared/stack_connectors/tsconfig.json | 2 +- .../integration_tests/task_cost_check.test.ts | 2 +- .../check_registered_connector_types.ts | 2 +- .../check_registered_task_types.ts | 2 +- 73 files changed, 510 insertions(+), 302 deletions(-) delete mode 100644 src/platform/packages/shared/kbn-connector-specs/server.ts create mode 100644 src/platform/packages/shared/kbn-connector-specs/server/index.ts create mode 100644 src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.test.ts create mode 100644 src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_events/register_connector_event_triggers.ts diff --git a/examples/workflows_extensions_example/server/triggers/index.ts b/examples/workflows_extensions_example/server/triggers/index.ts index 4a94dc05a5a2e..a27fd10c16768 100644 --- a/examples/workflows_extensions_example/server/triggers/index.ts +++ b/examples/workflows_extensions_example/server/triggers/index.ts @@ -10,12 +10,8 @@ import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; import { commonCustomTriggerDefinition } from '../../common/triggers/custom_trigger'; import { commonLoopTriggerDefinition } from '../../common/triggers/loop_trigger'; -import { commonExampleInboundWebhookReceivedTriggerDefinition } from '../../common/connectors/example_inbound_webhook'; export const registerTriggers = (workflowsExtensions: WorkflowsExtensionsServerPluginSetup) => { workflowsExtensions.registerTriggerDefinition(commonCustomTriggerDefinition); workflowsExtensions.registerTriggerDefinition(commonLoopTriggerDefinition); - workflowsExtensions.registerTriggerDefinition( - commonExampleInboundWebhookReceivedTriggerDefinition - ); }; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_action_type_model.test.tsx b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_action_type_model.test.tsx index 6f7d5b37ffa43..39b2d511d6fd9 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_action_type_model.test.tsx +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_action_type_model.test.tsx @@ -12,7 +12,7 @@ import type { FC, PropsWithChildren } from 'react'; import { waitFor, renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { docLinksServiceMock } from '@kbn/core/public/mocks'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import { serializeConnectorSpec } from '@kbn/connector-specs/src/lib/serialize_connector_spec'; import { actionTypeRegistryMock } from '../test_utils/action_type_registry.mock'; import type { ActionTypeModel } from '../types'; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.test.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.test.ts index 7ade167411e9d..5c92b9c434dc5 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.test.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ import { httpServiceMock, docLinksServiceMock } from '@kbn/core/public/mocks'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import { serializeConnectorSpec } from '@kbn/connector-specs/src/lib/serialize_connector_spec'; import type { ConnectorSpecWireResponse } from '../apis/fetch_connector_spec'; import { diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts index 9235f5083abca..80650b6dffdcb 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * as connectorsSpecs from './src/all_specs'; export type * from './src/connector_spec'; export type * from './src/connector_spec_events'; export { defineConnectorEvent } from './src/define_connector_event'; @@ -30,22 +29,12 @@ export { type JwtAlgorithm, } from './src/auth_types/oauth_client_credentials_private_key_jwt'; -export { getConnectorSpec } from './src/get_connector_spec'; -export { - listConnectorEventInfos, - listConnectorEventInfosForType, - type ConnectorEventInfo, -} from './src/list_connector_event_infos'; -export { resolveRegisteredConnectorEventByEventId } from './src/resolve_registered_connector_event_by_event_id'; export { inboundWebhookReceivedEventSchema } from './src/inbound_webhook_received_event_schema'; export { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, INBOUND_WEBHOOK_RECEIVED_EVENT_ID, INBOUND_WEBHOOK_RECEIVED_EVENT_KEY, } from './src/inbound_webhook_constants'; -export { computeIngestTokenHash } from './src/inbound_webhook/compute_ingest_token_hash'; -export { filterInboundHeaders } from './src/inbound_webhook/filter_inbound_headers'; -export { InboundWebhookConnector } from './src/specs/inbound_webhook/inbound_webhook'; export { isToolAction, TEST_CONNECTOR_SUB_ACTION } from './src/connector_spec'; export { getConnectorActionErrorMeta, diff --git a/src/platform/packages/shared/kbn-connector-specs/server.ts b/src/platform/packages/shared/kbn-connector-specs/server.ts deleted file mode 100644 index ad21076a9ca93..0000000000000 --- a/src/platform/packages/shared/kbn-connector-specs/server.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ diff --git a/src/platform/packages/shared/kbn-connector-specs/server/index.ts b/src/platform/packages/shared/kbn-connector-specs/server/index.ts new file mode 100644 index 0000000000000..292134c05b933 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/server/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { getConnectorSpec } from '../src/get_connector_spec'; +export { + listConnectorEventInfos, + listConnectorEventInfosForType, + type ConnectorEventInfo, +} from '../src/list_connector_event_infos'; +export { resolveRegisteredConnectorEventByEventId } from '../src/resolve_registered_connector_event_by_event_id'; +export { computeIngestTokenHash } from '../src/inbound_webhook/compute_ingest_token_hash'; +export { filterInboundHeaders } from '../src/inbound_webhook/filter_inbound_headers'; +export { InboundWebhookConnector } from '../src/specs/inbound_webhook/inbound_webhook'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts index a5aa54cfefde1..b6edb1da24f67 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/ears_experimental_utils.ts @@ -8,7 +8,6 @@ */ import { isString } from 'lodash'; -import * as allSpecs from '../all_specs'; import { EARS_AUTH_ID } from '../auth_types/ears'; import type { AuthTypeDef } from '../connector_spec'; @@ -17,11 +16,12 @@ export const isEarsExperimentalAuthType = ( ): authType is AuthTypeDef => !isString(authType) && authType.type === EARS_AUTH_ID && authType.isExperimental === true; -const experimentalEarsConnectorIds = new Set( - Object.values(allSpecs) - .filter((spec) => spec.auth?.types.some(isEarsExperimentalAuthType)) - .map((spec) => spec.metadata.id) -); +/** Connector type IDs that use experimental EARS auth — kept explicit so public UI can tree-shake all_specs. */ +const EXPERIMENTAL_EARS_CONNECTOR_TYPE_IDS = new Set([ + '.gmail', + '.google_calendar', + '.google_drive', +]); export const isEarsExperimentalConnector = (connectorTypeId: string): boolean => - experimentalEarsConnectorIds.has(connectorTypeId); + EXPERIMENTAL_EARS_CONNECTOR_TYPE_IDS.has(connectorTypeId); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts index b87d71bfa48ea..6d318389acada 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts @@ -9,7 +9,8 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { InboundWebhookConnector, INBOUND_WEBHOOK_RECEIVED_EVENT_ID } from '@kbn/connector-specs'; +import { INBOUND_WEBHOOK_RECEIVED_EVENT_ID } from '@kbn/connector-specs'; +import { InboundWebhookConnector } from './inbound_webhook'; describe('InboundWebhookConnector.handleEvents', () => { it('returns inboundWebhook.received payload', async () => { diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts index 8b5caf5a710f9..fba9b5f084800 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts @@ -50,23 +50,6 @@ export const InboundWebhookConnector: ConnectorSpec = { defaultMessage: 'Ingest token hash', }), }), - ingestToken: z - .string() - .min(32) - .optional() - .meta({ - sensitive: true, - label: i18n.translate('core.kibanaConnectorSpecs.inboundWebhook.secrets.ingestToken', { - defaultMessage: 'Ingest token', - }), - helpText: i18n.translate( - 'core.kibanaConnectorSpecs.inboundWebhook.secrets.ingestTokenHelp', - { - defaultMessage: - 'Secret token for authenticating inbound HTTP requests. Leave blank on create to auto-generate.', - } - ), - }), webhookUrl: z .string() .url() diff --git a/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts index c393c1299f61c..122c3f1f575c9 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { inboundWebhookReceivedEventSchema } from './inbound_webhook_received_event_schema'; +import { inboundWebhookReceivedEventSchema } from '@kbn/connector-specs'; import type { ConnectorMetadata } from './connector_spec'; import { defineConnectorEvent } from './define_connector_event'; import { diff --git a/src/platform/packages/shared/kbn-workflows/server/index.ts b/src/platform/packages/shared/kbn-workflows/server/index.ts index 37bce49e385b5..0c77851b7a862 100644 --- a/src/platform/packages/shared/kbn-workflows/server/index.ts +++ b/src/platform/packages/shared/kbn-workflows/server/index.ts @@ -38,6 +38,13 @@ export { buildExternalResumeUrl } from './external_resume/build_external_resume_ export { buildExternalResumeFormUrl } from './external_resume/build_external_resume_form_url'; export { computeTokenHmac } from './external_resume/compute_token_hmac'; +export { + resolveConnectorEventTriggerDefinition, +} from '../spec/workflow_surface/resolve_connector_event_trigger_definition'; +export { + resolveConnectorEventWorkflowSurface, +} from '../spec/workflow_surface/resolve_connector_event_workflow_surface'; + export type { GetManagedWorkflowStatusOptions, ManagedWorkflowStatus, diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts index 3acc7a700845a..0b2d340cd346e 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; import { collectConnectorEventsFromTypes } from './collect_connector_events_from_types'; import type { ConnectorEventInfo, ConnectorTypeInfo, StabilityLevel } from '../../../types/latest'; @@ -50,27 +49,15 @@ export const collectConnectorEventsForTriggerSchema = ( const connectorEventIds = new Set(connectorEvents.map((event) => event.eventId)); for (const trigger of normalizedTriggers) { - if (!connectorEventIds.has(trigger.id)) { - const fromSpec = resolveRegisteredConnectorEventByEventId(trigger.id); - if (fromSpec) { - connectorEvents.push({ - eventKey: fromSpec.eventKey, - eventId: fromSpec.eventId, - title: fromSpec.title, - description: fromSpec.description, - stability: fromSpec.stability, - }); - connectorEventIds.add(fromSpec.eventId); - } else if (trigger.requiresConnectorId) { - connectorEvents.push({ - eventKey: eventKeyFromTriggerId(trigger.id), - eventId: trigger.id, - title: trigger.title, - description: trigger.description, - stability: trigger.stability, - }); - connectorEventIds.add(trigger.id); - } + if (!connectorEventIds.has(trigger.id) && trigger.requiresConnectorId) { + connectorEvents.push({ + eventKey: eventKeyFromTriggerId(trigger.id), + eventId: trigger.id, + title: trigger.title, + description: trigger.description, + stability: trigger.stability, + }); + connectorEventIds.add(trigger.id); } } diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts index ffa96b37ae1b8..a3114a2d36be9 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts @@ -7,9 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { inboundWebhookReceivedEventSchema } from '@kbn/connector-specs'; import { defineConnectorEvent, - inboundWebhookReceivedEventSchema, toRegisteredConnectorEvent, } from '@kbn/connector-specs'; import type { ConnectorMetadata } from '@kbn/connector-specs'; diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts index 14891a1ff3706..22d71894f9935 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts @@ -20,5 +20,3 @@ export { connectorEventToTriggerDefinition, type ConnectorEventTriggerDefinition, } from './connector_event_to_trigger_definition'; -export { resolveConnectorEventTriggerDefinition } from './resolve_connector_event_trigger_definition'; -export { resolveConnectorEventWorkflowSurface } from './resolve_connector_event_workflow_surface'; diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts index 707a5c283cd5a..68ca047f8422a 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.test.ts @@ -35,8 +35,8 @@ const mockInboundWebhookReceivedEvent = toRegisteredConnectorEvent( }) ); -jest.mock('@kbn/connector-specs', () => { - const actual = jest.requireActual('@kbn/connector-specs'); +jest.mock('@kbn/connector-specs/server', () => { + const actual = jest.requireActual('@kbn/connector-specs/server'); return { ...actual, resolveRegisteredConnectorEventByEventId: jest.fn((eventId: string) => diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts index 3da44a13489ba..c9952941204b9 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_trigger_definition.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; +import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs/server'; import { connectorEventToTriggerDefinition, type ConnectorEventTriggerDefinition, diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts index 62e3d1a140872..87cf328ed4daa 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts @@ -20,8 +20,8 @@ const mockInboundWebhookReceivedEvent = { eventSchema: mockInboundWebhookReceivedEventSchema, }; -jest.mock('@kbn/connector-specs', () => { - const actual = jest.requireActual('@kbn/connector-specs'); +jest.mock('@kbn/connector-specs/server', () => { + const actual = jest.requireActual('@kbn/connector-specs/server'); return { ...actual, resolveRegisteredConnectorEventByEventId: jest.fn((eventId: string) => diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts index 5e93500e72ba2..335fbfac6c3ad 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; +import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs/server'; import { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; import type { WorkflowSurfaceDefinition } from './types'; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/connector_step.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/connector_step.ts index 554855258884a..b0e4e069003b4 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/connector_step.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/connector_step.ts @@ -8,7 +8,7 @@ */ import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import { SystemConnectorsMap } from '@kbn/workflows/common/constants'; import { ExecutionError } from '@kbn/workflows/server'; import { ActionsResponseContentLengthLimitError } from './actions_response_content_length_limit_error'; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts index ddae844c6329c..511d33f002734 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.test.ts @@ -14,8 +14,8 @@ import { readTriggerConnectorId, } from './connector_event_trigger_gate'; -jest.mock('@kbn/workflows', () => ({ - ...jest.requireActual('@kbn/workflows'), +jest.mock('@kbn/workflows/server', () => ({ + ...jest.requireActual('@kbn/workflows/server'), resolveConnectorEventTriggerDefinition: jest.fn((triggerId: string) => triggerId === 'inboundWebhook.received' ? { id: triggerId } : undefined ), diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts index ba9dc3a3eaf75..14b51016b8fd5 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/connector_event_trigger_gate.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; +import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows/server'; export const isConnectorEventTriggerId = (triggerId: string): boolean => resolveConnectorEventTriggerDefinition(triggerId) !== undefined; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts index 6563b17c5403c..b022771ae52cd 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/filter_workflows_by_trigger_condition.test.ts @@ -14,8 +14,8 @@ import { workflowMatchesTriggerCondition, } from './filter_workflows_by_trigger_condition'; -jest.mock('@kbn/workflows', () => ({ - ...jest.requireActual('@kbn/workflows'), +jest.mock('@kbn/workflows/server', () => ({ + ...jest.requireActual('@kbn/workflows/server'), resolveConnectorEventTriggerDefinition: jest.fn((triggerId: string) => triggerId === 'inboundWebhook.received' ? { id: triggerId } : undefined ), diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts index 030f42d3cf3c2..22677e4430c35 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.ts @@ -7,16 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; +import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows/server'; import { resolveTriggerDefinition } from './resolve_trigger_definition'; -jest.mock('@kbn/workflows', () => { - const actual = jest.requireActual('@kbn/workflows'); - return { - ...actual, - resolveConnectorEventTriggerDefinition: jest.fn(), - }; -}); +jest.mock('@kbn/workflows/server', () => ({ + ...jest.requireActual('@kbn/workflows/server'), + resolveConnectorEventTriggerDefinition: jest.fn(), +})); const mockResolveConnectorEventTriggerDefinition = resolveConnectorEventTriggerDefinition as jest.MockedFunction< diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts index dc8016918351c..1ffc03f159d18 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; +import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows/server'; import type { WorkflowsExtensionsServerPluginStart } from '@kbn/workflows-extensions/server'; import type { ServerTriggerDefinition } from '@kbn/workflows-extensions/server/types'; diff --git a/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts b/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts index 47ab4875adf9c..85098fd0f875a 100644 --- a/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts @@ -17,7 +17,7 @@ import { XSOARRunActionParamsSchema, XSOARRunActionResponseSchema, } from '@kbn/connector-schemas/xsoar'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import { i18n } from '@kbn/i18n'; import type { BaseConnectorContract } from '@kbn/workflows'; import { FetcherConfigSchema, KibanaHttpMethodSchema, KibanaStepMetaSchema } from '@kbn/workflows'; diff --git a/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts b/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts index f0ad728fdba39..42c010d9a143f 100644 --- a/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts +++ b/src/platform/plugins/shared/workflows_management/common/connector_sub_actions_map.ts @@ -56,7 +56,7 @@ import { SUB_ACTION as XSOAR_SUB_ACTION, } from '@kbn/connector-schemas/xsoar/constants'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; // Helper function to format sub-action names for display function formatSubActionName(action: string): string { diff --git a/src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.test.ts b/src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.test.ts new file mode 100644 index 0000000000000..c87f9164474d0 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ConnectorTypeInfo } from '@kbn/workflows'; +import { getCachedDynamicConnectorTypes } from '../schema'; +import { isConnectorEventTriggerId } from './is_connector_event_trigger_id'; + +jest.mock('../schema', () => ({ + getCachedDynamicConnectorTypes: jest.fn(() => null), +})); + +describe('isConnectorEventTriggerId', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getCachedDynamicConnectorTypes as jest.Mock).mockReturnValue(null); + }); + + it('returns false when connector types are not cached', () => { + expect(isConnectorEventTriggerId('inboundWebhook.received')).toBe(false); + }); + + it('returns true when the id matches a cached connector event', () => { + const connectorTypes: Record = { + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'When an HTTP request is received on the connector endpoint.', + stability: 'tech_preview', + }, + ], + }, + }; + + (getCachedDynamicConnectorTypes as jest.Mock).mockReturnValue(connectorTypes); + + expect(isConnectorEventTriggerId('inboundWebhook.received')).toBe(true); + expect(isConnectorEventTriggerId('slack.postMessage')).toBe(false); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.ts b/src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.ts new file mode 100644 index 0000000000000..ebca45f4f3173 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/common/lib/is_connector_event_trigger_id.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getCachedDynamicConnectorTypes } from '../schema'; + +/** + * Returns whether the given id is a connector-event trigger from the cached + * `/api/workflows/connectors` response (e.g. `inboundWebhook.received`). + */ +export const isConnectorEventTriggerId = (triggerId: string): boolean => { + const connectorTypes = getCachedDynamicConnectorTypes(); + if (!connectorTypes) { + return false; + } + + for (const connectorType of Object.values(connectorTypes)) { + if (connectorType.events?.some((event) => event.eventId === triggerId)) { + return true; + } + } + + return false; +}; diff --git a/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts b/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts index 38d4996b0d5fc..18a5342051766 100644 --- a/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts @@ -23,5 +23,7 @@ export const mapRegisteredTriggersForSchema = ( title: trigger.title, description: trigger.description, stability: trigger.stability, - requiresConnectorId: trigger.requiresConnectorId, + requiresConnectorId: + trigger.requiresConnectorId ?? + ('eventSchema' in trigger && trigger.eventSchema !== undefined), })); diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.stories.tsx b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.stories.tsx index a3d3896f045d4..f8473764638a2 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.stories.tsx +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/step_icon.stories.tsx @@ -18,7 +18,8 @@ import { } from '@elastic/eui'; import React, { Suspense, useEffect, useState } from 'react'; import { TypeRegistry } from '@kbn/alerts-ui-shared/lib'; -import { type ConnectorSpec, connectorsSpecs } from '@kbn/connector-specs'; +import type { ConnectorSpec } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import { ConnectorIconsMap } from '@kbn/connector-specs/icons'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { Logger } from '@kbn/logging'; diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts index 162a06b04dac7..3c32ae1f673bf 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.test.ts @@ -13,23 +13,18 @@ jest.mock('../../../../trigger_schemas', () => ({ }, })); -jest.mock('@kbn/workflows', () => { - const actual = jest.requireActual('@kbn/workflows'); - return { - ...actual, - resolveConnectorEventWorkflowSurface: jest.fn(), - }; -}); +jest.mock('../../../../../common/lib/is_connector_event_trigger_id', () => ({ + isConnectorEventTriggerId: jest.fn(() => false), +})); -import { resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; import { generateTriggerSnippet } from './generate_trigger_snippet'; +import { isConnectorEventTriggerId } from '../../../../../common/lib/is_connector_event_trigger_id'; import { triggerSchemas } from '../../../../trigger_schemas'; describe('generateTriggerSnippet', () => { beforeEach(() => { jest.clearAllMocks(); (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue(undefined); - (resolveConnectorEventWorkflowSurface as jest.Mock).mockReturnValue(undefined); }); describe('built-in trigger types (alert, manual, scheduled)', () => { it('should not include on.condition for alert, manual or scheduled', () => { @@ -78,8 +73,9 @@ describe('generateTriggerSnippet', () => { }); it('uses an explicit defaultConnectorId when provided', () => { - (resolveConnectorEventWorkflowSurface as jest.Mock).mockReturnValue({ - binding: { connectorTypeId: '.exampleInboundWebhook', instanceRef: 'required' }, + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ + id: 'exampleInboundWebhook.received', + requiresConnectorId: true, }); const snippet = generateTriggerSnippet('exampleInboundWebhook.received', { @@ -90,9 +86,10 @@ describe('generateTriggerSnippet', () => { expect(snippet).toContain('connector-id: example-inbound-webhook'); }); - it('includes connector-id when resolved from connector-event workflow surface', () => { - (resolveConnectorEventWorkflowSurface as jest.Mock).mockReturnValue({ - binding: { connectorTypeId: '.inboundWebhook', instanceRef: 'required' }, + it('includes connector-id when the extension trigger requires connector binding', () => { + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ + id: 'inboundWebhook.received', + requiresConnectorId: true, }); const snippet = generateTriggerSnippet('inboundWebhook.received', { full: true }); @@ -100,5 +97,14 @@ describe('generateTriggerSnippet', () => { expect(snippet).toContain('connector-id: '); expect(snippet).toContain('condition:'); }); + + it('includes connector-id for cached connector-event triggers without extension registration', () => { + (isConnectorEventTriggerId as jest.Mock).mockReturnValueOnce(true); + + const snippet = generateTriggerSnippet('inboundWebhook.received', { full: true }); + + expect(snippet).toContain('connector-id: '); + expect(snippet).toContain('condition:'); + }); }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts index e0fa917117134..8e673a0ded56a 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_trigger_snippet.ts @@ -9,7 +9,8 @@ import type { ToStringOptions } from 'yaml'; import { stringify } from 'yaml'; -import { isTriggerType, resolveConnectorEventWorkflowSurface } from '@kbn/workflows'; +import { isTriggerType } from '@kbn/workflows'; +import { isConnectorEventTriggerId } from '../../../../../common/lib/is_connector_event_trigger_id'; import { triggerSchemas } from '../../../../trigger_schemas'; /** Comment added above condition in custom trigger snippets to explain KQL and event.* usage. */ @@ -49,10 +50,8 @@ export function generateTriggerSnippet( let parameters: Record; // eslint-disable-line @typescript-eslint/no-explicit-any const triggerDefinition = triggerSchemas.getTriggerDefinition(triggerType); - const connectorEventSurface = resolveConnectorEventWorkflowSurface(triggerType); const requiresConnectorId = - connectorEventSurface?.binding.instanceRef === 'required' || - triggerDefinition?.requiresConnectorId === true; + triggerDefinition?.requiresConnectorId === true || isConnectorEventTriggerId(triggerType); const resolvedConnectorId = defaultConnectorId ?? (monacoSuggestionFormat ? '${1:}' : ''); const resolvedCondition = defaultCondition ?? triggerDefinition?.snippets?.condition ?? ''; diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index ef58c42ff7cb4..7bbb6b2578834 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -20,9 +20,9 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { isTriggerType, - resolveConnectorEventWorkflowSurface, WORKFLOWS_EXPERIMENTAL_FEATURES_SETTING_ID, } from '@kbn/workflows'; +import { isConnectorEventTriggerId } from '../../../../common/lib/is_connector_event_trigger_id'; import { useWorkflowsMonacoTheme, WORKFLOW_MONACO_LAYOUT_OPTIONS } from '@kbn/workflows-ui'; import type { z } from '@kbn/zod/v4'; import { ActionsMenuButton } from './actions_menu_button'; @@ -656,7 +656,7 @@ export const WorkflowYAMLEditor = ({ if ( isTriggerType(action.id) || triggerSchemas.isRegisteredTriggerId(action.id) || - resolveConnectorEventWorkflowSurface(action.id) !== undefined + isConnectorEventTriggerId(action.id) ) { const triggerDefinition = triggerSchemas.getTriggerDefinition(action.id); insertTriggerSnippet( diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts index 0cc0c3e023055..0e95cf2b64ec6 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.ts @@ -19,31 +19,20 @@ const mockConnectorEventSchema = z.object({ body: z.unknown(), }); -jest.mock('@kbn/workflows', () => { - const actual = jest.requireActual('@kbn/workflows'); - return { - ...actual, - resolveConnectorEventTriggerDefinition: jest.fn((triggerId: string) => - triggerId === 'inboundWebhook.received' - ? { - id: 'inboundWebhook.received', - title: 'Webhook received', - description: 'Fires when an authenticated request hits this connector endpoint.', - stability: 'tech_preview', - eventSchema: mockConnectorEventSchema, - } - : undefined - ), - }; -}); - describe('getTriggerConditionDefinition', () => { afterEach(() => { jest.restoreAllMocks(); }); it('returns connector-event trigger definitions with eventSchema for KQL autocomplete', () => { - jest.spyOn(triggerSchemas, 'getTriggerDefinition').mockReturnValue(undefined); + jest.spyOn(triggerSchemas, 'getTriggerDefinition').mockReturnValue({ + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + stability: 'tech_preview', + requiresConnectorId: true, + eventSchema: mockConnectorEventSchema, + }); const doc = parseDocument(`triggers: - type: inboundWebhook.received @@ -57,6 +46,7 @@ describe('getTriggerConditionDefinition', () => { title: 'Webhook received', description: 'Fires when an authenticated request hits this connector endpoint.', stability: 'tech_preview', + requiresConnectorId: true, eventSchema: mockConnectorEventSchema, }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts index f13dd80404e08..8b864a7ba6d31 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts @@ -8,7 +8,6 @@ */ import type { Document } from 'yaml'; -import { resolveConnectorEventTriggerDefinition } from '@kbn/workflows'; import type { PublicTriggerDefinition } from '@kbn/workflows-extensions/public'; import { triggerSchemas } from '../trigger_schemas'; import { @@ -18,7 +17,7 @@ import { /** * Returns the trigger definition used for KQL autocomplete on `triggers[i].on.condition`. - * Checks workflows_extensions first, then connector-event triggers from ConnectorSpec.events. + * Checks workflows_extensions registered trigger definitions (including connector events). */ export const getTriggerConditionDefinition = ( yamlDocument: Document, @@ -34,21 +33,5 @@ export const getTriggerConditionDefinition = ( return undefined; } - const registered = triggerSchemas.getTriggerDefinition(triggerType); - if (registered) { - return registered; - } - - const connectorEventDefinition = resolveConnectorEventTriggerDefinition(triggerType); - if (!connectorEventDefinition) { - return undefined; - } - - return { - id: connectorEventDefinition.id, - title: connectorEventDefinition.title, - description: connectorEventDefinition.description, - stability: connectorEventDefinition.stability, - eventSchema: connectorEventDefinition.eventSchema, - }; + return triggerSchemas.getTriggerDefinition(triggerType); }; diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts index 5d8275173b035..280141a7ee1c7 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts @@ -14,18 +14,6 @@ import { resolveConnectorEventSurfaceForTriggerId, } from './resolve_connector_event_surface_for_trigger'; -jest.mock('@kbn/workflows', () => { - const actual = jest.requireActual('@kbn/workflows'); - return { - ...actual, - resolveConnectorEventWorkflowSurface: jest.fn(() => undefined), - }; -}); - -jest.mock('@kbn/connector-specs', () => ({ - resolveRegisteredConnectorEventByEventId: jest.fn(() => undefined), -})); - describe('resolve_connector_event_surface_for_trigger', () => { const extensionTrigger = { id: 'exampleInboundWebhook.received', diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts index 4cff7d1866b56..ad592c5cd05b6 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts @@ -7,11 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs'; -import type { ConnectorTypeInfo, WorkflowSurfaceDefinition } from '@kbn/workflows'; -import { - connectorEventToWorkflowSurface, - resolveConnectorEventWorkflowSurface, +import type { + ConnectorEventInfo, + ConnectorTypeInfo, + WorkflowSurfaceDefinition, } from '@kbn/workflows'; import type { PublicTriggerDefinition } from '@kbn/workflows-extensions/public'; @@ -23,36 +22,84 @@ export const inferConnectorTypeIdFromEventId = (eventId: string): string | undef return `.${eventId.slice(0, dotIndex)}`; }; -export const findConnectorTypeIdForEventTrigger = ( +export const findConnectorEventForTrigger = ( triggerId: string, connectorTypes: Record -): string | undefined => { +): { event: ConnectorEventInfo; connectorTypeId: string } | undefined => { for (const connectorType of Object.values(connectorTypes)) { - if (connectorType.events?.some((event) => event.eventId === triggerId)) { - return connectorType.actionTypeId; + const event = connectorType.events?.find((candidate) => candidate.eventId === triggerId); + if (event) { + return { event, connectorTypeId: connectorType.actionTypeId }; } } - return inferConnectorTypeIdFromEventId(triggerId); + return undefined; +}; + +export const findConnectorTypeIdForEventTrigger = ( + triggerId: string, + connectorTypes: Record +): string | undefined => { + return ( + findConnectorEventForTrigger(triggerId, connectorTypes)?.connectorTypeId ?? + inferConnectorTypeIdFromEventId(triggerId) + ); +}; + +const connectorEventInfoToWorkflowSurface = ({ + event, + connectorTypeId, + extensionTrigger, +}: { + event: ConnectorEventInfo; + connectorTypeId: string; + extensionTrigger?: PublicTriggerDefinition; +}): WorkflowSurfaceDefinition => { + const eventSchema = extensionTrigger?.eventSchema; + + return { + id: event.eventId, + kind: 'trigger', + title: event.title, + description: event.description, + stability: event.stability ?? extensionTrigger?.stability ?? 'tech_preview', + binding: { + connectorTypeId, + instanceRef: 'required', + }, + surfaces: eventSchema + ? { + input: eventSchema, + filter: { + schema: eventSchema, + language: 'kql', + yamlPath: 'on.condition', + }, + } + : {}, + source: { + type: 'connector-event', + connectorTypeId, + eventKey: event.eventKey, + }, + }; }; /** - * Resolves a connector-event workflow surface for a trigger id using ConnectorSpec, - * dynamic connector types from the API, or extension trigger metadata. + * Resolves a connector-event workflow surface for a trigger id using connector types + * from the API and extension trigger metadata (never ConnectorSpec in the browser). */ export const resolveConnectorEventSurfaceForTriggerId = ( triggerId: string, connectorTypes: Record, extensionTrigger?: PublicTriggerDefinition ): WorkflowSurfaceDefinition | undefined => { - const fromRegisteredSpec = resolveConnectorEventWorkflowSurface(triggerId); - if (fromRegisteredSpec) { - return fromRegisteredSpec; - } - - const registeredEvent = resolveRegisteredConnectorEventByEventId(triggerId); - if (registeredEvent) { - return connectorEventToWorkflowSurface(registeredEvent); + const connectorEvent = findConnectorEventForTrigger(triggerId, connectorTypes); + if (connectorEvent) { + return connectorEventInfoToWorkflowSurface({ + ...connectorEvent, + extensionTrigger, + }); } const connectorTypeId = findConnectorTypeIdForEventTrigger(triggerId, connectorTypes); @@ -74,7 +121,16 @@ export const resolveConnectorEventSurfaceForTriggerId = ( connectorTypeId, instanceRef: 'required', }, - surfaces: {}, + surfaces: extensionTrigger.eventSchema + ? { + input: extensionTrigger.eventSchema, + filter: { + schema: extensionTrigger.eventSchema, + language: 'kql', + yamlPath: 'on.condition', + }, + } + : {}, source: { type: 'connector-event', connectorTypeId, diff --git a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts index 9256ce40f75cd..2eee93eb05d60 100644 --- a/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts @@ -8,45 +8,38 @@ */ import { parseDocument } from 'yaml'; -import { z } from '@kbn/zod/v4'; +import type { ConnectorTypeInfo } from '@kbn/workflows'; +import { resolveSurfaceAtPath } from './resolve_surface_at_path'; +import { triggerSchemas } from '../trigger_schemas'; -const mockInboundWebhookReceivedEventSchema = z.object({ - connectorId: z.string(), - body: z.unknown(), -}); +jest.mock('../trigger_schemas', () => ({ + triggerSchemas: { + getTriggerDefinition: jest.fn(), + }, +})); -jest.mock('@kbn/workflows', () => { - const actual = jest.requireActual('@kbn/workflows'); - return { - ...actual, - resolveConnectorEventWorkflowSurface: jest.fn((eventId: string) => - eventId === 'inboundWebhook.received' - ? { - id: 'inboundWebhook.received', - kind: 'trigger', - title: 'Webhook received', - description: 'Fires when an authenticated request hits this connector endpoint.', - stability: 'tech_preview', - binding: { - connectorTypeId: '.inboundWebhook', - instanceRef: 'required', - }, - surfaces: { - filter: { - schema: mockInboundWebhookReceivedEventSchema, - language: 'kql', - yamlPath: 'on.condition', - }, - }, - } - : undefined - ), +describe('resolveSurfaceAtPath', () => { + const connectorTypes: Record = { + '.inboundWebhook': { + actionTypeId: '.inboundWebhook', + displayName: 'Inbound Webhook', + instances: [], + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + subActions: [], + events: [ + { + eventKey: 'received', + eventId: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + }, + ], + }, }; -}); - -import { resolveSurfaceAtPath } from './resolve_surface_at_path'; -describe('resolveSurfaceAtPath', () => { const yaml = `triggers: - type: inboundWebhook.received connector-id: sales-ingress @@ -54,9 +47,23 @@ describe('resolveSurfaceAtPath', () => { condition: 'event.body.eventType: "order.created"' `; + beforeEach(() => { + jest.clearAllMocks(); + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue(undefined); + }); + it('resolves connector-id role on triggers[i].connector-id', () => { + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + stability: 'tech_preview', + requiresConnectorId: true, + }); const doc = parseDocument(yaml); - expect(resolveSurfaceAtPath(doc, ['triggers', 0, 'connector-id'])).toEqual({ + expect( + resolveSurfaceAtPath(doc, ['triggers', 0, 'connector-id'], { connectorTypes }) + ).toEqual({ role: 'connector-id', surface: expect.objectContaining({ id: 'inboundWebhook.received', @@ -66,8 +73,17 @@ describe('resolveSurfaceAtPath', () => { }); it('resolves kql-filter role on triggers[i].on.condition', () => { + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an authenticated request hits this connector endpoint.', + stability: 'tech_preview', + requiresConnectorId: true, + }); const doc = parseDocument(yaml); - expect(resolveSurfaceAtPath(doc, ['triggers', 0, 'on', 'condition'])).toEqual({ + expect( + resolveSurfaceAtPath(doc, ['triggers', 0, 'on', 'condition'], { connectorTypes }) + ).toEqual({ role: 'kql-filter', surface: expect.objectContaining({ id: 'inboundWebhook.received' }), }); @@ -79,6 +95,8 @@ describe('resolveSurfaceAtPath', () => { on: condition: 'event.severity: "high"' `); - expect(resolveSurfaceAtPath(doc, ['triggers', 0, 'on', 'condition'])).toBeUndefined(); + expect( + resolveSurfaceAtPath(doc, ['triggers', 0, 'on', 'condition'], { connectorTypes }) + ).toBeUndefined(); }); }); diff --git a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts index ec8dfc2b0e3a8..5d87c00895f5d 100644 --- a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.test.ts @@ -7,15 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -jest.mock('@kbn/connector-specs', () => { - const actual = jest.requireActual('@kbn/connector-specs'); +jest.mock('@kbn/connector-specs/server', () => { + const actual = jest.requireActual('@kbn/connector-specs/server'); return { ...actual, listConnectorEventInfosForType: jest.fn(() => []), }; }); -import { listConnectorEventInfosForType } from '@kbn/connector-specs'; +import { listConnectorEventInfosForType } from '@kbn/connector-specs/server'; import { getAvailableConnectors } from './workflow_connectors'; diff --git a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts index 96ccc49a8cf42..d0fdf7b629fc0 100644 --- a/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts +++ b/src/platform/plugins/shared/workflows_management/server/api/lib/workflow_connectors.ts @@ -10,7 +10,7 @@ import { WorkflowsConnectorFeatureId } from '@kbn/actions-plugin/common/connector_feature_config'; import type { ActionsClient, IUnsecuredActionsClient } from '@kbn/actions-plugin/server'; import type { FindActionResult } from '@kbn/actions-plugin/server/types'; -import { listConnectorEventInfosForType } from '@kbn/connector-specs'; +import { listConnectorEventInfosForType } from '@kbn/connector-specs/server'; import type { KibanaRequest } from '@kbn/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type { ConnectorEventInfo, ConnectorTypeInfo } from '@kbn/workflows'; diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.test.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.test.ts index b89b5be5d6f3f..58aff252ff099 100644 --- a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.test.ts @@ -807,8 +807,8 @@ describe('create()', () => { expect(preSaveHook).toHaveBeenCalledWith({ connectorId: 'mock-saved-object-id', - config: { foo: 'bar' }, - secrets: { apiKey: 'secret' }, + config: {}, + secrets: {}, logger, request, services: { scopedClusterClient }, diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.ts index d7125a7e718d3..d7eb736da30a6 100644 --- a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.ts +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/create/create.ts @@ -81,6 +81,12 @@ export async function create({ const validatedActionTypeSecrets = validateSecrets(actionType, secrets, { configurationUtilities, }); + const configForSaveHook = { + ...(validatedActionTypeConfig as Record), + }; + const secretsForSaveHook = { + ...(validatedActionTypeSecrets as Record), + }; if (actionType.validate?.connector) { validateConnector(actionType, { config, secrets }); } @@ -98,8 +104,8 @@ export async function create({ try { await actionType.preSaveHook({ connectorId: id, - config, - secrets, + config: configForSaveHook, + secrets: secretsForSaveHook, logger: context.logger, request: context.request, services: hookServices, @@ -126,17 +132,14 @@ export async function create({ ); const authMode = inferAuthMode({ authTypeRegistry: context.authTypeRegistry, - secrets, - config, + secrets: secretsForSaveHook, + config: configForSaveHook, }); const configForSave = actionType.source === ACTION_TYPE_SOURCES.spec - ? ensureConfigAuthType( - validatedActionTypeConfig as Record, - validatedActionTypeSecrets as Record - ) - : validatedActionTypeConfig; + ? ensureConfigAuthType(configForSaveHook, secretsForSaveHook) + : configForSaveHook; const result = await tryCatch( async () => @@ -147,7 +150,7 @@ export async function create({ name, isMissingSecrets: false, config: configForSave as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, + secrets: secretsForSaveHook as SavedObjectAttributes, ...(authMode !== undefined ? { authMode } : {}), }, { id } diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts index cd531c21c70bb..97d3c07f51f0f 100644 --- a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import { serializeConnectorSpec } from '@kbn/connector-specs/src/lib/serialize_connector_spec'; import { ConnectorAuditAction, connectorAuditEvent } from '../../../../lib/audit_events'; import type { GetConnectorSpecParams } from './types'; diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/update/update.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/update/update.ts index 28f1ea376984f..4c667f4d70311 100644 --- a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/update/update.ts +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/update/update.ts @@ -109,6 +109,12 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr const validatedActionTypeSecrets = validateSecrets(actionType, secrets, { configurationUtilities, }); + const configForSaveHook = { + ...(validatedActionTypeConfig as Record), + }; + const secretsForSaveHook = { + ...(validatedActionTypeSecrets as Record), + }; if (actionType.validate?.connector) { validateConnector(actionType, { config, secrets }); } @@ -123,8 +129,8 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr try { await actionType.preSaveHook({ connectorId: id, - config, - secrets, + config: configForSaveHook, + secrets: secretsForSaveHook, logger: context.logger, request: context.request, services: hookServices, @@ -152,11 +158,8 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr const configForSave = actionType.source === ACTION_TYPE_SOURCES.spec - ? ensureConfigAuthType( - validatedActionTypeConfig as Record, - validatedActionTypeSecrets as Record - ) - : validatedActionTypeConfig; + ? ensureConfigAuthType(configForSaveHook, secretsForSaveHook) + : configForSaveHook; const result = await tryCatch( async () => @@ -168,7 +171,7 @@ export async function update({ context, id, action }: ConnectorUpdateParams): Pr name, isMissingSecrets: false, config: configForSave as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, + secrets: secretsForSaveHook as SavedObjectAttributes, }, omitBy( { diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts b/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts index b7cf4eaee15d1..de249d2ba80a7 100644 --- a/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts +++ b/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts @@ -7,7 +7,8 @@ import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import { getConnectorSpec, normalizeConnectorTypeId } from '@kbn/connector-specs'; +import { normalizeConnectorTypeId } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { ActionsConfig } from '../config'; import type { ActionTypeRegistry, ConnectorEventEmitParams, InMemoryConnector } from '../types'; diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts b/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts index b4a40a42a57cc..9387722aa8830 100644 --- a/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts +++ b/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts @@ -7,7 +7,8 @@ import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import { getConnectorSpec, normalizeConnectorTypeId } from '@kbn/connector-specs'; +import { normalizeConnectorTypeId } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { ActionTypeRegistry, InMemoryConnector, RawAction } from '../types'; import { ACTION_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts index 53f30457369c8..017a6221efa03 100644 --- a/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { computeIngestTokenHash } from '@kbn/connector-specs'; +import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; import { extractIngestToken, verifyIngestToken } from './verify_ingress_auth'; diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts index b31bac12db4c9..d4ee2c8b0262f 100644 --- a/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts +++ b/x-pack/platform/plugins/shared/actions/server/inbound/verify_ingress_auth.ts @@ -7,7 +7,7 @@ import { timingSafeEqual } from 'node:crypto'; -import { computeIngestTokenHash } from '@kbn/connector-specs'; +import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; export const extractIngestToken = ({ query, diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/connector_types.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/connector_types.test.ts index 35f6a0b8c7293..705f4a32b8462 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/connector_types.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/connector_types.test.ts @@ -13,7 +13,7 @@ import { connectorTypes } from './mocks/connector_types'; import { actionsConfigMock } from '../actions_config.mock'; import { loggerMock } from '@kbn/logging-mocks'; import type { ActionTypeConfig, Services } from '../types'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; jest.mock('../action_type_registry', () => { const actual = jest.requireActual('../action_type_registry'); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/utils.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/utils.ts index a11b9a53a63e9..23890c2a0336d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/utils.ts @@ -6,7 +6,8 @@ */ import type { Connector } from '@kbn/actions-plugin/server'; -import { getConnectorSpec, isToolAction } from '@kbn/connector-specs'; +import { isToolAction } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { ConnectorItem, ConnectorSubAction, OAuthStatus } from '../../common/http_api/tools'; export const getTechnicalPreviewWarning = (featureName: string) => { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.test.ts index eeb740b80c443..912976d9ed82c 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.test.ts @@ -15,20 +15,24 @@ import type { ToolHandlerStandardReturn, } from '@kbn/agent-builder-server/tools/handler'; import { - getConnectorSpec, isToolAction, OAUTH_AUTHORIZATION_CODE_AUTH_ID, EARS_AUTH_ID, } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import { createExecuteConnectorSubActionTool, executeConnectorSubActionArgsSchema, } from './execute_connector_sub_action'; import type { ConnectorToolsOptions } from './types'; +jest.mock('@kbn/connector-specs/server', () => ({ + ...jest.requireActual('@kbn/connector-specs/server'), + getConnectorSpec: jest.fn(), +})); + jest.mock('@kbn/connector-specs', () => ({ ...jest.requireActual('@kbn/connector-specs'), - getConnectorSpec: jest.fn(), isToolAction: jest.fn(), })); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.ts index ce28ddeb47302..7046a2054a6a3 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/tools/builtin/connectors/execute_connector_sub_action.ts @@ -12,7 +12,8 @@ import { ToolResultType } from '@kbn/agent-builder-common/tools/tool_result'; import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids'; -import { getConnectorSpec, isToolAction } from '@kbn/connector-specs'; +import { isToolAction } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { ConnectorToolsOptions } from './types'; const connectorIdValidationMessage = diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.test.ts index 5e5962660254b..3692c7e12e92d 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.test.ts @@ -12,7 +12,7 @@ import { type ConnectorAttachmentData, } from '@kbn/agent-builder-common/attachments'; import type { AgentFormattedAttachment } from '@kbn/agent-builder-server/attachments'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import { formatSchemaForLlm } from '@kbn/agent-builder-server'; import { z } from '@kbn/zod/v4'; import { createConnectorAttachmentType } from './connector'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.ts index fecce23ad360f..663f377056a9d 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector.ts @@ -13,7 +13,7 @@ import { } from '@kbn/agent-builder-common/attachments'; import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; import { formatSchemaForLlm } from '@kbn/agent-builder-server'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; /** * Creates the definition for the `connector` attachment type. diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.test.ts index 496d2a011155e..4d2edfccb9c88 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.test.ts @@ -8,7 +8,7 @@ import { httpServerMock } from '@kbn/core-http-server-mocks'; import type { Attachment } from '@kbn/agent-builder-common/attachments'; import type { AgentFormattedAttachment } from '@kbn/agent-builder-server/attachments'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import { CONNECTOR_SETUP_ATTACHMENT_TYPE, type ConnectorSetupAttachmentData, diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.ts index 033c138aa06dd..28813d79bd501 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/attachment_types/connector_setup.ts @@ -6,7 +6,7 @@ */ import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import { CONNECTOR_SETUP_ATTACHMENT_TYPE, connectorSetupAttachmentDataSchema, diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/connector_lifecycle/connector_lifecycle_handler.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/connector_lifecycle/connector_lifecycle_handler.test.ts index 81a9a3041854a..42bcfdc41692a 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/connector_lifecycle/connector_lifecycle_handler.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/connector_lifecycle/connector_lifecycle_handler.test.ts @@ -11,7 +11,7 @@ import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID, CONTEXT_ENGINE_ENABLED_SETTING_ID, } from '@kbn/management-settings-ids'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import { createConnectorLifecycleHandler } from './connector_lifecycle_handler'; jest.mock('@kbn/connector-specs', () => ({ diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/connector_authoring.test.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/connector_authoring.test.ts index d6963a280bec8..9b5d8ef4857af 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/connector_authoring.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/connector_authoring.test.ts @@ -13,7 +13,7 @@ import type { import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; import { createAttachmentStateManager } from '@kbn/agent-builder-server/attachments'; import { AgentBuilderConnectorFeatureId } from '@kbn/actions-plugin/common'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { createListConnectorTypesTool } from './list_connector_types'; import { createProposeConnectorTool } from './propose_connector'; diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/list_connector_types.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/list_connector_types.ts index c4c185f718b2e..d9af507b064c2 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/list_connector_types.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/list_connector_types.ts @@ -12,7 +12,7 @@ import { getToolResultId, createErrorResult } from '@kbn/agent-builder-server'; import type { BuiltinSkillBoundedTool } from '@kbn/agent-builder-server/skills'; import { AgentBuilderConnectorFeatureId } from '@kbn/actions-plugin/common'; import type { ActionType } from '@kbn/actions-plugin/common'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; import { getConnectorTypeDisplayName, diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/utils.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/utils.ts index 0411347c72e87..d092abb1f4015 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/skills/connector_authoring/utils.ts @@ -6,7 +6,7 @@ */ import type { ActionType } from '@kbn/actions-plugin/common'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import { CONNECTOR_ID as MCP_CONNECTOR_TYPE_ID } from '@kbn/connector-schemas/mcp/constants'; /** diff --git a/x-pack/platform/plugins/shared/agent_builder_platform/server/sml_types/connector.ts b/x-pack/platform/plugins/shared/agent_builder_platform/server/sml_types/connector.ts index 1a8e31d4538fa..1b5ce51b1b108 100644 --- a/x-pack/platform/plugins/shared/agent_builder_platform/server/sml_types/connector.ts +++ b/x-pack/platform/plugins/shared/agent_builder_platform/server/sml_types/connector.ts @@ -12,7 +12,7 @@ import type { SmlTypeDefinition } from '@kbn/agent-context-layer-plugin/server'; import { kibanaSavedObjectPermissions } from '@kbn/agent-context-layer-plugin/server'; import type { ConnectorAttachmentData } from '@kbn/agent-builder-common/attachments'; import { AttachmentType } from '@kbn/agent-builder-common/attachments'; -import { getConnectorSpec } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; const CONNECTOR_SML_TYPE = 'connector'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_events/register_connector_event_triggers.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_events/register_connector_event_triggers.ts new file mode 100644 index 0000000000000..0571ea8d23801 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_events/register_connector_event_triggers.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { toRegisteredConnectorEvent } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; +import type { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public'; + +const DEFAULT_CONNECTOR_EVENT_ICON = React.lazy(() => + import('@elastic/eui/es/components/icon/assets/plugs').then(({ icon }) => ({ default: icon })) +); + +export function registerConnectorEventTriggers( + workflowsExtensions?: WorkflowsExtensionsPublicPluginSetup +): void { + if (!workflowsExtensions) { + return; + } + + for (const spec of Object.values(connectorsSpecs)) { + if (!spec.events) { + continue; + } + + for (const [eventKey, definition] of Object.entries(spec.events.definitions)) { + const event = toRegisteredConnectorEvent(spec.metadata, eventKey, definition); + workflowsExtensions.registerTriggerDefinition({ + id: event.eventId, + title: event.title, + description: event.description, + stability: event.stability ?? 'tech_preview', + requiresConnectorId: true, + eventSchema: event.eventSchema, + icon: DEFAULT_CONNECTOR_EVENT_ICON, + documentation: { + details: i18n.translate('stackConnectors.connectorEvents.triggerDocumentation', { + defaultMessage: + 'Subscribe with connector-id and optional KQL on event fields (e.g. event.body.eventType).', + }), + }, + snippets: { + condition: 'event.connectorId: "your-connector-id"', + }, + }); + } + } +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx index faaee08c91548..2ac707515d7c0 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx @@ -27,6 +27,30 @@ export const getInboundWebhookConnectorType = (): ActionTypeModel< defaultMessage: 'Inbound Webhook', }), validateParams: async (): Promise> => ({ errors: {} }), + connectorForm: { + serializer: ((formData: Record) => { + const secrets = formData?.secrets as Record | undefined; + if (!secrets?.authType) { + return formData; + } + + const config = formData?.config as Record | undefined; + return { + ...formData, + config: { ...config, authType: secrets.authType }, + }; + }) as unknown as NonNullable['serializer'], + deserializer: ((apiData: Record) => { + const config = apiData?.config as Record | undefined; + const secrets = apiData?.secrets as Record | undefined; + + if (!config?.authType || secrets?.authType) { + return apiData; + } + + return { ...apiData, secrets: { ...(secrets ?? {}), authType: config.authType } }; + }) as unknown as NonNullable['deserializer'], + }, actionParamsFields: lazy(async () => ({ default: () => null })), actionConnectorFields: lazy(() => import('./inbound_webhook_connector_fields').then(({ InboundWebhookConnectorFields }) => ({ diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx index 856d9c54d4e71..cc50d213d4c3a 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx @@ -48,6 +48,10 @@ export const InboundWebhookConnectorFields = ({ registerPreSubmitValidator(async () => undefined); }, [registerPreSubmitValidator]); + useEffect(() => { + setFieldValue('secrets.authType', 'none'); + }, [setFieldValue]); + useEffect(() => { if (isEdit || webhookUrl || !id) { return; @@ -76,6 +80,7 @@ export const InboundWebhookConnectorFields = ({ return ( <> + {!isEdit ? ( <> { + registerConnectorEventTriggers(workflowsExtensions); + } + ); } } diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts index 0a6368ce6415f..bc6bdc9b67e91 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts @@ -6,7 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import { connectorsSpecs, toRegisteredConnectorEvent } from '@kbn/connector-specs'; +import { toRegisteredConnectorEvent } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; export function registerConnectorEventTriggers( @@ -28,6 +29,7 @@ export function registerConnectorEventTriggers( title: event.title, description: event.description, stability: event.stability ?? 'tech_preview', + requiresConnectorId: true, eventSchema: event.eventSchema, documentation: { details: i18n.translate('stackConnectors.connectorEvents.triggerDocumentation', { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts index 5d13ef819c72c..0550b0ef1f120 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts @@ -7,9 +7,9 @@ import { type PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; -import { connectorsSpecs } from '@kbn/connector-specs'; -import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; +import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; import { registerInboundWebhookConnectorType } from './register_inbound_webhook_connector_type'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts index 044885f2abcb2..2e788e5b74f2e 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts @@ -9,11 +9,9 @@ import { randomBytes } from 'node:crypto'; import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; -import { - InboundWebhookConnector, - computeIngestTokenHash, - INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, -} from '@kbn/connector-specs'; +import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; +import { InboundWebhookConnector } from '@kbn/connector-specs/src/specs/inbound_webhook/inbound_webhook'; +import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; import type { KibanaRequest } from '@kbn/core/server'; export interface RegisterInboundWebhookConnectorTypeParams { @@ -31,30 +29,35 @@ export function registerInboundWebhookConnectorType({ actions.registerType({ ...connectorType, - preSaveHook: async ({ connectorId, config, secrets, request, isUpdate }) => { + preSaveHook: async ({ connectorId, config, request, isUpdate }) => { const spaceId = getSpaceId(request); const configRecord = config as Record; - const secretsRecord = secrets as Record; - let token = - typeof secretsRecord.ingestToken === 'string' && secretsRecord.ingestToken.length > 0 - ? secretsRecord.ingestToken - : undefined; + const existingWebhookUrl = + typeof configRecord.webhookUrl === 'string' ? configRecord.webhookUrl : undefined; + let token: string | undefined; + if (existingWebhookUrl) { + try { + token = new URL(existingWebhookUrl).searchParams.get('token') ?? undefined; + } catch { + token = undefined; + } + } if (!token && !isUpdate && typeof configRecord.ingestTokenHash !== 'string') { token = randomBytes(32).toString('hex'); } - if (token) { - configRecord.ingestTokenHash = computeIngestTokenHash({ - connectorId, - spaceId, - token, - }); - secretsRecord.webhookUrl = `${getPublicBaseUrl()}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; - configRecord.webhookUrl = secretsRecord.webhookUrl; - delete secretsRecord.ingestToken; + if (!token) { + return; } + + configRecord.ingestTokenHash = computeIngestTokenHash({ + connectorId, + spaceId, + token, + }); + configRecord.webhookUrl = `${getPublicBaseUrl()}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; }, }); } diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.test.ts index e4aaaeef494d7..51b53a444cd97 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.test.ts @@ -18,7 +18,7 @@ import type { ConnectorsPluginsStart } from './plugin'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { experimentalFeaturesMock } from '../public/mocks'; import { parseExperimentalConfigValue } from '../common/experimental_features'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; jest.mock('../common/experimental_features'); diff --git a/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json b/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json index 38fed988c0d54..dba03072ec9ff 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json +++ b/x-pack/platform/plugins/shared/stack_connectors/tsconfig.json @@ -52,7 +52,7 @@ "@kbn/core-http-server-mocks", "@kbn/usage-collection-plugin", "@kbn/react-query", - "@kbn/zod", + "@kbn/workflows-extensions", "@kbn/connector-schemas", "@kbn/connector-specs", "@kbn/actions-utils", diff --git a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/task_cost_check.test.ts b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/task_cost_check.test.ts index e63142b628c4b..6a91af1b32a55 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/task_cost_check.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/task_cost_check.test.ts @@ -14,7 +14,7 @@ import { TaskCost } from '../task'; import { setupTestServers } from './lib'; import type { TaskTypeDictionary } from '../task_type_dictionary'; import { sortBy } from 'lodash'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; jest.mock('../task_type_dictionary', () => { const actual = jest.requireActual('../task_type_dictionary'); diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index a67a983eb4938..86047c0b71c60 100644 --- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import type { FtrProviderContext } from '../../../common/ftr_provider_context'; const connectorIdsFromSpecs = new Set( diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 057b9f3cdc1f9..c00be7e4f8c97 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import type { Response as SupertestResponse } from 'supertest'; -import { connectorsSpecs } from '@kbn/connector-specs'; +import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import type { FtrProviderContext } from '../../ftr_provider_context'; const actionTypeIdsFromSpecs = new Set( From eeaa7dde5a4618566fc9f9bd62f3d1fadf652b3f Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Wed, 15 Jul 2026 23:08:13 +0200 Subject: [PATCH 14/19] fixing problems --- .../shared/kbn-connector-specs/index.ts | 7 + .../delegated_execution_credentials.ts | 40 +++++ .../specs/inbound_webhook/inbound_webhook.ts | 24 +++ .../server/inbound/handle_inbound_request.ts | 13 +- .../server/inbound/register_inbound_routes.ts | 9 +- .../plugins/shared/actions/server/types.ts | 5 + ..._workflows_connector_event_emitter.test.ts | 27 ++++ ...ister_workflows_connector_event_emitter.ts | 4 +- .../inbound_webhook_api_key_service.ts | 139 ++++++++++++++++++ .../server/connector_types_from_spec/index.ts | 15 +- ...register_inbound_webhook_connector_type.ts | 85 +++++++++-- .../shared/stack_connectors/server/plugin.ts | 15 +- 12 files changed, 366 insertions(+), 17 deletions(-) create mode 100644 src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/delegated_execution_credentials.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts index 80650b6dffdcb..a18bca6c2a5fa 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -35,6 +35,13 @@ export { INBOUND_WEBHOOK_RECEIVED_EVENT_ID, INBOUND_WEBHOOK_RECEIVED_EVENT_KEY, } from './src/inbound_webhook_constants'; +export { + getInboundWebhookAuthorizationHeader, + INBOUND_WEBHOOK_DELEGATED_API_KEY_ID_CONFIG, + INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET, + INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG, + INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET, +} from './src/inbound_webhook/delegated_execution_credentials'; export { isToolAction, TEST_CONNECTOR_SUB_ACTION } from './src/connector_spec'; export { getConnectorActionErrorMeta, diff --git a/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/delegated_execution_credentials.ts b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/delegated_execution_credentials.ts new file mode 100644 index 0000000000000..6fc5dff670c51 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/delegated_execution_credentials.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Secret/config field names for delegated Task Manager execution credentials + * granted when an inbound webhook connector is created (see feature/inbound-webhook). + */ +export const INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET = 'delegatedApiKey' as const; +export const INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET = 'delegatedUiamApiKey' as const; +export const INBOUND_WEBHOOK_DELEGATED_API_KEY_ID_CONFIG = 'delegatedApiKeyId' as const; +export const INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG = 'delegatedUiamApiKeyId' as const; + +/** + * Builds an `ApiKey …` Authorization header from inbound-webhook connector secrets. + * Returns undefined when delegated credentials have not been granted yet. + */ +export function getInboundWebhookAuthorizationHeader( + secrets: Record +): string | undefined { + const uiamApiKey = secrets[INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET]; + if (typeof uiamApiKey === 'string' && uiamApiKey.length > 0) { + const parts = Buffer.from(uiamApiKey, 'base64').toString().split(':'); + const value = parts[1]; + if (value) { + return `ApiKey ${value}`; + } + } + + const apiKey = secrets[INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET]; + if (typeof apiKey === 'string' && apiKey.length > 0) { + return `ApiKey ${apiKey}`; + } + return undefined; +} diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts index fba9b5f084800..c58db299791dc 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts @@ -60,6 +60,30 @@ export const InboundWebhookConnector: ConnectorSpec = { defaultMessage: 'Webhook URL', }), }), + delegatedApiKeyId: z + .string() + .optional() + .meta({ + hidden: true, + label: i18n.translate( + 'core.kibanaConnectorSpecs.inboundWebhook.config.delegatedApiKeyId', + { + defaultMessage: 'Delegated API key id', + } + ), + }), + delegatedUiamApiKeyId: z + .string() + .optional() + .meta({ + hidden: true, + label: i18n.translate( + 'core.kibanaConnectorSpecs.inboundWebhook.config.delegatedUiamApiKeyId', + { + defaultMessage: 'Delegated UIAM API key id', + } + ), + }), }) ), diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts b/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts index de249d2ba80a7..12b17df6aa633 100644 --- a/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts +++ b/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.ts @@ -7,7 +7,10 @@ import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import { normalizeConnectorTypeId } from '@kbn/connector-specs'; +import { + getInboundWebhookAuthorizationHeader, + normalizeConnectorTypeId, +} from '@kbn/connector-specs'; import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { ActionsConfig } from '../config'; @@ -121,6 +124,13 @@ export async function handleInboundRequest({ }); } + const authorizationHeader = getInboundWebhookAuthorizationHeader(connector.secrets); + if (!authorizationHeader) { + logger.warn( + `Inbound connector ${connectorId} is missing delegated execution credentials; workflow scheduling may fail` + ); + } + for (const event of result.events ?? []) { await emitConnectorEvents({ eventId: event.eventId, @@ -133,6 +143,7 @@ export async function handleInboundRequest({ spaceId, connectorId, connectorTypeId, + ...(authorizationHeader ? { authorizationHeader } : {}), }); } diff --git a/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts b/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts index bf7b7188990bc..045dd31600d3b 100644 --- a/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts +++ b/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import { asSpaceId } from '@kbn/core-spaces-common'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { CoreSetup, @@ -18,6 +19,7 @@ import type { } from '@kbn/core/server'; import type { ActionsConfig } from '../config'; +import { ACTION_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; import type { ActionsRequestHandlerContext, ActionTypeRegistry, @@ -99,9 +101,12 @@ export function registerInboundRoutes({ headers: {}, spaceId: asSpaceId(spaceId), }); - const unsecuredSavedObjectsClient = coreStart.savedObjects.getScopedClient(internalRequest); + const unsecuredSavedObjectsClient = coreStart.savedObjects.getScopedClient(internalRequest, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [ACTION_SAVED_OBJECT_TYPE], + }); const encryptedSavedObjectsClient = startPlugins.encryptedSavedObjects.getClient({ - includedHiddenTypes: ['action'], + includedHiddenTypes: [ACTION_SAVED_OBJECT_TYPE], }); return handleInboundRequest({ diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index 6c117bf1eaf5b..854df89ab745e 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -220,6 +220,11 @@ export interface ConnectorEventEmitParams { spaceId: string; connectorId: string; connectorTypeId: string; + /** + * Optional `ApiKey …` Authorization header from delegated connector credentials. + * Required for Task Manager to schedule workflows from unauthenticated ingress. + */ + authorizationHeader?: string; } /** diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts index c613edf74bd69..10662700840da 100644 --- a/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts +++ b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts @@ -31,12 +31,39 @@ describe('registerWorkflowsConnectorEventEmitter', () => { spaceId: 'default', connectorId: 'c1', connectorTypeId: '.inboundWebhook', + authorizationHeader: 'ApiKey dGVzdDp0ZXN0', }); expect(getClient).toHaveBeenCalled(); + const requestArg = getClient.mock.calls[0][0]; + expect(requestArg.headers.authorization).toBe('ApiKey dGVzdDp0ZXN0'); expect(emitEvent).toHaveBeenCalledWith('inboundWebhook.received', { connectorId: 'c1', body: {}, }); }); + + it('emits with empty auth headers when authorizationHeader is omitted', async () => { + const actions = actionsMock.createSetup(); + const emitEvent = jest.fn().mockResolvedValue(undefined); + const getClient = jest.fn().mockResolvedValue({ emitEvent }); + + registerWorkflowsConnectorEventEmitter({ + actions, + getWorkflowsExtensionsStart: async () => ({ getClient } as never), + logger: loggingSystemMock.create().get(), + }); + + const emitter = actions.registerConnectorEventEmitter.mock.calls[0][0]; + await emitter.emit({ + eventId: 'inboundWebhook.received', + payload: { connectorId: 'c1', body: {} }, + spaceId: 'default', + connectorId: 'c1', + connectorTypeId: '.inboundWebhook', + }); + + const requestArg = getClient.mock.calls[0][0]; + expect(requestArg.headers.authorization).toBeUndefined(); + }); }); diff --git a/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts index 0acbf4feee5cf..de5ab0795fa93 100644 --- a/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts +++ b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.ts @@ -31,7 +31,7 @@ export function registerWorkflowsConnectorEventEmitter({ logger: Logger; }): void { const emitter: ConnectorEventEmitter = { - emit: async ({ eventId, payload, spaceId }) => { + emit: async ({ eventId, payload, spaceId, authorizationHeader }) => { const workflowsExtensions = await getWorkflowsExtensionsStart(); if (!workflowsExtensions) { logger.warn( @@ -41,7 +41,7 @@ export function registerWorkflowsConnectorEventEmitter({ } const request = kibanaRequestFactory({ - headers: {}, + headers: authorizationHeader ? { authorization: authorizationHeader } : {}, spaceId: asSpaceId(spaceId), }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts new file mode 100644 index 0000000000000..5dbb7af91080b --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getInboundWebhookAuthorizationHeader, + INBOUND_WEBHOOK_DELEGATED_API_KEY_ID_CONFIG, + INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET, + INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG, + INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET, +} from '@kbn/connector-specs'; +import type { KibanaRequest, Logger, SecurityServiceStart } from '@kbn/core/server'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; +import { HTTPAuthorizationHeader, isUiamCredential } from '@kbn/core-security-server'; + +export { + INBOUND_WEBHOOK_DELEGATED_API_KEY_ID_CONFIG as DELEGATED_API_KEY_ID_CONFIG, + INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET as DELEGATED_API_KEY_SECRET, + INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG as DELEGATED_UIAM_API_KEY_ID_CONFIG, + INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET as DELEGATED_UIAM_API_KEY_SECRET, + getInboundWebhookAuthorizationHeader as tryGetAuthorizationHeaderFromSecrets, +}; + +export interface InboundWebhookDelegatedCredentials { + [INBOUND_WEBHOOK_DELEGATED_API_KEY_ID_CONFIG]: string; + [INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG]?: string; + secrets: { + [INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET]: string; + [INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET]?: string; + }; +} + +/** + * Mirrors `feature/inbound-webhook` InboundWebhookApiKeyService: grant/clone a + * user-scoped API key at connector create so unauthenticated ingress can schedule + * Task Manager runs with a clonable Authorization header. + */ +export class InboundWebhookApiKeyService { + constructor(private readonly security: SecurityServiceStart, private readonly logger: Logger) {} + + public async grant( + request: KibanaRequest, + connectorId: string + ): Promise { + const apiKeys = this.security.authc.apiKeys; + if (!(await apiKeys.areAPIKeysEnabled())) { + throw new Error('API keys are disabled'); + } + + const user = this.security.authc.getCurrentUser(request); + if (!user) { + throw new Error('Cannot create an inbound webhook without an authenticated user'); + } + + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + const isUiamRequest = authorizationHeader ? isUiamCredential(authorizationHeader) : false; + const keyName = `Inbound webhook: ${connectorId}`; + + const esResult = + user.authentication_type === 'api_key' && !isUiamRequest + ? await apiKeys.cloneAsInternalUser(request, { name: keyName }) + : await apiKeys.grantAsInternalUser(request, { + name: keyName, + role_descriptors: {}, + }); + if (!esResult) { + throw new Error('Failed to create an API key for the inbound webhook'); + } + + const encodedApiKey = + 'encoded' in esResult && typeof esResult.encoded === 'string' + ? esResult.encoded + : Buffer.from(`${esResult.id}:${esResult.api_key}`).toString('base64'); + + let uiamResult: { id: string; api_key: string } | null = null; + if (apiKeys.uiam && isUiamRequest) { + uiamResult = await apiKeys.uiam.grant(request, { name: keyName }); + } + + return { + [INBOUND_WEBHOOK_DELEGATED_API_KEY_ID_CONFIG]: esResult.id, + ...(uiamResult ? { [INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG]: uiamResult.id } : {}), + secrets: { + [INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET]: encodedApiKey, + ...(uiamResult + ? { + [INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET]: Buffer.from( + `${uiamResult.id}:${uiamResult.api_key}` + ).toString('base64'), + } + : {}), + }, + }; + } + + public getAuthorizationHeader(secrets: Record): string { + const header = getInboundWebhookAuthorizationHeader(secrets); + if (!header) { + throw new Error('Inbound webhook is missing delegated execution credentials'); + } + return header; + } + + public async invalidate(params: { + apiKeyId?: string; + uiamApiKeyId?: string; + uiamApiKey?: string; + }): Promise { + if (params.apiKeyId) { + try { + await this.security.authc.apiKeys.invalidateAsInternalUser({ ids: [params.apiKeyId] }); + } catch (error) { + this.logger.warn(`Failed to invalidate inbound webhook API key ${params.apiKeyId}`, { + error, + }); + } + } + + if (!params.uiamApiKeyId || !params.uiamApiKey || !this.security.authc.apiKeys.uiam) { + return; + } + try { + const [, value] = Buffer.from(params.uiamApiKey, 'base64').toString().split(':'); + const request = kibanaRequestFactory({ + headers: { authorization: `ApiKey ${value}` }, + }); + await this.security.authc.apiKeys.uiam.invalidate(request, { + id: params.uiamApiKeyId, + }); + } catch (error) { + this.logger.warn(`Failed to invalidate inbound webhook UIAM API key ${params.uiamApiKeyId}`, { + error, + }); + } + } +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts index 0550b0ef1f120..2c73496a2a7b3 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts @@ -10,6 +10,7 @@ import { type PluginSetupContract as ActionsPluginSetupContract } from '@kbn/act import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; import * as connectorsSpecs from '@kbn/connector-specs/src/all_specs'; import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; +import type { KibanaRequest, Logger, SecurityServiceStart } from '@kbn/core/server'; import { registerInboundWebhookConnectorType } from './register_inbound_webhook_connector_type'; @@ -17,12 +18,22 @@ export function registerConnectorTypesFromSpecs({ actions, getSpaceId, getPublicBaseUrl, + getSecurity, + logger, }: { actions: ActionsPluginSetupContract; - getSpaceId: (request: import('@kbn/core/server').KibanaRequest) => string; + getSpaceId: (request: KibanaRequest) => string; getPublicBaseUrl: () => string; + getSecurity: () => Promise; + logger: Logger; }) { - registerInboundWebhookConnectorType({ actions, getSpaceId, getPublicBaseUrl }); + registerInboundWebhookConnectorType({ + actions, + getSpaceId, + getPublicBaseUrl, + getSecurity, + logger, + }); for (const spec of Object.values(connectorsSpecs)) { if (spec.metadata.id === INBOUND_WEBHOOK_CONNECTOR_TYPE_ID) { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts index 2e788e5b74f2e..bc87487d8d618 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts @@ -12,26 +12,55 @@ import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; import { InboundWebhookConnector } from '@kbn/connector-specs/src/specs/inbound_webhook/inbound_webhook'; import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; -import type { KibanaRequest } from '@kbn/core/server'; +import type { KibanaRequest, Logger, SecurityServiceStart } from '@kbn/core/server'; +import { z } from '@kbn/zod/v4'; + +import { + DELEGATED_API_KEY_ID_CONFIG, + DELEGATED_API_KEY_SECRET, + DELEGATED_UIAM_API_KEY_ID_CONFIG, + DELEGATED_UIAM_API_KEY_SECRET, + InboundWebhookApiKeyService, +} from './inbound_webhook_api_key_service'; export interface RegisterInboundWebhookConnectorTypeParams { actions: ActionsPluginSetupContract; getSpaceId: (request: KibanaRequest) => string; getPublicBaseUrl: () => string; + getSecurity: () => Promise; + logger: Logger; } +const inboundWebhookSecretsSchema = z.object({ + authType: z.literal('none').optional(), + [DELEGATED_API_KEY_SECRET]: z.string().optional(), + [DELEGATED_UIAM_API_KEY_SECRET]: z.string().optional(), +}); + export function registerInboundWebhookConnectorType({ actions, getSpaceId, getPublicBaseUrl, + getSecurity, + logger, }: RegisterInboundWebhookConnectorTypeParams): void { const connectorType = createConnectorTypeFromSpec(InboundWebhookConnector, actions); + const apiKeyServicePromise = getSecurity().then( + (security) => new InboundWebhookApiKeyService(security, logger) + ); actions.registerType({ ...connectorType, - preSaveHook: async ({ connectorId, config, request, isUpdate }) => { + validate: { + ...connectorType.validate, + secrets: { + schema: inboundWebhookSecretsSchema, + }, + }, + preSaveHook: async ({ connectorId, config, secrets, request, isUpdate }) => { const spaceId = getSpaceId(request); const configRecord = config as Record; + const secretsRecord = secrets as Record; const existingWebhookUrl = typeof configRecord.webhookUrl === 'string' ? configRecord.webhookUrl : undefined; @@ -48,16 +77,54 @@ export function registerInboundWebhookConnectorType({ token = randomBytes(32).toString('hex'); } - if (!token) { + if (token) { + configRecord.ingestTokenHash = computeIngestTokenHash({ + connectorId, + spaceId, + token, + }); + configRecord.webhookUrl = `${getPublicBaseUrl()}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; + } + + const hasDelegatedKey = + typeof secretsRecord[DELEGATED_API_KEY_SECRET] === 'string' && + secretsRecord[DELEGATED_API_KEY_SECRET].length > 0; + + if (hasDelegatedKey) { return; } - configRecord.ingestTokenHash = computeIngestTokenHash({ - connectorId, - spaceId, - token, - }); - configRecord.webhookUrl = `${getPublicBaseUrl()}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; + const apiKeyService = await apiKeyServicePromise; + const credentials = await apiKeyService.grant(request, connectorId); + + configRecord[DELEGATED_API_KEY_ID_CONFIG] = credentials[DELEGATED_API_KEY_ID_CONFIG]; + if (credentials[DELEGATED_UIAM_API_KEY_ID_CONFIG]) { + configRecord[DELEGATED_UIAM_API_KEY_ID_CONFIG] = + credentials[DELEGATED_UIAM_API_KEY_ID_CONFIG]; + } + + secretsRecord[DELEGATED_API_KEY_SECRET] = credentials.secrets[DELEGATED_API_KEY_SECRET]; + if (credentials.secrets[DELEGATED_UIAM_API_KEY_SECRET]) { + secretsRecord[DELEGATED_UIAM_API_KEY_SECRET] = + credentials.secrets[DELEGATED_UIAM_API_KEY_SECRET]; + } + }, + postDeleteHook: async ({ connectorId, config }) => { + const configRecord = config as Record; + const apiKeyId = + typeof configRecord[DELEGATED_API_KEY_ID_CONFIG] === 'string' + ? configRecord[DELEGATED_API_KEY_ID_CONFIG] + : undefined; + const uiamApiKeyId = + typeof configRecord[DELEGATED_UIAM_API_KEY_ID_CONFIG] === 'string' + ? configRecord[DELEGATED_UIAM_API_KEY_ID_CONFIG] + : undefined; + if (!apiKeyId && !uiamApiKeyId) { + return; + } + const apiKeyService = await apiKeyServicePromise; + await apiKeyService.invalidate({ apiKeyId, uiamApiKeyId }); + logger.debug(`Invalidated inbound webhook credentials for connector ${connectorId}`); }, }); } diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 4e111abe83270..69ff838e09dc2 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts @@ -5,7 +5,13 @@ * 2.0. */ -import type { PluginInitializerContext, Plugin, CoreSetup, CoreStart } from '@kbn/core/server'; +import type { + PluginInitializerContext, + Plugin, + CoreSetup, + CoreStart, + Logger, +} from '@kbn/core/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; @@ -48,6 +54,7 @@ export class StackConnectorsPlugin implements Plugin { private config: StackConnectorsConfigType; + private readonly logger: Logger; readonly experimentalFeatures: ExperimentalFeatures; // Whether this is a Serverless deployment, and — if so — whether its organization is in trial. @@ -59,6 +66,7 @@ export class StackConnectorsPlugin constructor(context: PluginInitializerContext) { this.config = context.config.get(); + this.logger = context.logger.get(); this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []); } @@ -101,6 +109,11 @@ export class StackConnectorsPlugin actions, getSpaceId: (request) => plugins.spaces?.spacesService.getSpaceId(request) ?? 'default', getPublicBaseUrl: () => core.http.basePath.publicBaseUrl ?? '', + getSecurity: async () => { + const [coreStart] = await core.getStartServices(); + return coreStart.security; + }, + logger: this.logger, }); registerConnectorEventTriggers(plugins.workflowsExtensions); } From 6427f28e140ab97cf7bb73afb1bf4e17e922ce0c Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Wed, 15 Jul 2026 23:34:28 +0200 Subject: [PATCH 15/19] minting token --- .../plugins/shared/actions/common/index.ts | 1 + .../connector_types/inbound_webhook/api.ts | 21 ++ .../inbound_webhook_connector_fields.tsx | 176 ++++++++++------ ...nbound_webhook_ingress_credentials.test.ts | 192 ++++++++++++++++++ ...ure_inbound_webhook_ingress_credentials.ts | 147 ++++++++++++++ ...register_inbound_webhook_connector_type.ts | 38 +--- .../shared/stack_connectors/server/plugin.ts | 17 +- .../stack_connectors/server/routes/index.ts | 1 + .../routes/rotate_inbound_webhook_url.test.ts | 103 ++++++++++ .../routes/rotate_inbound_webhook_url.ts | 90 ++++++++ 10 files changed, 688 insertions(+), 98 deletions(-) create mode 100644 x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.test.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.test.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.ts diff --git a/x-pack/platform/plugins/shared/actions/common/index.ts b/x-pack/platform/plugins/shared/actions/common/index.ts index 1832287a1a236..8fb2995b34195 100644 --- a/x-pack/platform/plugins/shared/actions/common/index.ts +++ b/x-pack/platform/plugins/shared/actions/common/index.ts @@ -89,6 +89,7 @@ export const DEFAULT_MICROSOFT_GRAPH_API_SCOPE = 'https://graph.microsoft.com/.d export const MAX_EMAIL_BODY_LENGTH = 25 * 1000 * 1000; // 25MB export const CONNECTOR_ID_MAX_LENGTH = 36; +export { validateConnectorId } from './validate_connector_id'; export const ISO_DATE_MAX_LENGTH = 100; export const MAX_EXECUTION_FILTER_LENGTH = 8192; export const MAX_FEATURE_ID_LENGTH = 100; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts new file mode 100644 index 0000000000000..0864a0ea561f4 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup } from '@kbn/core/public'; +import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../../common'; + +export async function rotateInboundWebhookUrl({ + http, + connectorId, +}: { + http: HttpSetup; + connectorId: string; +}): Promise<{ webhookUrl: string; ingestTokenHash: string }> { + return http.post(`${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/inbound_webhook/_rotate_url`, { + body: JSON.stringify({ connectorId }), + }); +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx index cc50d213d4c3a..2a97e13d3417e 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx @@ -5,9 +5,17 @@ * 2.0. */ -import { EuiButton, EuiCopy, EuiSpacer } from '@elastic/eui'; -import React, { useEffect } from 'react'; -import { HiddenField, TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { + EuiButton, + EuiCallOut, + EuiCopy, + EuiFieldText, + EuiFormRow, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import React, { useEffect, useRef, useState } from 'react'; +import { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { UseField, useFormContext, @@ -18,95 +26,107 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { ActionConnectorFieldsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -const computeIngestTokenHash = async ({ - connectorId, - spaceId, - token, -}: { - connectorId: string; - spaceId: string; - token: string; -}): Promise => { - const bytes = new TextEncoder().encode(`${connectorId}|${spaceId}|${token}`); - const digest = await window.crypto.subtle.digest('SHA-256', bytes); - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join(''); -}; +import { rotateInboundWebhookUrl } from './api'; + +const CONNECTOR_ID_SETTLE_MS = 400; +const CONNECTOR_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -export const InboundWebhookConnectorFields = ({ - isEdit, - readOnly, - registerPreSubmitValidator, -}: ActionConnectorFieldsProps) => { +export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnectorFieldsProps) => { const { http } = useKibana().services; const { setFieldValue } = useFormContext(); const [{ id, config }] = useFormData({ watch: ['id', 'config.webhookUrl'] }); - const webhookUrl = config?.webhookUrl as string | undefined; - - useEffect(() => { - registerPreSubmitValidator(async () => undefined); - }, [registerPreSubmitValidator]); + const webhookUrl = typeof config?.webhookUrl === 'string' ? config.webhookUrl : undefined; + const [isRotating, setIsRotating] = useState(false); + const [rotateError, setRotateError] = useState(); + const requestIdRef = useRef(0); useEffect(() => { setFieldValue('secrets.authType', 'none'); }, [setFieldValue]); useEffect(() => { - if (isEdit || webhookUrl || !id) { + if (isEdit || readOnly) { return; } - const createWebhook = async () => { - const token = - window.crypto.randomUUID().replaceAll('-', '') + - window.crypto.randomUUID().replaceAll('-', ''); - const path = http.basePath.prepend(`/api/events/v1/inboundWebhook/${id}?token=${token}`); - const spaceId = http.basePath.serverBasePath.includes('/s/') - ? http.basePath.serverBasePath.split('/s/')[1]?.split('/')[0] ?? 'default' - : 'default'; - setFieldValue('config.webhookUrl', `${window.location.origin}${path}`); - setFieldValue( - 'config.ingestTokenHash', - await computeIngestTokenHash({ - connectorId: String(id), - spaceId, - token, + + const connectorId = typeof id === 'string' ? id.trim() : ''; + if (!connectorId || !CONNECTOR_ID_PATTERN.test(connectorId)) { + return; + } + + if (webhookUrl?.includes(`/inboundWebhook/${connectorId}?`)) { + return; + } + + if (webhookUrl) { + setFieldValue('config.webhookUrl', undefined); + setFieldValue('config.ingestTokenHash', undefined); + } + + const timer = window.setTimeout(() => { + const requestId = ++requestIdRef.current; + setIsRotating(true); + setRotateError(undefined); + + void rotateInboundWebhookUrl({ http, connectorId }) + .then((credentials) => { + if (requestId !== requestIdRef.current) { + return; + } + setFieldValue('config.webhookUrl', credentials.webhookUrl); + setFieldValue('config.ingestTokenHash', credentials.ingestTokenHash); }) - ); + .catch(() => { + if (requestId !== requestIdRef.current) { + return; + } + setRotateError( + i18n.translate('stackConnectors.inboundWebhook.rotateUrlError', { + defaultMessage: + 'Unable to generate the webhook URL. Check the connector ID and try again.', + }) + ); + }) + .finally(() => { + if (requestId === requestIdRef.current) { + setIsRotating(false); + } + }); + }, CONNECTOR_ID_SETTLE_MS); + + return () => { + window.clearTimeout(timer); }; - void createWebhook(); - }, [http.basePath, id, isEdit, setFieldValue, webhookUrl]); + }, [http, id, isEdit, readOnly, setFieldValue, webhookUrl]); return ( <> + - {!isEdit ? ( + {webhookUrl ? ( <> - + + + - + {(copy) => ( - ) : null} + ) : ( + + {isRotating ? ( + + ) : ( + + )} + + )} ); }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.test.ts new file mode 100644 index 0000000000000..85ac7190b6e96 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.test.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; + +import { + buildInboundWebhookUrl, + ensureInboundWebhookIngressCredentials, + mintInboundWebhookIngressCredentials, +} from './ensure_inbound_webhook_ingress_credentials'; + +describe('buildInboundWebhookUrl', () => { + it('omits space prefix for the default space', () => { + expect( + buildInboundWebhookUrl({ + publicBaseUrl: 'https://kibana.example.com', + spaceId: 'default', + connectorId: 'conn-1', + token: 'abc', + }) + ).toBe('https://kibana.example.com/api/events/v1/inboundWebhook/conn-1?token=abc'); + }); + + it('includes space prefix for non-default spaces and strips trailing slash', () => { + expect( + buildInboundWebhookUrl({ + publicBaseUrl: 'https://kibana.example.com/kb/', + spaceId: 'security', + connectorId: 'conn-1', + token: 'abc', + }) + ).toBe( + 'https://kibana.example.com/kb/s/security/api/events/v1/inboundWebhook/conn-1?token=abc' + ); + }); +}); + +describe('ensureInboundWebhookIngressCredentials', () => { + const logger = loggingSystemMock.createLogger(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps verified credentials from rotate-URL on create', () => { + const minted = mintInboundWebhookIngressCredentials({ + connectorId: 'conn-1', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + }); + const config: Record = { ...minted }; + + ensureInboundWebhookIngressCredentials({ + config, + connectorId: 'conn-1', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + isUpdate: false, + logger, + }); + + expect(config).toEqual(minted); + }); + + it('canonicalizes host for verified rotate credentials on create', () => { + const token = 'a'.repeat(64); + const config: Record = { + webhookUrl: `https://evil.example/api/events/v1/inboundWebhook/conn-1?token=${token}`, + ingestTokenHash: computeIngestTokenHash({ + connectorId: 'conn-1', + spaceId: 'default', + token, + }), + }; + + ensureInboundWebhookIngressCredentials({ + config, + connectorId: 'conn-1', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + isUpdate: false, + logger, + }); + + expect(config.webhookUrl).toBe( + `https://kibana.example.com/api/events/v1/inboundWebhook/conn-1?token=${token}` + ); + expect(config.ingestTokenHash).toBe( + computeIngestTokenHash({ + connectorId: 'conn-1', + spaceId: 'default', + token, + }) + ); + }); + + it('remints on create when client-supplied credentials do not verify', () => { + const config: Record = { + webhookUrl: 'https://evil.example/api/events/v1/inboundWebhook/conn-1?token=client-token', + ingestTokenHash: 'a'.repeat(64), + }; + + ensureInboundWebhookIngressCredentials({ + config, + connectorId: 'conn-1', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + isUpdate: false, + logger, + }); + + expect(config.webhookUrl).toMatch( + /^https:\/\/kibana\.example\.com\/api\/events\/v1\/inboundWebhook\/conn-1\?token=[a-f0-9]{64}$/ + ); + expect(config.webhookUrl).not.toContain('client-token'); + const token = new URL(String(config.webhookUrl)).searchParams.get('token')!; + expect(config.ingestTokenHash).toBe( + computeIngestTokenHash({ + connectorId: 'conn-1', + spaceId: 'default', + token, + }) + ); + }); + + it('remints on create when connector id no longer matches rotated URL', () => { + const minted = mintInboundWebhookIngressCredentials({ + connectorId: 'old-id', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + }); + const config: Record = { ...minted }; + + ensureInboundWebhookIngressCredentials({ + config, + connectorId: 'new-id', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + isUpdate: false, + logger, + }); + + expect(String(config.webhookUrl)).toContain('/inboundWebhook/new-id?'); + expect(config.webhookUrl).not.toBe(minted.webhookUrl); + }); + + it('preserves existing credentials on update', () => { + const config: Record = { + webhookUrl: 'https://kibana.example.com/api/events/v1/inboundWebhook/conn-1?token=keep-me', + ingestTokenHash: computeIngestTokenHash({ + connectorId: 'conn-1', + spaceId: 'default', + token: 'keep-me', + }), + }; + const original = { ...config }; + + ensureInboundWebhookIngressCredentials({ + config, + connectorId: 'conn-1', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + isUpdate: true, + logger, + }); + + expect(config).toEqual(original); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('remints on update when credentials are missing', () => { + const config: Record = {}; + + ensureInboundWebhookIngressCredentials({ + config, + connectorId: 'conn-1', + spaceId: 'team-a', + publicBaseUrl: 'https://kibana.example.com', + isUpdate: true, + logger, + }); + + expect(config.webhookUrl).toContain('/s/team-a/api/events/v1/inboundWebhook/conn-1?token='); + expect(typeof config.ingestTokenHash).toBe('string'); + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.ts new file mode 100644 index 0000000000000..6ccb85c56ec0c --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { randomBytes } from 'node:crypto'; + +import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; +import type { Logger } from '@kbn/logging'; + +export interface InboundWebhookIngressCredentials { + readonly ingestTokenHash: string; + readonly webhookUrl: string; +} + +export const buildInboundWebhookUrl = ({ + publicBaseUrl, + spaceId, + connectorId, + token, +}: { + publicBaseUrl: string; + spaceId: string; + connectorId: string; + token: string; +}): string => { + const base = publicBaseUrl.replace(/\/$/, ''); + const spacePrefix = spaceId !== 'default' ? `/s/${encodeURIComponent(spaceId)}` : ''; + return `${base}${spacePrefix}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; +}; + +export const mintInboundWebhookIngressCredentials = ({ + connectorId, + spaceId, + publicBaseUrl, +}: { + connectorId: string; + spaceId: string; + publicBaseUrl: string; +}): InboundWebhookIngressCredentials => { + const token = randomBytes(32).toString('hex'); + return { + ingestTokenHash: computeIngestTokenHash({ connectorId, spaceId, token }), + webhookUrl: buildInboundWebhookUrl({ publicBaseUrl, spaceId, connectorId, token }), + }; +}; + +/** Returns the token when URL + hash were minted for this connectorId/spaceId. */ +const getVerifiedIngestToken = ({ + webhookUrl, + ingestTokenHash, + connectorId, + spaceId, +}: { + webhookUrl: unknown; + ingestTokenHash: unknown; + connectorId: string; + spaceId: string; +}): string | undefined => { + if (typeof webhookUrl !== 'string' || typeof ingestTokenHash !== 'string') { + return undefined; + } + + try { + const url = new URL(webhookUrl); + const expectedPathEnd = `/api/events/v1/inboundWebhook/${connectorId}`; + if (!url.pathname.endsWith(expectedPathEnd)) { + return undefined; + } + if (spaceId !== 'default' && !url.pathname.includes(`/s/${encodeURIComponent(spaceId)}/`)) { + return undefined; + } + const token = url.searchParams.get('token') ?? undefined; + if (!token || computeIngestTokenHash({ connectorId, spaceId, token }) !== ingestTokenHash) { + return undefined; + } + return token; + } catch { + return undefined; + } +}; + +const applyCredentials = ( + config: Record, + credentials: InboundWebhookIngressCredentials +): void => { + config.ingestTokenHash = credentials.ingestTokenHash; + config.webhookUrl = credentials.webhookUrl; +}; + +export const ensureInboundWebhookIngressCredentials = ({ + config, + connectorId, + spaceId, + publicBaseUrl, + isUpdate, + logger, +}: { + config: Record; + connectorId: string; + spaceId: string; + publicBaseUrl: string; + isUpdate: boolean; + logger: Logger; +}): void => { + if (!isUpdate) { + const token = getVerifiedIngestToken({ + webhookUrl: config.webhookUrl, + ingestTokenHash: config.ingestTokenHash, + connectorId, + spaceId, + }); + if (token) { + config.webhookUrl = buildInboundWebhookUrl({ + publicBaseUrl, + spaceId, + connectorId, + token, + }); + return; + } + applyCredentials( + config, + mintInboundWebhookIngressCredentials({ connectorId, spaceId, publicBaseUrl }) + ); + return; + } + + if ( + typeof config.ingestTokenHash === 'string' && + config.ingestTokenHash.length > 0 && + typeof config.webhookUrl === 'string' && + config.webhookUrl.length > 0 + ) { + return; + } + + logger.warn( + `Inbound webhook credentials missing on update for connector ${connectorId}; minting new credentials` + ); + applyCredentials( + config, + mintInboundWebhookIngressCredentials({ connectorId, spaceId, publicBaseUrl }) + ); +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts index bc87487d8d618..ca7e4cca8ee14 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts @@ -5,16 +5,13 @@ * 2.0. */ -import { randomBytes } from 'node:crypto'; - import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; -import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; import { InboundWebhookConnector } from '@kbn/connector-specs/src/specs/inbound_webhook/inbound_webhook'; -import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; import type { KibanaRequest, Logger, SecurityServiceStart } from '@kbn/core/server'; import { z } from '@kbn/zod/v4'; +import { ensureInboundWebhookIngressCredentials } from './ensure_inbound_webhook_ingress_credentials'; import { DELEGATED_API_KEY_ID_CONFIG, DELEGATED_API_KEY_SECRET, @@ -62,29 +59,14 @@ export function registerInboundWebhookConnectorType({ const configRecord = config as Record; const secretsRecord = secrets as Record; - const existingWebhookUrl = - typeof configRecord.webhookUrl === 'string' ? configRecord.webhookUrl : undefined; - let token: string | undefined; - if (existingWebhookUrl) { - try { - token = new URL(existingWebhookUrl).searchParams.get('token') ?? undefined; - } catch { - token = undefined; - } - } - - if (!token && !isUpdate && typeof configRecord.ingestTokenHash !== 'string') { - token = randomBytes(32).toString('hex'); - } - - if (token) { - configRecord.ingestTokenHash = computeIngestTokenHash({ - connectorId, - spaceId, - token, - }); - configRecord.webhookUrl = `${getPublicBaseUrl()}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; - } + ensureInboundWebhookIngressCredentials({ + config: configRecord, + connectorId, + spaceId, + publicBaseUrl: getPublicBaseUrl(), + isUpdate, + logger, + }); const hasDelegatedKey = typeof secretsRecord[DELEGATED_API_KEY_SECRET] === 'string' && @@ -128,5 +110,3 @@ export function registerInboundWebhookConnectorType({ }, }); } - -export { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 69ff838e09dc2..3da65a8b598e5 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts @@ -10,6 +10,7 @@ import type { Plugin, CoreSetup, CoreStart, + KibanaRequest, Logger, } from '@kbn/core/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; @@ -28,6 +29,7 @@ import { getWellKnownEmailServiceRoute, getWebhookSecretHeadersKeyRoute, getHttpSecretQueryParamsKeyRoute, + rotateInboundWebhookUrlRoute, } from './routes'; import type { ExperimentalFeatures } from '../common/experimental_features'; import { parseExperimentalConfigValue } from '../common/experimental_features'; @@ -105,16 +107,27 @@ export class StackConnectorsPlugin }); if (this.experimentalFeatures.connectorsFromSpecs) { + const getSpaceId = (request: KibanaRequest) => + plugins.spaces?.spacesService.getSpaceId(request) ?? 'default'; + const getPublicBaseUrl = () => core.http.basePath.publicBaseUrl ?? ''; + registerConnectorTypesFromSpecs({ actions, - getSpaceId: (request) => plugins.spaces?.spacesService.getSpaceId(request) ?? 'default', - getPublicBaseUrl: () => core.http.basePath.publicBaseUrl ?? '', + getSpaceId, + getPublicBaseUrl, getSecurity: async () => { const [coreStart] = await core.getStartServices(); return coreStart.security; }, logger: this.logger, }); + rotateInboundWebhookUrlRoute({ + router, + getStartServices: core.getStartServices, + getPublicBaseUrl, + getSpaceId, + logger: this.logger, + }); registerConnectorEventTriggers(plugins.workflowsExtensions); } diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts index d982a7b1aed0e..a567d9a29f7f6 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts @@ -8,3 +8,4 @@ export { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; export { getWebhookSecretHeadersKeyRoute } from './get_webhook_secret_headers_key'; export { getHttpSecretQueryParamsKeyRoute } from './get_http_secret_query_params_key'; +export { rotateInboundWebhookUrlRoute } from './rotate_inbound_webhook_url'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.test.ts new file mode 100644 index 0000000000000..84758ad39d4e8 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { httpServerMock, httpServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; + +import { rotateInboundWebhookUrlRoute } from './rotate_inbound_webhook_url'; + +describe('rotateInboundWebhookUrlRoute', () => { + const logger = loggingSystemMock.createLogger(); + + const setup = () => { + const router = httpServiceMock.createRouter(); + const ensureAuthorized = jest.fn().mockResolvedValue(undefined); + const getStartServices = jest.fn().mockResolvedValue([ + {}, + { + actions: { + getActionsAuthorizationWithRequest: () => ({ ensureAuthorized }), + }, + }, + ]); + + rotateInboundWebhookUrlRoute({ + router, + getStartServices, + getPublicBaseUrl: () => 'https://kibana.example.com', + getSpaceId: () => 'default', + logger, + }); + + const [config, handler] = router.post.mock.calls[0]; + return { config, handler, ensureAuthorized }; + }; + + it('registers the internal rotate URL path', () => { + const { config } = setup(); + expect(config.path).toBe('/internal/stack_connectors/inbound_webhook/_rotate_url'); + }); + + it('returns minted credentials when authorized', async () => { + const { handler, ensureAuthorized } = setup(); + const res = httpServerMock.createResponseFactory(); + const req = httpServerMock.createKibanaRequest({ + body: { connectorId: 'my-connector' }, + }); + + await handler({}, req, res); + + expect(ensureAuthorized).toHaveBeenCalledWith({ + operation: 'create', + actionTypeId: '.inboundWebhook', + }); + expect(res.ok).toHaveBeenCalled(); + const body = res.ok.mock.calls[0][0]?.body as { + webhookUrl: string; + ingestTokenHash: string; + }; + expect(body.webhookUrl).toMatch( + /^https:\/\/kibana\.example\.com\/api\/events\/v1\/inboundWebhook\/my-connector\?token=[a-f0-9]{64}$/ + ); + const token = new URL(body.webhookUrl).searchParams.get('token')!; + expect(body.ingestTokenHash).toBe( + computeIngestTokenHash({ + connectorId: 'my-connector', + spaceId: 'default', + token, + }) + ); + }); + + it('returns 400 for invalid connector ids', async () => { + const { handler } = setup(); + const res = httpServerMock.createResponseFactory(); + const req = httpServerMock.createKibanaRequest({ + body: { connectorId: 'Not Valid' }, + }); + + await handler({}, req, res); + + expect(res.badRequest).toHaveBeenCalled(); + }); + + it('returns forbidden when authorization fails', async () => { + const { handler, ensureAuthorized } = setup(); + ensureAuthorized.mockRejectedValue(Boom.forbidden('Unauthorized')); + const res = httpServerMock.createResponseFactory(); + const req = httpServerMock.createKibanaRequest({ + body: { connectorId: 'my-connector' }, + }); + + await handler({}, req, res); + + expect(res.customError).toHaveBeenCalledWith( + expect.objectContaining({ statusCode: 403 }) + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.ts new file mode 100644 index 0000000000000..5cf17f4449bf2 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { schema } from '@kbn/config-schema'; +import { CONNECTOR_ID_MAX_LENGTH, validateConnectorId } from '@kbn/actions-plugin/common'; +import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; +import type { IRouter, KibanaRequest, Logger, StartServicesAccessor } from '@kbn/core/server'; + +import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common'; +import { mintInboundWebhookIngressCredentials } from '../connector_types_from_spec/ensure_inbound_webhook_ingress_credentials'; +import type { ConnectorsPluginsStart } from '../plugin'; + +export const rotateInboundWebhookUrlRoute = ({ + router, + getStartServices, + getPublicBaseUrl, + getSpaceId, + logger, +}: { + router: IRouter; + getStartServices: StartServicesAccessor; + getPublicBaseUrl: () => string; + getSpaceId: (request: KibanaRequest) => string; + logger: Logger; +}): void => { + router.post( + { + path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/inbound_webhook/_rotate_url`, + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because it relies on the Actions authorization model (create).', + }, + }, + validate: { + body: schema.object({ + connectorId: schema.string({ minLength: 1, maxLength: CONNECTOR_ID_MAX_LENGTH }), + }), + }, + options: { + access: 'internal', + }, + }, + async (_ctx, req, res) => { + const { connectorId } = req.body; + + try { + validateConnectorId(connectorId); + } catch (error) { + return res.badRequest({ + body: error instanceof Error ? error.message : 'Invalid connector ID', + }); + } + + try { + const [, { actions }] = await getStartServices(); + await actions.getActionsAuthorizationWithRequest(req).ensureAuthorized({ + operation: 'create', + actionTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }); + + return res.ok({ + body: mintInboundWebhookIngressCredentials({ + connectorId, + spaceId: getSpaceId(req), + publicBaseUrl: getPublicBaseUrl(), + }), + }); + } catch (error) { + if (isBoom(error)) { + return res.customError({ + statusCode: error.output.statusCode, + body: { message: error.message }, + }); + } + logger.error(`Failed to rotate inbound webhook URL: ${error}`); + return res.customError({ + statusCode: 500, + body: { message: 'Failed to rotate inbound webhook URL' }, + }); + } + } + ); +}; From 0c06f27b7423253524286a241acdbbb9397aba10 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Thu, 16 Jul 2026 09:46:15 +0200 Subject: [PATCH 16/19] cleaning up --- .../shared/kbn-connector-specs/index.ts | 1 + .../kbn-connector-specs/server/index.ts | 6 +- .../src/connector_event_type_id.ts | 8 ++ .../src/to_registered_connector_event.test.ts | 13 ++- .../shared/kbn-workflows/server/index.ts | 7 +- ...onnector_event_to_workflow_surface.test.ts | 82 ------------------- .../connector_event_to_workflow_surface.ts | 38 --------- .../spec/workflow_surface/index.ts | 1 - ...e_connector_event_workflow_surface.test.ts | 64 --------------- ...esolve_connector_event_workflow_surface.ts | 24 ------ .../shared/workflows_management/kibana.jsonc | 1 - .../snippets/generate_surface_snippet.test.ts | 34 -------- .../lib/snippets/generate_surface_snippet.ts | 35 -------- .../workflows_management/server/types.ts | 6 -- .../connector_types/inbound_webhook/api.ts | 8 +- .../inbound_webhook_connector_fields.tsx | 21 ++++- ...ure_connector_ingress_credentials.test.ts} | 74 ++++++++--------- ...> ensure_connector_ingress_credentials.ts} | 46 +++++++---- .../inbound_webhook_api_key_service.ts | 15 ---- ...register_inbound_webhook_connector_type.ts | 22 ++--- .../shared/stack_connectors/server/plugin.ts | 4 +- .../stack_connectors/server/routes/index.ts | 2 +- ...s => rotate_connector_ingress_url.test.ts} | 54 ++++++++++-- ...url.ts => rotate_connector_ingress_url.ts} | 29 +++++-- .../edit_connector_flyout/index.test.tsx | 57 +++++++++++-- .../edit_connector_flyout/index.tsx | 57 ++++++++++++- 26 files changed, 299 insertions(+), 410 deletions(-) delete mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts delete mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts delete mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts delete mode 100644 src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts delete mode 100644 src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts delete mode 100644 src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts rename x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/{ensure_inbound_webhook_ingress_credentials.test.ts => ensure_connector_ingress_credentials.test.ts} (76%) rename x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/{ensure_inbound_webhook_ingress_credentials.ts => ensure_connector_ingress_credentials.ts} (69%) rename x-pack/platform/plugins/shared/stack_connectors/server/routes/{rotate_inbound_webhook_url.test.ts => rotate_connector_ingress_url.test.ts} (66%) rename x-pack/platform/plugins/shared/stack_connectors/server/routes/{rotate_inbound_webhook_url.ts => rotate_connector_ingress_url.ts} (68%) diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts index a18bca6c2a5fa..6cea629822ad6 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -12,6 +12,7 @@ export type * from './src/connector_spec_events'; export { defineConnectorEvent } from './src/define_connector_event'; export { buildConnectorEventId, + buildConnectorIngressEventsPath, connectorTypeToEventNamespace, normalizeConnectorTypeId, } from './src/connector_event_type_id'; diff --git a/src/platform/packages/shared/kbn-connector-specs/server/index.ts b/src/platform/packages/shared/kbn-connector-specs/server/index.ts index 292134c05b933..79ef95a6a1edd 100644 --- a/src/platform/packages/shared/kbn-connector-specs/server/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/server/index.ts @@ -1,6 +1,6 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License + * or more contributor license agreements. Licensed under the Elastic License * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side * Public License v 1"; you may not use this file except in compliance with, at * your election, the "Elastic License 2.0", the "GNU Affero General Public @@ -9,11 +9,7 @@ export { getConnectorSpec } from '../src/get_connector_spec'; export { - listConnectorEventInfos, listConnectorEventInfosForType, type ConnectorEventInfo, } from '../src/list_connector_event_infos'; export { resolveRegisteredConnectorEventByEventId } from '../src/resolve_registered_connector_event_by_event_id'; -export { computeIngestTokenHash } from '../src/inbound_webhook/compute_ingest_token_hash'; -export { filterInboundHeaders } from '../src/inbound_webhook/filter_inbound_headers'; -export { InboundWebhookConnector } from '../src/specs/inbound_webhook/inbound_webhook'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_event_type_id.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_event_type_id.ts index 7754f530e24f4..6fab2da8125f7 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/connector_event_type_id.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_event_type_id.ts @@ -23,3 +23,11 @@ export const normalizeConnectorTypeId = (typeId: string): string => export const buildConnectorEventId = (connectorTypeId: string, eventKey: string): string => `${connectorTypeToEventNamespace(connectorTypeId)}.${eventKey}`; + +export const buildConnectorIngressEventsPath = ({ + connectorTypeId, + connectorId, +}: { + connectorTypeId: string; + connectorId: string; +}): string => `/api/events/v1/${connectorTypeToEventNamespace(connectorTypeId)}/${connectorId}`; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts index 122c3f1f575c9..579f6e7afc3c9 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/to_registered_connector_event.test.ts @@ -7,11 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { inboundWebhookReceivedEventSchema } from '@kbn/connector-specs'; +import { inboundWebhookReceivedEventSchema } from '..'; import type { ConnectorMetadata } from './connector_spec'; import { defineConnectorEvent } from './define_connector_event'; import { buildConnectorEventId, + buildConnectorIngressEventsPath, connectorTypeToEventNamespace, normalizeConnectorTypeId, } from './connector_event_type_id'; @@ -36,6 +37,15 @@ describe('connector event type id helpers', () => { expect(normalizeConnectorTypeId('.inboundWebhook')).toBe('.inboundWebhook'); }); + it('buildConnectorIngressEventsPath builds the hub route segment', () => { + expect( + buildConnectorIngressEventsPath({ + connectorTypeId: '.inboundWebhook', + connectorId: 'conn-1', + }) + ).toBe('/api/events/v1/inboundWebhook/conn-1'); + }); + it('buildConnectorEventId follows convention', () => { expect(buildConnectorEventId('.inboundWebhook', 'received')).toBe('inboundWebhook.received'); }); @@ -71,5 +81,4 @@ describe('toRegisteredConnectorEvent', () => { /eventId mismatch/ ); }); - }); diff --git a/src/platform/packages/shared/kbn-workflows/server/index.ts b/src/platform/packages/shared/kbn-workflows/server/index.ts index 0c77851b7a862..8863ef8bd197f 100644 --- a/src/platform/packages/shared/kbn-workflows/server/index.ts +++ b/src/platform/packages/shared/kbn-workflows/server/index.ts @@ -38,12 +38,7 @@ export { buildExternalResumeUrl } from './external_resume/build_external_resume_ export { buildExternalResumeFormUrl } from './external_resume/build_external_resume_form_url'; export { computeTokenHmac } from './external_resume/compute_token_hmac'; -export { - resolveConnectorEventTriggerDefinition, -} from '../spec/workflow_surface/resolve_connector_event_trigger_definition'; -export { - resolveConnectorEventWorkflowSurface, -} from '../spec/workflow_surface/resolve_connector_event_workflow_surface'; +export { resolveConnectorEventTriggerDefinition } from '../spec/workflow_surface/resolve_connector_event_trigger_definition'; export type { GetManagedWorkflowStatusOptions, diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts deleted file mode 100644 index a3114a2d36be9..0000000000000 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { inboundWebhookReceivedEventSchema } from '@kbn/connector-specs'; -import { - defineConnectorEvent, - toRegisteredConnectorEvent, -} from '@kbn/connector-specs'; -import type { ConnectorMetadata } from '@kbn/connector-specs'; -import { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; - -const inboundWebhookMetadata: ConnectorMetadata = { - id: '.inboundWebhook', - displayName: 'Inbound Webhook', - description: 'Receive HTTP requests from external systems', - minimumLicense: 'gold', - supportedFeatureIds: ['workflows'], -}; - -describe('connectorEventToWorkflowSurface', () => { - it('maps inboundWebhook.received to a trigger surface with required connector binding', () => { - const registered = toRegisteredConnectorEvent( - inboundWebhookMetadata, - 'received', - defineConnectorEvent({ - eventId: 'inboundWebhook.received', - title: 'Webhook received', - description: 'Fires when an authenticated request hits this connector endpoint.', - eventSchema: inboundWebhookReceivedEventSchema, - }) - ); - - const surface = connectorEventToWorkflowSurface(registered); - - expect(surface).toEqual({ - id: 'inboundWebhook.received', - kind: 'trigger', - title: 'Webhook received', - description: 'Fires when an authenticated request hits this connector endpoint.', - stability: 'tech_preview', - binding: { - connectorTypeId: '.inboundWebhook', - instanceRef: 'required', - }, - surfaces: { - input: inboundWebhookReceivedEventSchema, - filter: { - schema: inboundWebhookReceivedEventSchema, - language: 'kql', - yamlPath: 'on.condition', - }, - }, - source: { - type: 'connector-event', - connectorTypeId: '.inboundWebhook', - eventKey: 'received', - }, - }); - }); - - it('preserves explicit stability from the connector event', () => { - const registered = toRegisteredConnectorEvent( - inboundWebhookMetadata, - 'received', - defineConnectorEvent({ - eventId: 'inboundWebhook.received', - title: 'Webhook received', - description: 'Stable webhook ingress.', - eventSchema: inboundWebhookReceivedEventSchema, - stability: 'beta', - }) - ); - - expect(connectorEventToWorkflowSurface(registered).stability).toBe('beta'); - }); -}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts deleted file mode 100644 index 02779784eae87..0000000000000 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/connector_event_to_workflow_surface.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { RegisteredConnectorEvent } from '@kbn/connector-specs'; -import type { WorkflowSurfaceDefinition } from './types'; - -export const connectorEventToWorkflowSurface = ( - event: RegisteredConnectorEvent -): WorkflowSurfaceDefinition => ({ - id: event.eventId, - kind: 'trigger', - title: event.title, - description: event.description, - stability: event.stability ?? 'tech_preview', - binding: { - connectorTypeId: event.connectorTypeId, - instanceRef: 'required', - }, - surfaces: { - input: event.eventSchema, - filter: { - schema: event.eventSchema, - language: 'kql', - yamlPath: 'on.condition', - }, - }, - source: { - type: 'connector-event', - connectorTypeId: event.connectorTypeId, - eventKey: event.eventKey, - }, -}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts index 22d71894f9935..2a35a007273cb 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts @@ -15,7 +15,6 @@ export type { WorkflowSurfaceKind, WorkflowSurfaceSource, } from './types'; -export { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; export { connectorEventToTriggerDefinition, type ConnectorEventTriggerDefinition, diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts deleted file mode 100644 index 87cf328ed4daa..0000000000000 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { inboundWebhookReceivedEventSchema } from '@kbn/connector-specs'; - -const mockInboundWebhookReceivedEventSchema = inboundWebhookReceivedEventSchema; - -const mockInboundWebhookReceivedEvent = { - eventId: 'inboundWebhook.received', - eventKey: 'received', - connectorTypeId: '.inboundWebhook', - title: 'Webhook received', - description: 'Fires when an authenticated request hits this connector endpoint.', - eventSchema: mockInboundWebhookReceivedEventSchema, -}; - -jest.mock('@kbn/connector-specs/server', () => { - const actual = jest.requireActual('@kbn/connector-specs/server'); - return { - ...actual, - resolveRegisteredConnectorEventByEventId: jest.fn((eventId: string) => - eventId === 'inboundWebhook.received' ? mockInboundWebhookReceivedEvent : undefined - ), - }; -}); - -import { resolveConnectorEventWorkflowSurface } from './resolve_connector_event_workflow_surface'; - -describe('resolveConnectorEventWorkflowSurface', () => { - it('returns a trigger surface with required connector binding and KQL filter schema', () => { - const surface = resolveConnectorEventWorkflowSurface('inboundWebhook.received'); - - expect(surface).toMatchObject({ - id: 'inboundWebhook.received', - kind: 'trigger', - binding: { - connectorTypeId: '.inboundWebhook', - instanceRef: 'required', - }, - surfaces: { - filter: { - language: 'kql', - yamlPath: 'on.condition', - }, - }, - source: { - type: 'connector-event', - connectorTypeId: '.inboundWebhook', - eventKey: 'received', - }, - }); - expect(surface?.surfaces.filter?.schema).toBe(mockInboundWebhookReceivedEventSchema); - }); - - it('returns undefined for unknown event ids', () => { - expect(resolveConnectorEventWorkflowSurface('unknown.event')).toBeUndefined(); - }); -}); diff --git a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts deleted file mode 100644 index 335fbfac6c3ad..0000000000000 --- a/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/resolve_connector_event_workflow_surface.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { resolveRegisteredConnectorEventByEventId } from '@kbn/connector-specs/server'; -import { connectorEventToWorkflowSurface } from './connector_event_to_workflow_surface'; -import type { WorkflowSurfaceDefinition } from './types'; - -/** Resolves a connector-event trigger id to its workflow surface (from ConnectorSpec.events). */ -export const resolveConnectorEventWorkflowSurface = ( - eventId: string -): WorkflowSurfaceDefinition | undefined => { - const event = resolveRegisteredConnectorEventByEventId(eventId); - if (!event) { - return undefined; - } - - return connectorEventToWorkflowSurface(event); -}; diff --git a/src/platform/plugins/shared/workflows_management/kibana.jsonc b/src/platform/plugins/shared/workflows_management/kibana.jsonc index aad3e65c1e5f7..2d47dddf5db17 100644 --- a/src/platform/plugins/shared/workflows_management/kibana.jsonc +++ b/src/platform/plugins/shared/workflows_management/kibana.jsonc @@ -15,7 +15,6 @@ "navigation", "taskManager", "actions", - "encryptedSavedObjects", "workflowsExecutionEngine", "workflowsExtensions", "features", diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts deleted file mode 100644 index aee7876dec230..0000000000000 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -jest.mock('./generate_trigger_snippet', () => ({ - generateTriggerSnippet: jest.fn(() => 'mock-snippet'), -})); - -import { generateSurfaceSnippet, generateSurfaceSnippetFromSurface } from './generate_surface_snippet'; -import { generateTriggerSnippet } from './generate_trigger_snippet'; - -describe('generateSurfaceSnippet', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('delegates to generateTriggerSnippet using the surface id', () => { - const options = { full: true, defaultConnectorId: 'sales-ingress' }; - - expect(generateSurfaceSnippet('inboundWebhook.received', options)).toBe('mock-snippet'); - expect(generateTriggerSnippet).toHaveBeenCalledWith('inboundWebhook.received', options); - }); - - it('delegates from a resolved workflow surface definition', () => { - generateSurfaceSnippetFromSurface({ id: 'inboundWebhook.received' }, { full: true }); - - expect(generateTriggerSnippet).toHaveBeenCalledWith('inboundWebhook.received', { full: true }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts deleted file mode 100644 index 7cfb31c1764c6..0000000000000 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_surface_snippet.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { WorkflowSurfaceDefinition } from '@kbn/workflows'; -import { generateTriggerSnippet } from './generate_trigger_snippet'; - -export interface GenerateSurfaceSnippetOptions { - full?: boolean; - monacoSuggestionFormat?: boolean; - withTriggersSection?: boolean; - defaultCondition?: string; - defaultConnectorId?: string; -} - -/** Generates a YAML trigger snippet for a connector-event workflow surface id. */ -export function generateSurfaceSnippet( - surfaceId: string, - options?: GenerateSurfaceSnippetOptions -): string { - return generateTriggerSnippet(surfaceId, options); -} - -/** Generates a YAML trigger snippet from a resolved workflow surface definition. */ -export function generateSurfaceSnippetFromSurface( - surface: Pick, - options?: GenerateSurfaceSnippetOptions -): string { - return generateSurfaceSnippet(surface.id, options); -} diff --git a/src/platform/plugins/shared/workflows_management/server/types.ts b/src/platform/plugins/shared/workflows_management/server/types.ts index b0f5dff0c2387..d6cb465f8ba29 100644 --- a/src/platform/plugins/shared/workflows_management/server/types.ts +++ b/src/platform/plugins/shared/workflows_management/server/types.ts @@ -17,10 +17,6 @@ import type { AlertingServerSetup, } from '@kbn/alerting-plugin/server'; import type { CustomRequestHandlerContext, IRouter } from '@kbn/core/server'; -import type { - EncryptedSavedObjectsPluginSetup, - EncryptedSavedObjectsPluginStart, -} from '@kbn/encrypted-saved-objects-plugin/server'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import type { InboxPluginSetup } from '@kbn/inbox-plugin/server'; @@ -54,7 +50,6 @@ export interface WorkflowsServerPluginSetupDeps { features?: FeaturesPluginSetup; taskManager?: TaskManagerSetupContract; actions?: ActionsPluginSetupContract; - encryptedSavedObjects?: EncryptedSavedObjectsPluginSetup; alerting?: AlertingServerSetup; spaces: SpacesPluginSetup; serverless?: ServerlessServerSetup; @@ -71,7 +66,6 @@ export interface WorkflowsServerPluginStartDeps { taskManager: TaskManagerStartContract; workflowsExecutionEngine: WorkflowsExecutionEnginePluginStart; actions: ActionsPluginStartContract; - encryptedSavedObjects?: EncryptedSavedObjectsPluginStart; security?: SecurityPluginStart; spaces: SpacesPluginStart; workflowsExtensions: WorkflowsExtensionsServerPluginStart; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts index 0864a0ea561f4..c38a4c01b7791 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts @@ -8,14 +8,16 @@ import type { HttpSetup } from '@kbn/core/public'; import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../../common'; -export async function rotateInboundWebhookUrl({ +export async function rotateConnectorIngressUrl({ http, connectorId, + connectorTypeId, }: { http: HttpSetup; connectorId: string; + connectorTypeId: string; }): Promise<{ webhookUrl: string; ingestTokenHash: string }> { - return http.post(`${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/inbound_webhook/_rotate_url`, { - body: JSON.stringify({ connectorId }), + return http.post(`${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/connector_ingress/_rotate_url`, { + body: JSON.stringify({ connectorId, connectorTypeId }), }); } diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx index 2a97e13d3417e..b3749bed6b55c 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx @@ -21,12 +21,16 @@ import { useFormContext, useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + buildConnectorIngressEventsPath, + INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, +} from '@kbn/connector-specs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { ActionConnectorFieldsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import { rotateInboundWebhookUrl } from './api'; +import { rotateConnectorIngressUrl } from './api'; const CONNECTOR_ID_SETTLE_MS = 400; const CONNECTOR_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -54,7 +58,14 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec return; } - if (webhookUrl?.includes(`/inboundWebhook/${connectorId}?`)) { + if ( + webhookUrl?.includes( + `${buildConnectorIngressEventsPath({ + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + connectorId, + })}?` + ) + ) { return; } @@ -68,7 +79,11 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec setIsRotating(true); setRotateError(undefined); - void rotateInboundWebhookUrl({ http, connectorId }) + void rotateConnectorIngressUrl({ + http, + connectorId, + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }) .then((credentials) => { if (requestId !== requestIdRef.current) { return; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.test.ts similarity index 76% rename from x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.test.ts rename to x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.test.ts index 85ac7190b6e96..080130a8c0a03 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.test.ts @@ -5,58 +5,48 @@ * 2.0. */ +import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; import { - buildInboundWebhookUrl, - ensureInboundWebhookIngressCredentials, - mintInboundWebhookIngressCredentials, -} from './ensure_inbound_webhook_ingress_credentials'; - -describe('buildInboundWebhookUrl', () => { - it('omits space prefix for the default space', () => { - expect( - buildInboundWebhookUrl({ - publicBaseUrl: 'https://kibana.example.com', - spaceId: 'default', - connectorId: 'conn-1', - token: 'abc', - }) - ).toBe('https://kibana.example.com/api/events/v1/inboundWebhook/conn-1?token=abc'); - }); + ensureConnectorIngressCredentials, + mintConnectorIngressCredentials, +} from './ensure_connector_ingress_credentials'; - it('includes space prefix for non-default spaces and strips trailing slash', () => { - expect( - buildInboundWebhookUrl({ - publicBaseUrl: 'https://kibana.example.com/kb/', - spaceId: 'security', - connectorId: 'conn-1', - token: 'abc', - }) - ).toBe( - 'https://kibana.example.com/kb/s/security/api/events/v1/inboundWebhook/conn-1?token=abc' - ); - }); -}); - -describe('ensureInboundWebhookIngressCredentials', () => { +describe('ensureConnectorIngressCredentials', () => { const logger = loggingSystemMock.createLogger(); + const connectorTypeId = INBOUND_WEBHOOK_CONNECTOR_TYPE_ID; beforeEach(() => { jest.clearAllMocks(); }); + it('builds space-aware URLs when minting credentials', () => { + const minted = mintConnectorIngressCredentials({ + connectorTypeId, + connectorId: 'conn-1', + spaceId: 'team-a', + publicBaseUrl: 'https://kibana.example.com/kb/', + }); + + expect(minted.webhookUrl).toMatch( + /^https:\/\/kibana\.example\.com\/kb\/s\/team-a\/api\/events\/v1\/inboundWebhook\/conn-1\?token=[a-f0-9]{64}$/ + ); + }); + it('keeps verified credentials from rotate-URL on create', () => { - const minted = mintInboundWebhookIngressCredentials({ + const minted = mintConnectorIngressCredentials({ + connectorTypeId, connectorId: 'conn-1', spaceId: 'default', publicBaseUrl: 'https://kibana.example.com', }); const config: Record = { ...minted }; - ensureInboundWebhookIngressCredentials({ + ensureConnectorIngressCredentials({ config, + connectorTypeId, connectorId: 'conn-1', spaceId: 'default', publicBaseUrl: 'https://kibana.example.com', @@ -78,8 +68,9 @@ describe('ensureInboundWebhookIngressCredentials', () => { }), }; - ensureInboundWebhookIngressCredentials({ + ensureConnectorIngressCredentials({ config, + connectorTypeId, connectorId: 'conn-1', spaceId: 'default', publicBaseUrl: 'https://kibana.example.com', @@ -105,8 +96,9 @@ describe('ensureInboundWebhookIngressCredentials', () => { ingestTokenHash: 'a'.repeat(64), }; - ensureInboundWebhookIngressCredentials({ + ensureConnectorIngressCredentials({ config, + connectorTypeId, connectorId: 'conn-1', spaceId: 'default', publicBaseUrl: 'https://kibana.example.com', @@ -129,15 +121,17 @@ describe('ensureInboundWebhookIngressCredentials', () => { }); it('remints on create when connector id no longer matches rotated URL', () => { - const minted = mintInboundWebhookIngressCredentials({ + const minted = mintConnectorIngressCredentials({ + connectorTypeId, connectorId: 'old-id', spaceId: 'default', publicBaseUrl: 'https://kibana.example.com', }); const config: Record = { ...minted }; - ensureInboundWebhookIngressCredentials({ + ensureConnectorIngressCredentials({ config, + connectorTypeId, connectorId: 'new-id', spaceId: 'default', publicBaseUrl: 'https://kibana.example.com', @@ -160,8 +154,9 @@ describe('ensureInboundWebhookIngressCredentials', () => { }; const original = { ...config }; - ensureInboundWebhookIngressCredentials({ + ensureConnectorIngressCredentials({ config, + connectorTypeId, connectorId: 'conn-1', spaceId: 'default', publicBaseUrl: 'https://kibana.example.com', @@ -176,8 +171,9 @@ describe('ensureInboundWebhookIngressCredentials', () => { it('remints on update when credentials are missing', () => { const config: Record = {}; - ensureInboundWebhookIngressCredentials({ + ensureConnectorIngressCredentials({ config, + connectorTypeId, connectorId: 'conn-1', spaceId: 'team-a', publicBaseUrl: 'https://kibana.example.com', diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.ts similarity index 69% rename from x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.ts rename to x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.ts index 6ccb85c56ec0c..32309f0f117a5 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_inbound_webhook_ingress_credentials.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.ts @@ -7,55 +7,69 @@ import { randomBytes } from 'node:crypto'; +import { buildConnectorIngressEventsPath } from '@kbn/connector-specs'; import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; import type { Logger } from '@kbn/logging'; -export interface InboundWebhookIngressCredentials { +export interface ConnectorIngressCredentials { readonly ingestTokenHash: string; readonly webhookUrl: string; } -export const buildInboundWebhookUrl = ({ +const buildConnectorIngressUrl = ({ publicBaseUrl, spaceId, + connectorTypeId, connectorId, token, }: { publicBaseUrl: string; spaceId: string; + connectorTypeId: string; connectorId: string; token: string; }): string => { const base = publicBaseUrl.replace(/\/$/, ''); const spacePrefix = spaceId !== 'default' ? `/s/${encodeURIComponent(spaceId)}` : ''; - return `${base}${spacePrefix}/api/events/v1/inboundWebhook/${connectorId}?token=${token}`; + const ingressPath = buildConnectorIngressEventsPath({ connectorTypeId, connectorId }); + return `${base}${spacePrefix}${ingressPath}?token=${token}`; }; -export const mintInboundWebhookIngressCredentials = ({ +export const mintConnectorIngressCredentials = ({ + connectorTypeId, connectorId, spaceId, publicBaseUrl, }: { + connectorTypeId: string; connectorId: string; spaceId: string; publicBaseUrl: string; -}): InboundWebhookIngressCredentials => { +}): ConnectorIngressCredentials => { const token = randomBytes(32).toString('hex'); return { ingestTokenHash: computeIngestTokenHash({ connectorId, spaceId, token }), - webhookUrl: buildInboundWebhookUrl({ publicBaseUrl, spaceId, connectorId, token }), + webhookUrl: buildConnectorIngressUrl({ + publicBaseUrl, + spaceId, + connectorTypeId, + connectorId, + token, + }), }; }; -/** Returns the token when URL + hash were minted for this connectorId/spaceId. */ +/** Returns the token when URL + hash were minted for this connector type/id/space. */ const getVerifiedIngestToken = ({ webhookUrl, ingestTokenHash, + connectorTypeId, connectorId, spaceId, }: { webhookUrl: unknown; ingestTokenHash: unknown; + connectorTypeId: string; connectorId: string; spaceId: string; }): string | undefined => { @@ -65,7 +79,7 @@ const getVerifiedIngestToken = ({ try { const url = new URL(webhookUrl); - const expectedPathEnd = `/api/events/v1/inboundWebhook/${connectorId}`; + const expectedPathEnd = buildConnectorIngressEventsPath({ connectorTypeId, connectorId }); if (!url.pathname.endsWith(expectedPathEnd)) { return undefined; } @@ -84,14 +98,15 @@ const getVerifiedIngestToken = ({ const applyCredentials = ( config: Record, - credentials: InboundWebhookIngressCredentials + credentials: ConnectorIngressCredentials ): void => { config.ingestTokenHash = credentials.ingestTokenHash; config.webhookUrl = credentials.webhookUrl; }; -export const ensureInboundWebhookIngressCredentials = ({ +export const ensureConnectorIngressCredentials = ({ config, + connectorTypeId, connectorId, spaceId, publicBaseUrl, @@ -99,6 +114,7 @@ export const ensureInboundWebhookIngressCredentials = ({ logger, }: { config: Record; + connectorTypeId: string; connectorId: string; spaceId: string; publicBaseUrl: string; @@ -109,13 +125,15 @@ export const ensureInboundWebhookIngressCredentials = ({ const token = getVerifiedIngestToken({ webhookUrl: config.webhookUrl, ingestTokenHash: config.ingestTokenHash, + connectorTypeId, connectorId, spaceId, }); if (token) { - config.webhookUrl = buildInboundWebhookUrl({ + config.webhookUrl = buildConnectorIngressUrl({ publicBaseUrl, spaceId, + connectorTypeId, connectorId, token, }); @@ -123,7 +141,7 @@ export const ensureInboundWebhookIngressCredentials = ({ } applyCredentials( config, - mintInboundWebhookIngressCredentials({ connectorId, spaceId, publicBaseUrl }) + mintConnectorIngressCredentials({ connectorTypeId, connectorId, spaceId, publicBaseUrl }) ); return; } @@ -138,10 +156,10 @@ export const ensureInboundWebhookIngressCredentials = ({ } logger.warn( - `Inbound webhook credentials missing on update for connector ${connectorId}; minting new credentials` + `Connector ingress credentials missing on update for connector ${connectorId} (${connectorTypeId}); minting new credentials` ); applyCredentials( config, - mintInboundWebhookIngressCredentials({ connectorId, spaceId, publicBaseUrl }) + mintConnectorIngressCredentials({ connectorTypeId, connectorId, spaceId, publicBaseUrl }) ); }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts index 5dbb7af91080b..40642d011e82b 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.ts @@ -6,7 +6,6 @@ */ import { - getInboundWebhookAuthorizationHeader, INBOUND_WEBHOOK_DELEGATED_API_KEY_ID_CONFIG, INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET, INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG, @@ -21,7 +20,6 @@ export { INBOUND_WEBHOOK_DELEGATED_API_KEY_SECRET as DELEGATED_API_KEY_SECRET, INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_ID_CONFIG as DELEGATED_UIAM_API_KEY_ID_CONFIG, INBOUND_WEBHOOK_DELEGATED_UIAM_API_KEY_SECRET as DELEGATED_UIAM_API_KEY_SECRET, - getInboundWebhookAuthorizationHeader as tryGetAuthorizationHeaderFromSecrets, }; export interface InboundWebhookDelegatedCredentials { @@ -33,11 +31,6 @@ export interface InboundWebhookDelegatedCredentials { }; } -/** - * Mirrors `feature/inbound-webhook` InboundWebhookApiKeyService: grant/clone a - * user-scoped API key at connector create so unauthenticated ingress can schedule - * Task Manager runs with a clonable Authorization header. - */ export class InboundWebhookApiKeyService { constructor(private readonly security: SecurityServiceStart, private readonly logger: Logger) {} @@ -96,14 +89,6 @@ export class InboundWebhookApiKeyService { }; } - public getAuthorizationHeader(secrets: Record): string { - const header = getInboundWebhookAuthorizationHeader(secrets); - if (!header) { - throw new Error('Inbound webhook is missing delegated execution credentials'); - } - return header; - } - public async invalidate(params: { apiKeyId?: string; uiamApiKeyId?: string; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts index ca7e4cca8ee14..d70fdd147fe36 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts @@ -8,10 +8,11 @@ import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import { createConnectorTypeFromSpec } from '@kbn/actions-plugin/server/lib'; import { InboundWebhookConnector } from '@kbn/connector-specs/src/specs/inbound_webhook/inbound_webhook'; +import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; import type { KibanaRequest, Logger, SecurityServiceStart } from '@kbn/core/server'; import { z } from '@kbn/zod/v4'; -import { ensureInboundWebhookIngressCredentials } from './ensure_inbound_webhook_ingress_credentials'; +import { ensureConnectorIngressCredentials } from './ensure_connector_ingress_credentials'; import { DELEGATED_API_KEY_ID_CONFIG, DELEGATED_API_KEY_SECRET, @@ -20,14 +21,6 @@ import { InboundWebhookApiKeyService, } from './inbound_webhook_api_key_service'; -export interface RegisterInboundWebhookConnectorTypeParams { - actions: ActionsPluginSetupContract; - getSpaceId: (request: KibanaRequest) => string; - getPublicBaseUrl: () => string; - getSecurity: () => Promise; - logger: Logger; -} - const inboundWebhookSecretsSchema = z.object({ authType: z.literal('none').optional(), [DELEGATED_API_KEY_SECRET]: z.string().optional(), @@ -40,7 +33,13 @@ export function registerInboundWebhookConnectorType({ getPublicBaseUrl, getSecurity, logger, -}: RegisterInboundWebhookConnectorTypeParams): void { +}: { + actions: ActionsPluginSetupContract; + getSpaceId: (request: KibanaRequest) => string; + getPublicBaseUrl: () => string; + getSecurity: () => Promise; + logger: Logger; +}): void { const connectorType = createConnectorTypeFromSpec(InboundWebhookConnector, actions); const apiKeyServicePromise = getSecurity().then( (security) => new InboundWebhookApiKeyService(security, logger) @@ -59,8 +58,9 @@ export function registerInboundWebhookConnectorType({ const configRecord = config as Record; const secretsRecord = secrets as Record; - ensureInboundWebhookIngressCredentials({ + ensureConnectorIngressCredentials({ config: configRecord, + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, connectorId, spaceId, publicBaseUrl: getPublicBaseUrl(), diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 3da65a8b598e5..9aca70d8235ec 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts @@ -29,7 +29,7 @@ import { getWellKnownEmailServiceRoute, getWebhookSecretHeadersKeyRoute, getHttpSecretQueryParamsKeyRoute, - rotateInboundWebhookUrlRoute, + rotateConnectorIngressUrlRoute, } from './routes'; import type { ExperimentalFeatures } from '../common/experimental_features'; import { parseExperimentalConfigValue } from '../common/experimental_features'; @@ -121,7 +121,7 @@ export class StackConnectorsPlugin }, logger: this.logger, }); - rotateInboundWebhookUrlRoute({ + rotateConnectorIngressUrlRoute({ router, getStartServices: core.getStartServices, getPublicBaseUrl, diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts index a567d9a29f7f6..f53d21b0fc458 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/index.ts @@ -8,4 +8,4 @@ export { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; export { getWebhookSecretHeadersKeyRoute } from './get_webhook_secret_headers_key'; export { getHttpSecretQueryParamsKeyRoute } from './get_http_secret_query_params_key'; -export { rotateInboundWebhookUrlRoute } from './rotate_inbound_webhook_url'; +export { rotateConnectorIngressUrlRoute } from './rotate_connector_ingress_url'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.test.ts similarity index 66% rename from x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.test.ts rename to x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.test.ts index 84758ad39d4e8..1454a33437ff6 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.test.ts @@ -6,12 +6,13 @@ */ import Boom from '@hapi/boom'; +import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; import { httpServerMock, httpServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { computeIngestTokenHash } from '@kbn/connector-specs/src/inbound_webhook/compute_ingest_token_hash'; -import { rotateInboundWebhookUrlRoute } from './rotate_inbound_webhook_url'; +import { rotateConnectorIngressUrlRoute } from './rotate_connector_ingress_url'; -describe('rotateInboundWebhookUrlRoute', () => { +describe('rotateConnectorIngressUrlRoute', () => { const logger = loggingSystemMock.createLogger(); const setup = () => { @@ -26,7 +27,7 @@ describe('rotateInboundWebhookUrlRoute', () => { }, ]); - rotateInboundWebhookUrlRoute({ + rotateConnectorIngressUrlRoute({ router, getStartServices, getPublicBaseUrl: () => 'https://kibana.example.com', @@ -40,14 +41,17 @@ describe('rotateInboundWebhookUrlRoute', () => { it('registers the internal rotate URL path', () => { const { config } = setup(); - expect(config.path).toBe('/internal/stack_connectors/inbound_webhook/_rotate_url'); + expect(config.path).toBe('/internal/stack_connectors/connector_ingress/_rotate_url'); }); it('returns minted credentials when authorized', async () => { const { handler, ensureAuthorized } = setup(); const res = httpServerMock.createResponseFactory(); const req = httpServerMock.createKibanaRequest({ - body: { connectorId: 'my-connector' }, + body: { + connectorId: 'my-connector', + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }, }); await handler({}, req, res); @@ -74,11 +78,44 @@ describe('rotateInboundWebhookUrlRoute', () => { ); }); + it('accepts connector type ids without a leading dot', async () => { + const { handler } = setup(); + const res = httpServerMock.createResponseFactory(); + const req = httpServerMock.createKibanaRequest({ + body: { + connectorId: 'my-connector', + connectorTypeId: 'inboundWebhook', + }, + }); + + await handler({}, req, res); + + expect(res.ok).toHaveBeenCalled(); + }); + it('returns 400 for invalid connector ids', async () => { const { handler } = setup(); const res = httpServerMock.createResponseFactory(); const req = httpServerMock.createKibanaRequest({ - body: { connectorId: 'Not Valid' }, + body: { + connectorId: 'Not Valid', + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }, + }); + + await handler({}, req, res); + + expect(res.badRequest).toHaveBeenCalled(); + }); + + it('returns 400 for connector types without ingress events', async () => { + const { handler } = setup(); + const res = httpServerMock.createResponseFactory(); + const req = httpServerMock.createKibanaRequest({ + body: { + connectorId: 'my-connector', + connectorTypeId: '.slack', + }, }); await handler({}, req, res); @@ -91,7 +128,10 @@ describe('rotateInboundWebhookUrlRoute', () => { ensureAuthorized.mockRejectedValue(Boom.forbidden('Unauthorized')); const res = httpServerMock.createResponseFactory(); const req = httpServerMock.createKibanaRequest({ - body: { connectorId: 'my-connector' }, + body: { + connectorId: 'my-connector', + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }, }); await handler({}, req, res); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts similarity index 68% rename from x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.ts rename to x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts index 5cf17f4449bf2..ebfb96d21167f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_inbound_webhook_url.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts @@ -8,14 +8,15 @@ import { isBoom } from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import { CONNECTOR_ID_MAX_LENGTH, validateConnectorId } from '@kbn/actions-plugin/common'; -import { INBOUND_WEBHOOK_CONNECTOR_TYPE_ID } from '@kbn/connector-specs'; +import { normalizeConnectorTypeId } from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; import type { IRouter, KibanaRequest, Logger, StartServicesAccessor } from '@kbn/core/server'; import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common'; -import { mintInboundWebhookIngressCredentials } from '../connector_types_from_spec/ensure_inbound_webhook_ingress_credentials'; +import { mintConnectorIngressCredentials } from '../connector_types_from_spec/ensure_connector_ingress_credentials'; import type { ConnectorsPluginsStart } from '../plugin'; -export const rotateInboundWebhookUrlRoute = ({ +export const rotateConnectorIngressUrlRoute = ({ router, getStartServices, getPublicBaseUrl, @@ -30,7 +31,7 @@ export const rotateInboundWebhookUrlRoute = ({ }): void => { router.post( { - path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/inbound_webhook/_rotate_url`, + path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/connector_ingress/_rotate_url`, security: { authz: { enabled: false, @@ -41,6 +42,7 @@ export const rotateInboundWebhookUrlRoute = ({ validate: { body: schema.object({ connectorId: schema.string({ minLength: 1, maxLength: CONNECTOR_ID_MAX_LENGTH }), + connectorTypeId: schema.string({ minLength: 1 }), }), }, options: { @@ -48,7 +50,7 @@ export const rotateInboundWebhookUrlRoute = ({ }, }, async (_ctx, req, res) => { - const { connectorId } = req.body; + const { connectorId, connectorTypeId: rawConnectorTypeId } = req.body; try { validateConnectorId(connectorId); @@ -58,15 +60,24 @@ export const rotateInboundWebhookUrlRoute = ({ }); } + const connectorTypeId = normalizeConnectorTypeId(rawConnectorTypeId); + const spec = getConnectorSpec(connectorTypeId); + if (!spec?.events) { + return res.badRequest({ + body: `Connector type "${connectorTypeId}" does not support ingress events`, + }); + } + try { const [, { actions }] = await getStartServices(); await actions.getActionsAuthorizationWithRequest(req).ensureAuthorized({ operation: 'create', - actionTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + actionTypeId: connectorTypeId, }); return res.ok({ - body: mintInboundWebhookIngressCredentials({ + body: mintConnectorIngressCredentials({ + connectorTypeId, connectorId, spaceId: getSpaceId(req), publicBaseUrl: getPublicBaseUrl(), @@ -79,10 +90,10 @@ export const rotateInboundWebhookUrlRoute = ({ body: { message: error.message }, }); } - logger.error(`Failed to rotate inbound webhook URL: ${error}`); + logger.error(`Failed to rotate connector ingress URL: ${error}`); return res.customError({ statusCode: 500, - body: { message: 'Failed to rotate inbound webhook URL' }, + body: { message: 'Failed to rotate connector ingress URL' }, }); } } diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx index 2bfba461e3208..0fa1eb0877956 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.test.tsx @@ -18,6 +18,26 @@ import { createAppMockRenderer } from '../../test_utils'; import { TECH_PREVIEW_LABEL } from '../../translations'; import { createMockActionConnector } from '@kbn/alerts-ui-shared/src/common/test_utils/connector.mock'; +jest.mock('../../../lib/action_connector_api', () => ({ + ...(jest.requireActual('../../../lib/action_connector_api') as object), + loadActionTypes: jest.fn(), +})); + +const { loadActionTypes } = jest.requireMock('../../../lib/action_connector_api'); + +const stackConnectorActionType = { + id: '.test', + name: 'Test', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: [], + minimumLicenseRequired: 'basic' as const, + isSystemActionType: false, + isDeprecated: false, + source: 'stack' as const, +}; + const updateConnectorResponse = { connector_type_id: 'test', is_preconfigured: false, @@ -61,6 +81,7 @@ describe('EditConnectorFlyout', () => { jest.clearAllMocks(); actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(actionTypeModel); + loadActionTypes.mockResolvedValue([stackConnectorActionType]); appMockRenderer = createAppMockRenderer(); appMockRenderer.coreStart.application.capabilities = { ...appMockRenderer.coreStart.application.capabilities, @@ -789,6 +810,15 @@ describe('is spec connector', () => { const onClose = jest.fn(); const onConnectorUpdated = jest.fn(); + const inboundWebhookConnector: ActionConnector = createMockActionConnector({ + id: 'inbound-webhook-1', + name: 'testing', + actionTypeId: '.inboundWebhook', + config: { webhookUrl: 'https://example.com/webhook' }, + secrets: {}, + authMode: 'shared', + }); + const actionTypeModel = actionTypeRegistryMock.createMockActionTypeModel({ actionConnectorFields: lazy(() => import('../connector_mock')), validateParams: (): Promise> => { @@ -801,10 +831,23 @@ describe('is spec connector', () => { beforeEach(() => { jest.clearAllMocks(); - // Spec connectors are not registered client-side; the spec fetch path is irrelevant - // for this test since it only asserts tab rendering, which is gated on registry lookup. - actionTypeRegistry.has.mockReturnValue(false); + actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(actionTypeModel); + loadActionTypes.mockResolvedValue([ + { + id: '.inboundWebhook', + name: 'Inbound Webhook', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['workflows'], + minimumLicenseRequired: 'basic' as const, + isSystemActionType: false, + isDeprecated: false, + source: 'spec' as const, + isTestable: false, + }, + ]); appMockRenderer = createAppMockRenderer(); appMockRenderer.coreStart.application.capabilities = { ...appMockRenderer.coreStart.application.capabilities, @@ -814,17 +857,19 @@ describe('is spec connector', () => { appMockRenderer.coreStart.http.post = jest.fn().mockResolvedValue(executeConnectorResponse); }); - it('should not render the test tab', async () => { + it('should not render the test tab for non-testable spec connectors', async () => { const { getByTestId } = appMockRenderer.render( ); expect(getByTestId('configureConnectorTab')).toBeInTheDocument(); - expect(screen.queryByTestId('testConnectorTab')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId('testConnectorTab')).not.toBeInTheDocument(); + }); }); }); diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx index 464709123792f..cf138b7056748 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx @@ -25,6 +25,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; import { isActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import { ACTION_TYPE_SOURCES } from '@kbn/actions-types'; import type { Option } from 'fp-ts/Option'; import { none, some } from 'fp-ts/Option'; import type { ConnectorFormSchema } from '@kbn/alerts-ui-shared'; @@ -32,6 +33,7 @@ import { useActionTypeModel } from '@kbn/alerts-ui-shared/src/common/hooks/use_a import { ReadOnlyConnectorMessage } from './read_only'; import type { ActionConnector, + ActionType, ActionTypeRegistryContract, UserConfiguredActionConnector, } from '../../../../types'; @@ -46,6 +48,19 @@ import { ConnectorRulesList } from '../connector_rules_list'; import { useExecuteConnector } from '../../../hooks/use_execute_connector'; import { FlyoutHeader } from './header'; import { FlyoutFooter } from './footer'; +import { loadActionTypes } from '../../../lib/action_connector_api'; + +const isConnectorTypeTestable = (actionType?: ActionType): boolean => { + if (!actionType) { + return false; + } + + if (actionType.source === ACTION_TYPE_SOURCES.spec) { + return Boolean(actionType.isTestable); + } + + return !actionType.source || actionType.source === ACTION_TYPE_SOURCES.stack; +}; export interface EditConnectorFlyoutProps { actionTypeRegistry: ActionTypeRegistryContract; @@ -105,6 +120,46 @@ const EditConnectorFlyoutComponent: React.FC = ({ }); const [selectedTab, setTab] = useState(tab); + const [connectorActionType, setConnectorActionType] = useState(); + + useEffect(() => { + let cancelled = false; + + void loadActionTypes({ http }).then((actionTypes) => { + if (cancelled) { + return; + } + + setConnectorActionType(actionTypes.find((type) => type.id === connector.actionTypeId)); + }); + + return () => { + cancelled = true; + }; + }, [connector.actionTypeId, http]); + + const isTestable = useMemo(() => { + if (isTestableProp !== undefined) { + return isTestableProp; + } + + if (connectorActionType === undefined) { + return actionTypeRegistry.has(connector.actionTypeId); + } + + return isConnectorTypeTestable(connectorActionType); + }, [actionTypeRegistry, connector.actionTypeId, connectorActionType, isTestableProp]); + + useEffect(() => { + if (connectorActionType === undefined) { + return; + } + + if (!isTestable && selectedTab === EditConnectorTabs.Test) { + setTab(EditConnectorTabs.Configuration); + } + }, [connectorActionType, isTestable, selectedTab]); + /** * Test connector */ @@ -458,8 +513,6 @@ const EditConnectorFlyoutComponent: React.FC = ({ return actionTypeModel?.isExperimental; }, [actionTypeModel, connector]); - const isTestable = isTestableProp ?? actionTypeRegistry.has(connector.actionTypeId); - return ( <> Date: Thu, 16 Jul 2026 09:50:47 +0200 Subject: [PATCH 17/19] don't show webhook url again, just rotate button on edit --- .../inbound_webhook_connector_fields.tsx | 183 +++++++++++++++--- 1 file changed, 152 insertions(+), 31 deletions(-) diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx index b3749bed6b55c..0b9e36a6da767 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx @@ -8,13 +8,16 @@ import { EuiButton, EuiCallOut, + EuiConfirmModal, EuiCopy, EuiFieldText, EuiFormRow, EuiLoadingSpinner, EuiSpacer, + EuiText, + useGeneratedHtmlId, } from '@elastic/eui'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { UseField, @@ -42,44 +45,23 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec const webhookUrl = typeof config?.webhookUrl === 'string' ? config.webhookUrl : undefined; const [isRotating, setIsRotating] = useState(false); const [rotateError, setRotateError] = useState(); + const [rotateSuccess, setRotateSuccess] = useState(false); + const [showRotateConfirm, setShowRotateConfirm] = useState(false); const requestIdRef = useRef(0); + const rotateConfirmTitleId = useGeneratedHtmlId(); useEffect(() => { setFieldValue('secrets.authType', 'none'); }, [setFieldValue]); - useEffect(() => { - if (isEdit || readOnly) { - return; - } - - const connectorId = typeof id === 'string' ? id.trim() : ''; - if (!connectorId || !CONNECTOR_ID_PATTERN.test(connectorId)) { - return; - } - - if ( - webhookUrl?.includes( - `${buildConnectorIngressEventsPath({ - connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, - connectorId, - })}?` - ) - ) { - return; - } - - if (webhookUrl) { - setFieldValue('config.webhookUrl', undefined); - setFieldValue('config.ingestTokenHash', undefined); - } - - const timer = window.setTimeout(() => { + const mintIngressUrl = useCallback( + (connectorId: string) => { const requestId = ++requestIdRef.current; setIsRotating(true); setRotateError(undefined); + setRotateSuccess(false); - void rotateConnectorIngressUrl({ + return rotateConnectorIngressUrl({ http, connectorId, connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, @@ -90,6 +72,9 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec } setFieldValue('config.webhookUrl', credentials.webhookUrl); setFieldValue('config.ingestTokenHash', credentials.ingestTokenHash); + if (isEdit) { + setRotateSuccess(true); + } }) .catch(() => { if (requestId !== requestIdRef.current) { @@ -107,18 +92,154 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec setIsRotating(false); } }); + }, + [http, isEdit, setFieldValue] + ); + + useEffect(() => { + if (isEdit || readOnly) { + return; + } + + const connectorId = typeof id === 'string' ? id.trim() : ''; + if (!connectorId || !CONNECTOR_ID_PATTERN.test(connectorId)) { + return; + } + + if ( + webhookUrl?.includes( + `${buildConnectorIngressEventsPath({ + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + connectorId, + })}?` + ) + ) { + return; + } + + if (webhookUrl) { + setFieldValue('config.webhookUrl', undefined); + setFieldValue('config.ingestTokenHash', undefined); + } + + const timer = window.setTimeout(() => { + void mintIngressUrl(connectorId); }, CONNECTOR_ID_SETTLE_MS); return () => { window.clearTimeout(timer); }; - }, [http, id, isEdit, readOnly, setFieldValue, webhookUrl]); + }, [id, isEdit, mintIngressUrl, readOnly, setFieldValue, webhookUrl]); - return ( + const handleConfirmRotate = useCallback(() => { + setShowRotateConfirm(false); + const connectorId = typeof id === 'string' ? id.trim() : ''; + if (!connectorId) { + return; + } + void mintIngressUrl(connectorId); + }, [id, mintIngressUrl]); + + const hiddenFields = ( <> + + ); + + if (isEdit) { + return ( + <> + {hiddenFields} + + +

+ +

+
+
+ {rotateSuccess ? ( + <> + + + + ) : null} + {rotateError ? ( + <> + + + + ) : null} + + setShowRotateConfirm(true)} + disabled={readOnly || isRotating} + isLoading={isRotating} + data-test-subj="rotateInboundWebhookUrl" + > + + + {showRotateConfirm ? ( + setShowRotateConfirm(false)} + onConfirm={handleConfirmRotate} + cancelButtonText={i18n.translate('stackConnectors.inboundWebhook.rotateUrlCancel', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('stackConnectors.inboundWebhook.rotateUrlConfirm', { + defaultMessage: 'Rotate URL', + })} + buttonColor="danger" + data-test-subj="rotateInboundWebhookUrlConfirm" + > +

+ +

+
+ ) : null} + + ); + } + + return ( + <> + {hiddenFields} {webhookUrl ? ( <> Date: Thu, 16 Jul 2026 10:00:26 +0200 Subject: [PATCH 18/19] Rotating url --- .../inbound_webhook_connector_fields.tsx | 182 +++++++++--------- .../ensure_connector_ingress_credentials.ts | 5 + .../server/connector_types_from_spec/index.ts | 2 +- ...register_inbound_webhook_connector_type.ts | 4 +- ..._connector_ingress_public_base_url.test.ts | 73 +++++++ ...solve_connector_ingress_public_base_url.ts | 48 +++++ .../shared/stack_connectors/server/plugin.ts | 9 +- .../routes/rotate_connector_ingress_url.ts | 4 +- 8 files changed, 235 insertions(+), 92 deletions(-) create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.test.ts create mode 100644 x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.ts diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx index 0b9e36a6da767..0eff418e62659 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx @@ -140,6 +140,36 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec void mintIngressUrl(connectorId); }, [id, mintIngressUrl]); + const renderWebhookUrlField = (url: string) => ( + <> + + + + + + {(copy) => ( + + + + )} + + + ); + const hiddenFields = ( <> @@ -149,26 +179,14 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec ); if (isEdit) { + const showRotatedUrl = rotateSuccess && Boolean(webhookUrl); + return ( <> {hiddenFields} - - -

- -

-
-
- {rotateSuccess ? ( + {showRotatedUrl && webhookUrl ? ( <> + {renderWebhookUrlField(webhookUrl)} - ) : null} + ) : ( + + +

+ +

+
+
+ )} {rotateError ? ( <> @@ -193,45 +227,52 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec /> ) : null} - - setShowRotateConfirm(true)} - disabled={readOnly || isRotating} - isLoading={isRotating} - data-test-subj="rotateInboundWebhookUrl" - > - - - {showRotateConfirm ? ( - setShowRotateConfirm(false)} - onConfirm={handleConfirmRotate} - cancelButtonText={i18n.translate('stackConnectors.inboundWebhook.rotateUrlCancel', { - defaultMessage: 'Cancel', - })} - confirmButtonText={i18n.translate('stackConnectors.inboundWebhook.rotateUrlConfirm', { - defaultMessage: 'Rotate URL', - })} - buttonColor="danger" - data-test-subj="rotateInboundWebhookUrlConfirm" - > -

+ {!showRotatedUrl ? ( + <> + + setShowRotateConfirm(true)} + disabled={readOnly || isRotating} + isLoading={isRotating} + data-test-subj="rotateInboundWebhookUrl" + > -

-
+
+ {showRotateConfirm ? ( + setShowRotateConfirm(false)} + onConfirm={handleConfirmRotate} + cancelButtonText={i18n.translate('stackConnectors.inboundWebhook.rotateUrlCancel', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate( + 'stackConnectors.inboundWebhook.rotateUrlConfirm', + { + defaultMessage: 'Rotate URL', + } + )} + buttonColor="danger" + data-test-subj="rotateInboundWebhookUrlConfirm" + > +

+ +

+
+ ) : null} + ) : null} ); @@ -241,38 +282,7 @@ export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnec <> {hiddenFields} {webhookUrl ? ( - <> - - - - - - {(copy) => ( - - - - )} - - + renderWebhookUrlField(webhookUrl) ) : ( { const base = publicBaseUrl.replace(/\/$/, ''); + if (!base || !/^https?:\/\//i.test(base)) { + throw new Error( + 'Cannot mint connector ingress URL without an absolute Kibana public base URL. Configure server.publicBaseUrl.' + ); + } const spacePrefix = spaceId !== 'default' ? `/s/${encodeURIComponent(spaceId)}` : ''; const ingressPath = buildConnectorIngressEventsPath({ connectorTypeId, connectorId }); return `${base}${spacePrefix}${ingressPath}?token=${token}`; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts index 2c73496a2a7b3..afa16adfa35c9 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/index.ts @@ -23,7 +23,7 @@ export function registerConnectorTypesFromSpecs({ }: { actions: ActionsPluginSetupContract; getSpaceId: (request: KibanaRequest) => string; - getPublicBaseUrl: () => string; + getPublicBaseUrl: (request: KibanaRequest) => string; getSecurity: () => Promise; logger: Logger; }) { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts index d70fdd147fe36..439aefced69be 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts @@ -36,7 +36,7 @@ export function registerInboundWebhookConnectorType({ }: { actions: ActionsPluginSetupContract; getSpaceId: (request: KibanaRequest) => string; - getPublicBaseUrl: () => string; + getPublicBaseUrl: (request: KibanaRequest) => string; getSecurity: () => Promise; logger: Logger; }): void { @@ -63,7 +63,7 @@ export function registerInboundWebhookConnectorType({ connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, connectorId, spaceId, - publicBaseUrl: getPublicBaseUrl(), + publicBaseUrl: getPublicBaseUrl(request), isUpdate, logger, }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.test.ts new file mode 100644 index 0000000000000..24914424e4399 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServerMock } from '@kbn/core/server/mocks'; + +import { resolveConnectorIngressPublicBaseUrl } from './resolve_connector_ingress_public_base_url'; + +describe('resolveConnectorIngressPublicBaseUrl', () => { + const serverInfo = { + protocol: 'http', + hostname: '0.0.0.0', + port: 5601, + }; + + it('prefers configured publicBaseUrl', () => { + const request = httpServerMock.createKibanaRequest({ + headers: { host: 'ignored.example:5601' }, + }); + + expect( + resolveConnectorIngressPublicBaseUrl({ + publicBaseUrl: 'https://kibana.example.com/kb/', + serverBasePath: '/kb', + request, + serverInfo, + }) + ).toBe('https://kibana.example.com/kb'); + }); + + it('falls back to request host when publicBaseUrl is unset', () => { + const request = httpServerMock.createKibanaRequest({ + headers: { host: 'localhost:5601', 'x-forwarded-proto': 'https' }, + }); + + expect( + resolveConnectorIngressPublicBaseUrl({ + serverBasePath: '', + request, + serverInfo, + }) + ).toBe('https://localhost:5601'); + }); + + it('appends serverBasePath when deriving from the request host', () => { + const request = httpServerMock.createKibanaRequest({ + headers: { host: 'localhost:5601' }, + }); + + expect( + resolveConnectorIngressPublicBaseUrl({ + serverBasePath: '/kb', + request, + serverInfo, + }) + ).toBe('http://localhost:5601/kb'); + }); + + it('falls back to server listen address when host header is missing', () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + expect( + resolveConnectorIngressPublicBaseUrl({ + serverBasePath: '', + request, + serverInfo: { protocol: 'http', hostname: '127.0.0.1', port: 5601 }, + }) + ).toBe('http://127.0.0.1:5601'); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.ts new file mode 100644 index 0000000000000..49254cc45b4a1 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/resolve_connector_ingress_public_base_url.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core/server'; + +export interface ResolveConnectorIngressPublicBaseUrlParams { + /** Prefer `server.publicBaseUrl` when configured (already includes server base path). */ + publicBaseUrl?: string; + serverBasePath: string; + request: KibanaRequest; + serverInfo: { + protocol: string; + hostname: string; + port: number; + }; +} + +/** + * Absolute Kibana origin used when minting connector ingress URLs. + * Prefers `server.publicBaseUrl`, then the request Host header, then listen address. + */ +export const resolveConnectorIngressPublicBaseUrl = ({ + publicBaseUrl, + serverBasePath, + request, + serverInfo, +}: ResolveConnectorIngressPublicBaseUrlParams): string => { + if (publicBaseUrl) { + return publicBaseUrl.replace(/\/$/, ''); + } + + const rawProto = request.headers['x-forwarded-proto']; + const forwardedProto = Array.isArray(rawProto) + ? rawProto[rawProto.length - 1] + : rawProto; + const host = typeof request.headers.host === 'string' ? request.headers.host : undefined; + + if (host) { + return `${forwardedProto ?? 'http'}://${host}${serverBasePath}`; + } + + const { protocol, hostname, port } = serverInfo; + return `${protocol}://${hostname}:${port}${serverBasePath}`; +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 9aca70d8235ec..e9ec6552a421d 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts @@ -35,6 +35,7 @@ import type { ExperimentalFeatures } from '../common/experimental_features'; import { parseExperimentalConfigValue } from '../common/experimental_features'; import type { ConfigSchema as StackConnectorsConfigType } from './config'; import { registerConnectorTypesFromSpecs } from './connector_types_from_spec'; +import { resolveConnectorIngressPublicBaseUrl } from './connector_types_from_spec/resolve_connector_ingress_public_base_url'; import { registerConnectorEventTriggers } from './connector_events/register_connector_event_triggers'; export interface ConnectorsPluginsSetup { @@ -109,7 +110,13 @@ export class StackConnectorsPlugin if (this.experimentalFeatures.connectorsFromSpecs) { const getSpaceId = (request: KibanaRequest) => plugins.spaces?.spacesService.getSpaceId(request) ?? 'default'; - const getPublicBaseUrl = () => core.http.basePath.publicBaseUrl ?? ''; + const getPublicBaseUrl = (request: KibanaRequest) => + resolveConnectorIngressPublicBaseUrl({ + publicBaseUrl: core.http.basePath.publicBaseUrl, + serverBasePath: core.http.basePath.serverBasePath, + request, + serverInfo: core.http.getServerInfo(), + }); registerConnectorTypesFromSpecs({ actions, diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts index ebfb96d21167f..dd656a5d8b7bd 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts @@ -25,7 +25,7 @@ export const rotateConnectorIngressUrlRoute = ({ }: { router: IRouter; getStartServices: StartServicesAccessor; - getPublicBaseUrl: () => string; + getPublicBaseUrl: (request: KibanaRequest) => string; getSpaceId: (request: KibanaRequest) => string; logger: Logger; }): void => { @@ -80,7 +80,7 @@ export const rotateConnectorIngressUrlRoute = ({ connectorTypeId, connectorId, spaceId: getSpaceId(req), - publicBaseUrl: getPublicBaseUrl(), + publicBaseUrl: getPublicBaseUrl(req), }), }); } catch (error) { From f94ffeedbcd4f9a3230b32a00f200dee76fcf053 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Thu, 16 Jul 2026 17:04:12 +0200 Subject: [PATCH 19/19] fixes --- ...map_registered_triggers_for_schema.test.ts | 67 +++++++++++++++++++ .../lib/map_registered_triggers_for_schema.ts | 4 +- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.test.ts diff --git a/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.test.ts b/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.test.ts new file mode 100644 index 0000000000000..d9bffad5cb7ff --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getTriggerSchema } from '@kbn/workflows/spec/schema/triggers'; +import { collectConnectorEventsForTriggerSchema } from '@kbn/workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema'; + +import { mapRegisteredTriggersForSchema } from './map_registered_triggers_for_schema'; + +describe('mapRegisteredTriggersForSchema', () => { + it('does not require connector-id for extension triggers without explicit connector binding', () => { + const registeredTriggers = mapRegisteredTriggersForSchema([ + { + id: 'cases.caseUpdated', + title: 'Cases - Case updated', + description: 'Emitted when a case is updated.', + stability: 'tech_preview', + }, + ]); + + expect(registeredTriggers).toEqual([ + { + id: 'cases.caseUpdated', + title: 'Cases - Case updated', + description: 'Emitted when a case is updated.', + stability: 'tech_preview', + requiresConnectorId: false, + }, + ]); + + const { connectorEvents, customTriggerIds } = collectConnectorEventsForTriggerSchema( + {}, + registeredTriggers + ); + + expect(connectorEvents).toEqual([]); + expect(customTriggerIds).toEqual(['cases.caseUpdated']); + + const triggerSchema = getTriggerSchema(customTriggerIds, connectorEvents); + + expect( + triggerSchema.safeParse({ + type: 'cases.caseUpdated', + on: { condition: 'event.caseId: "abc"' }, + }).success + ).toBe(true); + }); + + it('requires connector-id only when explicitly flagged', () => { + const registeredTriggers = mapRegisteredTriggersForSchema([ + { + id: 'inboundWebhook.received', + title: 'Webhook received', + description: 'Fires when an inbound webhook request is received', + stability: 'tech_preview', + requiresConnectorId: true, + }, + ]); + + expect(registeredTriggers[0]?.requiresConnectorId).toBe(true); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts b/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts index 18a5342051766..a7866e9029b67 100644 --- a/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_schema.ts @@ -23,7 +23,5 @@ export const mapRegisteredTriggersForSchema = ( title: trigger.title, description: trigger.description, stability: trigger.stability, - requiresConnectorId: - trigger.requiresConnectorId ?? - ('eventSchema' in trigger && trigger.eventSchema !== undefined), + requiresConnectorId: trigger.requiresConnectorId === true, }));