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-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 f53f34a8f6fc7..6cea629822ad6 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -7,12 +7,12 @@ * 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'; export { buildConnectorEventId, + buildConnectorIngressEventsPath, connectorTypeToEventNamespace, normalizeConnectorTypeId, } from './src/connector_event_type_id'; @@ -30,14 +30,19 @@ 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 { + 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/server/index.ts b/src/platform/packages/shared/kbn-connector-specs/server/index.ts new file mode 100644 index 0000000000000..79ef95a6a1edd --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/server/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { + listConnectorEventInfosForType, + type ConnectorEventInfo, +} from '../src/list_connector_event_infos'; +export { resolveRegisteredConnectorEventByEventId } from '../src/resolve_registered_connector_event_by_event_id'; 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/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/inbound_webhook/compute_ingest_token_hash.ts b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/compute_ingest_token_hash.ts new file mode 100644 index 0000000000000..5f3487fb0795a --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/inbound_webhook/compute_ingest_token_hash.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". + */ + +// eslint-disable-next-line import/no-nodejs-modules -- server-side ingress credential hashing +import { createHash } from 'node:crypto'; + +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/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/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/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 new file mode 100644 index 0000000000000..6d318389acada --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { INBOUND_WEBHOOK_RECEIVED_EVENT_ID } from '@kbn/connector-specs'; +import { InboundWebhookConnector } from './inbound_webhook'; + +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..c58db299791dc --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/inbound_webhook/inbound_webhook.ts @@ -0,0 +1,158 @@ +/* + * 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', + }), + }), + webhookUrl: z + .string() + .url() + .optional() + .meta({ + hidden: true, + label: i18n.translate('core.kibanaConnectorSpecs.inboundWebhook.config.webhookUrl', { + 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', + } + ), + }), + }) + ), + + 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/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..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 './inbound_webhook_received_event_schema'; +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/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/packages/shared/kbn-workflows/index.ts b/src/platform/packages/shared/kbn-workflows/index.ts index ba1ddb8aa047c..2631d61682cd2 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'; @@ -85,6 +86,11 @@ export { ManualTriggerSchema, TriggerSchema, getTriggerSchema, + getTriggerSchemaFromConnectorEvents, + collectConnectorEventsFromTypes, + collectConnectorEventsForTriggerSchema, + type RegisteredTriggerForSchema, + type RegisteredTriggerSchemaArg, TriggerTypes, WORKFLOW_EVENTS_VALUES_SET, WorkflowEventsSchema, 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/server/index.ts b/src/platform/packages/shared/kbn-workflows/server/index.ts index 37bce49e385b5..8863ef8bd197f 100644 --- a/src/platform/packages/shared/kbn-workflows/server/index.ts +++ b/src/platform/packages/shared/kbn-workflows/server/index.ts @@ -38,6 +38,8 @@ 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 type { GetManagedWorkflowStatusOptions, ManagedWorkflowStatus, 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_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..0b2d340cd346e --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/spec/schema/triggers/collect_connector_events_for_trigger_schema.ts @@ -0,0 +1,68 @@ +/* + * 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 } 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) && 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/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..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 @@ -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,22 @@ 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 { + collectConnectorEventsForTriggerSchema, + type RegisteredTriggerForSchema, + type RegisteredTriggerSchemaArg, +} from './collect_connector_events_for_trigger_schema'; export const TriggerSchema = z.discriminatedUnion('type', [ AlertRuleTriggerSchema, @@ -28,46 +47,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/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/plugins/shared/workflows_extensions/public/eui_icons.d.ts b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts similarity index 59% rename from src/platform/plugins/shared/workflows_extensions/public/eui_icons.d.ts rename to src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.ts index c9eb3f08df2ab..2a35a007273cb 100644 --- a/src/platform/plugins/shared/workflows_extensions/public/eui_icons.d.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/workflow_surface/index.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'; - - interface SVGRProps { - title?: string; - titleId?: string; - } - export const icon: ({ - title, - titleId, - ...props - }: React.SVGProps & SVGRProps) => React.JSX.Element; - export {}; -} +export type { + InstanceRef, + WorkflowSurfaceBinding, + WorkflowSurfaceDefinition, + WorkflowSurfaceFilter, + WorkflowSurfaceKind, + WorkflowSurfaceSource, +} from './types'; +export { + connectorEventToTriggerDefinition, + type ConnectorEventTriggerDefinition, +} from './connector_event_to_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..68ca047f8422a --- /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/server', () => { + const actual = jest.requireActual('@kbn/connector-specs/server'); + 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..c9952941204b9 --- /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/server'; +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/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/**/*"] } 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/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 new file mode 100644 index 0000000000000..511d33f002734 --- /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/server', () => ({ + ...jest.requireActual('@kbn/workflows/server'), + 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..14b51016b8fd5 --- /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/server'; + +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..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,9 +14,16 @@ import { workflowMatchesTriggerCondition, } from './filter_workflows_by_trigger_condition'; +jest.mock('@kbn/workflows/server', () => ({ + ...jest.requireActual('@kbn/workflows/server'), + 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 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..22677e4430c35 --- /dev/null +++ b/src/platform/plugins/shared/workflows_execution_engine/server/trigger_events/resolve_trigger_definition.test.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", 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/server'; +import { resolveTriggerDefinition } from './resolve_trigger_definition'; + +jest.mock('@kbn/workflows/server', () => ({ + ...jest.requireActual('@kbn/workflows/server'), + 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..1ffc03f159d18 --- /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/server'; +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_extensions/common/trigger_registry/types.ts b/src/platform/plugins/shared/workflows_extensions/common/trigger_registry/types.ts index ab93ec35e455a..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 @@ -71,6 +71,10 @@ export interface CommonTriggerDefinition { 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.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 new file mode 100644 index 0000000000000..a7866e9029b67 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/common/lib/map_registered_triggers_for_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 type { RegisteredTriggerForSchema } from '@kbn/workflows'; + +export const mapRegisteredTriggersForSchema = ( + triggers: Array<{ + id: string; + title: string; + description: string; + stability: RegisteredTriggerForSchema['stability']; + requiresConnectorId?: boolean; + }> +): RegisteredTriggerForSchema[] => + triggers.map((trigger) => ({ + id: trigger.id, + title: trigger.title, + description: trigger.description, + stability: trigger.stability, + requiresConnectorId: trigger.requiresConnectorId === true, + })); diff --git a/src/platform/plugins/shared/workflows_management/common/schema.ts b/src/platform/plugins/shared/workflows_management/common/schema.ts index f02a9dfecbf24..60b999f58c8f4 100644 --- a/src/platform/plugins/shared/workflows_management/common/schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/schema.ts @@ -11,11 +11,13 @@ import type { BaseConnectorContract, ConnectorContractUnion, ConnectorTypeInfo, + RegisteredTriggerSchemaArg, StepDeprecationInfo, StepPropertyHandler, } from '@kbn/workflows'; import { builtInStepDefinitions, + collectConnectorEventsForTriggerSchema, DEPRECATED_STEP_METADATA, generateLightweightYamlSchema, generateYamlSchemaFromConnectors, @@ -459,17 +461,24 @@ export function getAllConnectorsWithDynamic( return getAllConnectorsWithDynamicInternal(dynamicConnectorTypes); } +const getWorkflowTriggerSchemaInput = ( + dynamicConnectorTypes: Record, + registeredTriggers: RegisteredTriggerSchemaArg[] = [] +) => collectConnectorEventsForTriggerSchema(dynamicConnectorTypes, registeredTriggers); + export const getWorkflowZodSchema = ( dynamicConnectorTypes: Record, - registeredTriggerIds: string[] = [], + registeredTriggers: RegisteredTriggerSchemaArg[] = [], options: WorkflowZodSchemaOptions = {} ): z.ZodType => { + const triggerInput = getWorkflowTriggerSchemaInput(dynamicConnectorTypes, registeredTriggers); + if (options.lightweight) { - return generateLightweightYamlSchema(registeredTriggerIds); + return generateLightweightYamlSchema(triggerInput); } const allConnectors = getAllConnectorsWithDynamicInternal(dynamicConnectorTypes); - return generateYamlSchemaFromConnectors(allConnectors, registeredTriggerIds); + return generateYamlSchemaFromConnectors(allConnectors, triggerInput); }; export const getWorkflowZodSchemaLoose = ( 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/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/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/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/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/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/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/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..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 @@ -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,78 @@ 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: {}, + source: { + type: 'connector-event', + connectorTypeId: '.inboundWebhook', + eventKey: 'received', + }, + }, + }); + + 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..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 @@ -8,7 +8,7 @@ */ import { getConnectorIdSuggestionsItems } from './get_connector_id_suggestions_items'; -import { resolveConnectorIdStepType } from './resolve_connector_id_step_type'; +import { resolveConnectorIdBinding } from '../../../../../../workflow_surface/connector_id_provider'; import type { AutocompleteContext } from '../../context/autocomplete.types'; export function getConnectorIdSuggestions({ @@ -18,18 +18,26 @@ export function getConnectorIdSuggestions({ focusedStepInfo, focusedYamlPair, path, + yamlDocument, dynamicConnectorTypes, }: AutocompleteContext) { - const stepConnectorType = resolveConnectorIdStepType(focusedStepInfo, path, focusedYamlPair); + const binding = resolveConnectorIdBinding({ + yamlDocument, + path, + focusedStepInfo, + focusedYamlPair, + connectorTypes: dynamicConnectorTypes ?? undefined, + }); if ( - !stepConnectorType || + !binding || !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 +45,8 @@ export function getConnectorIdSuggestions({ startColumn: lineParseResult.valueStartIndex + 1, endColumn: line.length + 1, }; - return getConnectorIdSuggestionsItems(stepConnectorType, replaceRange, dynamicConnectorTypes); + return getConnectorIdSuggestionsItems(binding, replaceRange, dynamicConnectorTypes); } - return getConnectorIdSuggestionsItems(stepConnectorType, 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..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 @@ -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 instances for connector-event surfaces when API omits 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.some((item) => item.insertText === 'sales-ingress')).toBe(true); + }); }); 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/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); 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..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 @@ -7,9 +7,25 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +jest.mock('../../../../trigger_schemas', () => ({ + triggerSchemas: { + getTriggerDefinition: jest.fn(), + }, +})); + +jest.mock('../../../../../common/lib/is_connector_event_trigger_id', () => ({ + isConnectorEventTriggerId: jest.fn(() => false), +})); + 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); + }); 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 +55,56 @@ describe('generateTriggerSnippet', () => { expect(snippet).not.toContain('event.source:ui'); }); }); + + describe('connector-event triggers', () => { + it('includes connector-id placeholder when the trigger requires connector binding', () => { + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ + id: 'exampleInboundWebhook.received', + requiresConnectorId: true, + snippets: { + condition: 'event.body: *', + }, + }); + + const snippet = generateTriggerSnippet('exampleInboundWebhook.received', { full: true }); + + expect(snippet).toContain('connector-id: '); + expect(snippet).toContain('condition: "event.body: *"'); + }); + + it('uses an explicit defaultConnectorId when provided', () => { + (triggerSchemas.getTriggerDefinition as jest.Mock).mockReturnValue({ + id: 'exampleInboundWebhook.received', + requiresConnectorId: true, + }); + + const snippet = generateTriggerSnippet('exampleInboundWebhook.received', { + full: true, + defaultConnectorId: 'example-inbound-webhook', + }); + + expect(snippet).toContain('connector-id: example-inbound-webhook'); + }); + + 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 }); + + 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 d95874c5d74ae..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 @@ -10,6 +10,8 @@ import type { ToStringOptions } from 'yaml'; import { stringify } from 'yaml'; 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. */ const CUSTOM_TRIGGER_CONDITION_COMMENT = @@ -21,6 +23,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 +43,19 @@ 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 requiresConnectorId = + triggerDefinition?.requiresConnectorId === true || isConnectorEventTriggerId(triggerType); + const resolvedConnectorId = + defaultConnectorId ?? (monacoSuggestionFormat ? '${1:}' : ''); + const resolvedCondition = defaultCondition ?? triggerDefinition?.snippets?.condition ?? ''; + switch (triggerType) { case 'alert': parameters = {}; @@ -80,9 +92,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..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 @@ -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, + 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'; @@ -649,7 +653,11 @@ export const WorkflowYAMLEditor = ({ if (!model || !editor) { return; } - if (isTriggerType(action.id) || triggerSchemas.isRegisteredTriggerId(action.id)) { + if ( + isTriggerType(action.id) || + triggerSchemas.isRegisteredTriggerId(action.id) || + isConnectorEventTriggerId(action.id) + ) { const triggerDefinition = triggerSchemas.getTriggerDefinition(action.id); insertTriggerSnippet( model, 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..bb98fc5864192 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.test.ts @@ -0,0 +1,173 @@ +/* + * 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 instances for connector-event surfaces when API omits events[] but type exists', () => { + 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.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', () => { + 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..39b92fa907f5a --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_binding.ts @@ -0,0 +1,109 @@ +/* + * 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 { + const connectorTypeInfo = connectorTypes[binding.connectorTypeId]; + if (binding.requireConnectorTypeEvents && !connectorTypeInfo) { + 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 new file mode 100644 index 0000000000000..1b41ba077622c --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.test.ts @@ -0,0 +1,94 @@ +/* + * 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, 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' + ? { + 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 + ), +})); + +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('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 + 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..75968383e3174 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/connector_id_provider.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 { Document } from 'yaml'; +import type { ConnectorTypeInfo, 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'; + +export interface ConnectorIdProviderContext { + readonly yamlDocument: Document; + readonly path: ReadonlyArray; + readonly focusedStepInfo: StepInfo | null; + readonly focusedYamlPair: StepPropInfo | null; + readonly connectorTypes?: Record; +} + +/** + * Resolves the workflow surface for connector-id autocomplete at the cursor path. + */ +export const resolveConnectorIdSurface = ( + yamlDocument: Document, + path: ReadonlyArray, + focusedStepInfo: StepInfo | null = null, + focusedYamlPair: StepPropInfo | null = null, + connectorTypes: Record = {} +): WorkflowSurfaceDefinition | undefined => + resolveSurfaceAtPath(yamlDocument, [...path], { + focusedStepInfo, + focusedYamlPair, + connectorTypes, + })?.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, + context.connectorTypes + ); + if (surface) { + const binding = resolveConnectorIdBindingFromSurface(surface); + if (binding) { + return binding; + } + } + + const stepType = resolveConnectorIdStepType( + context.focusedStepInfo, + context.path, + context.focusedYamlPair + ); + if (!stepType) { + return undefined; + } + + return resolveConnectorIdBindingFromStepType(stepType); +}; + +/** + * @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/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..0e95cf2b64ec6 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.test.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 { 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(), +}); + +describe('getTriggerConditionDefinition', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns connector-event trigger definitions with eventSchema for KQL autocomplete', () => { + 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 + 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', + requiresConnectorId: true, + 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..8b864a7ba6d31 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/kql_filter_provider.ts @@ -0,0 +1,37 @@ +/* + * 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 { 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 registered trigger definitions (including connector 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; + } + + 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 new file mode 100644 index 0000000000000..280141a7ee1c7 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.test.ts @@ -0,0 +1,88 @@ +/* + * 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'; + +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..ad592c5cd05b6 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_connector_event_surface_for_trigger.ts @@ -0,0 +1,140 @@ +/* + * 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, + WorkflowSurfaceDefinition, +} 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 findConnectorEventForTrigger = ( + triggerId: string, + connectorTypes: Record +): { event: ConnectorEventInfo; connectorTypeId: string } | undefined => { + for (const connectorType of Object.values(connectorTypes)) { + const event = connectorType.events?.find((candidate) => candidate.eventId === triggerId); + if (event) { + return { event, connectorTypeId: connectorType.actionTypeId }; + } + } + + 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 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 connectorEvent = findConnectorEventForTrigger(triggerId, connectorTypes); + if (connectorEvent) { + return connectorEventInfoToWorkflowSurface({ + ...connectorEvent, + extensionTrigger, + }); + } + + 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: extensionTrigger.eventSchema + ? { + input: extensionTrigger.eventSchema, + filter: { + schema: extensionTrigger.eventSchema, + language: 'kql', + yamlPath: 'on.condition', + }, + } + : {}, + source: { + type: 'connector-event', + connectorTypeId, + eventKey, + }, + }; +}; 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..2eee93eb05d60 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.test.ts @@ -0,0 +1,102 @@ +/* + * 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 type { ConnectorTypeInfo } from '@kbn/workflows'; +import { resolveSurfaceAtPath } from './resolve_surface_at_path'; +import { triggerSchemas } from '../trigger_schemas'; + +jest.mock('../trigger_schemas', () => ({ + triggerSchemas: { + getTriggerDefinition: jest.fn(), + }, +})); + +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.', + }, + ], + }, + }; + + const yaml = `triggers: + - type: inboundWebhook.received + connector-id: sales-ingress + on: + 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'], { connectorTypes }) + ).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', () => { + (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'], { connectorTypes }) + ).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'], { connectorTypes }) + ).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..9366f17eef4ee --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/workflow_surface/resolve_surface_at_path.ts @@ -0,0 +1,174 @@ +/* + * 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 { 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, + 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'; + +export interface ResolvedSurfaceAtPath { + readonly surface: WorkflowSurfaceDefinition; + readonly role: SurfaceCursorRole; +} + +export interface ResolveSurfaceAtPathOptions { + readonly focusedStepInfo?: StepInfo | null; + readonly focusedYamlPair?: StepPropInfo | null; + readonly connectorTypes?: Record; +} + +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 || isStepConnectorIdPath(path)) { + return 'connector-id'; + } + if (getTriggerConditionBlockIndex(path) !== null) { + return 'kql-filter'; + } + if (getTriggerBlockIndex(path) !== null) { + return 'trigger'; + } + return undefined; +}; + +const resolveTriggerSurfaceAtPath = ( + yamlDocument: Document, + path: (string | number)[], + role: SurfaceCursorRole, + connectorTypes: Record +): ResolvedSurfaceAtPath | undefined => { + const triggerIndex = + getTriggerConnectorIdBlockIndex(path) ?? + getTriggerConditionBlockIndex(path) ?? + getTriggerBlockIndex(path); + if (triggerIndex === null) { + return undefined; + } + + const triggerType = getTriggerTypeAtIndex(yamlDocument, triggerIndex); + if (!triggerType) { + return undefined; + } + + const extensionTrigger = triggerSchemas.getTriggerDefinition(triggerType); + const surface = resolveConnectorEventSurfaceForTriggerId( + triggerType, + connectorTypes, + extensionTrigger + ); + if (!surface) { + return undefined; + } + + 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, options?.connectorTypes ?? {}); + } + + if (path[0] === 'steps' && role === 'connector-id') { + return resolveStepConnectorIdSurface( + path, + options?.focusedStepInfo ?? null, + options?.focusedYamlPair ?? null + ); + } + + return undefined; +}; 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..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,8 +7,22 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +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/server'; + 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..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,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/server'; 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/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/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, 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/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); } } 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/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/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/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/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/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/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..12b17df6aa633 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/handle_inbound_request.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; 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 { + getInboundWebhookAuthorizationHeader, + normalizeConnectorTypeId, +} from '@kbn/connector-specs'; +import { getConnectorSpec } from '@kbn/connector-specs/server'; + +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, + }); + } + + 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, + payload: { + ...event.payload, + connectorId, + connectorTypeId, + correlationKey: event.correlationKey, + }, + spaceId, + connectorId, + connectorTypeId, + ...(authorizationHeader ? { authorizationHeader } : {}), + }); + } + + 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..9387722aa8830 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/load_inbound_connector.ts @@ -0,0 +1,102 @@ +/* + * 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 { 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'; +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..045dd31600d3b --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/inbound/register_inbound_routes.ts @@ -0,0 +1,129 @@ +/* + * 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 { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; +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 { ACTION_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; +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, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [ACTION_SAVED_OBJECT_TYPE], + }); + const encryptedSavedObjectsClient = startPlugins.encryptedSavedObjects.getClient({ + includedHiddenTypes: [ACTION_SAVED_OBJECT_TYPE], + }); + + 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..017a6221efa03 --- /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/src/inbound_webhook/compute_ingest_token_hash'; + +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..d4ee2c8b0262f --- /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/src/inbound_webhook/compute_ingest_token_hash'; + +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/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/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..854df89ab745e 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,28 @@ 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; + /** + * Optional `ApiKey …` Authorization header from delegated connector credentials. + * Required for Task Manager to schedule workflows from unauthenticated ingress. + */ + authorizationHeader?: 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/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/src/platform/packages/shared/kbn-connector-specs/server.ts b/x-pack/platform/plugins/shared/connector_events_bridge/jest.config.js similarity index 61% rename from src/platform/packages/shared/kbn-connector-specs/server.ts rename to x-pack/platform/plugins/shared/connector_events_bridge/jest.config.js index ad21076a9ca93..91f7ba2ce6fb4 100644 --- a/src/platform/packages/shared/kbn-connector-specs/server.ts +++ b/x-pack/platform/plugins/shared/connector_events_bridge/jest.config.js @@ -1,8 +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 + * 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..10662700840da --- /dev/null +++ b/x-pack/platform/plugins/shared/connector_events_bridge/server/register_workflows_connector_event_emitter.test.ts @@ -0,0 +1,69 @@ +/* + * 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', + 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 new file mode 100644 index 0000000000000..de5ab0795fa93 --- /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, authorizationHeader }) => { + const workflowsExtensions = await getWorkflowsExtensionsStart(); + if (!workflowsExtensions) { + logger.warn( + `Workflows extensions unavailable; skipping connector event emit for ${eventId}` + ); + return; + } + + const request = kibanaRequestFactory({ + headers: authorizationHeader ? { authorization: authorizationHeader } : {}, + 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_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/api.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts new file mode 100644 index 0000000000000..c38a4c01b7791 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/api.ts @@ -0,0 +1,23 @@ +/* + * 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 rotateConnectorIngressUrl({ + http, + connectorId, + connectorTypeId, +}: { + http: HttpSetup; + connectorId: string; + connectorTypeId: string; +}): Promise<{ webhookUrl: string; ingestTokenHash: string }> { + 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.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx new file mode 100644 index 0000000000000..2ac707515d7c0 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook.tsx @@ -0,0 +1,60 @@ +/* + * 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: {} }), + 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 }) => ({ + 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..0eff418e62659 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/inbound_webhook/inbound_webhook_connector_fields.tsx @@ -0,0 +1,311 @@ +/* + * 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, + EuiCallOut, + EuiConfirmModal, + EuiCopy, + EuiFieldText, + EuiFormRow, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + useGeneratedHtmlId, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { HiddenField } 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 { + 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 { rotateConnectorIngressUrl } from './api'; + +const CONNECTOR_ID_SETTLE_MS = 400; +const CONNECTOR_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export const InboundWebhookConnectorFields = ({ isEdit, readOnly }: ActionConnectorFieldsProps) => { + const { http } = useKibana().services; + const { setFieldValue } = useFormContext(); + const [{ id, config }] = useFormData({ watch: ['id', 'config.webhookUrl'] }); + 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]); + + const mintIngressUrl = useCallback( + (connectorId: string) => { + const requestId = ++requestIdRef.current; + setIsRotating(true); + setRotateError(undefined); + setRotateSuccess(false); + + return rotateConnectorIngressUrl({ + http, + connectorId, + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }) + .then((credentials) => { + if (requestId !== requestIdRef.current) { + return; + } + setFieldValue('config.webhookUrl', credentials.webhookUrl); + setFieldValue('config.ingestTokenHash', credentials.ingestTokenHash); + if (isEdit) { + setRotateSuccess(true); + } + }) + .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); + } + }); + }, + [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); + }; + }, [id, isEdit, mintIngressUrl, readOnly, setFieldValue, webhookUrl]); + + const handleConfirmRotate = useCallback(() => { + setShowRotateConfirm(false); + const connectorId = typeof id === 'string' ? id.trim() : ''; + if (!connectorId) { + return; + } + void mintIngressUrl(connectorId); + }, [id, mintIngressUrl]); + + const renderWebhookUrlField = (url: string) => ( + <> + + + + + + {(copy) => ( + + + + )} + + + ); + + const hiddenFields = ( + <> + + + + + ); + + if (isEdit) { + const showRotatedUrl = rotateSuccess && Boolean(webhookUrl); + + return ( + <> + {hiddenFields} + {showRotatedUrl && webhookUrl ? ( + <> + {renderWebhookUrlField(webhookUrl)} + + + + ) : ( + + +

+ +

+
+
+ )} + {rotateError ? ( + <> + + + + ) : null} + {!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} + + ); + } + + return ( + <> + {hiddenFields} + {webhookUrl ? ( + renderWebhookUrlField(webhookUrl) + ) : ( + + {isRotating ? ( + + ) : ( + + )} + + )} + + ); +}; 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..adfc4ea35ac2b 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/public/plugin.ts @@ -8,6 +8,7 @@ import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public'; import type { TriggersAndActionsUIPublicPluginSetup } from '@kbn/triggers-actions-ui-plugin/public'; import type { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import type { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public'; import type { ConfigSchema as StackConnectorsConfigType } from '../server/config'; import { registerConnectorTypes } from './connector_types'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; @@ -21,6 +22,7 @@ export type Start = void; export interface StackConnectorsPublicSetupDeps { triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; actions: ActionsPublicPluginSetup; + workflowsExtensions?: WorkflowsExtensionsPublicPluginSetup; } export class StackConnectorsPublicPlugin @@ -33,7 +35,10 @@ export class StackConnectorsPublicPlugin this.config = ctx.config.get(); this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []); } - public setup(core: CoreSetup, { triggersActionsUi, actions }: StackConnectorsPublicSetupDeps) { + public setup( + core: CoreSetup, + { triggersActionsUi, actions, workflowsExtensions }: StackConnectorsPublicSetupDeps + ) { ExperimentalFeaturesService.init({ experimentalFeatures: this.experimentalFeatures }); ConfigService.init({ config: this.config }); @@ -43,6 +48,19 @@ export class StackConnectorsPublicPlugin validateEmailAddresses: actions.validateEmailAddresses, }, }); + + if (this.experimentalFeatures.connectorsFromSpecs) { + void import('./connector_types/inbound_webhook/inbound_webhook').then( + ({ getInboundWebhookConnectorType }) => { + triggersActionsUi.actionTypeRegistry.register(getInboundWebhookConnectorType()); + } + ); + void import('./connector_events/register_connector_event_triggers').then( + ({ registerConnectorEventTriggers }) => { + registerConnectorEventTriggers(workflowsExtensions); + } + ); + } } 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..bc6bdc9b67e91 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_events/register_connector_event_triggers.ts @@ -0,0 +1,46 @@ +/* + * 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 { 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( + 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', + requiresConnectorId: true, + 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/ensure_connector_ingress_credentials.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.test.ts new file mode 100644 index 0000000000000..080130a8c0a03 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { 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 { + ensureConnectorIngressCredentials, + mintConnectorIngressCredentials, +} from './ensure_connector_ingress_credentials'; + +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 = mintConnectorIngressCredentials({ + connectorTypeId, + connectorId: 'conn-1', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + }); + const config: Record = { ...minted }; + + ensureConnectorIngressCredentials({ + config, + connectorTypeId, + 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, + }), + }; + + ensureConnectorIngressCredentials({ + config, + connectorTypeId, + 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), + }; + + ensureConnectorIngressCredentials({ + config, + connectorTypeId, + 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 = mintConnectorIngressCredentials({ + connectorTypeId, + connectorId: 'old-id', + spaceId: 'default', + publicBaseUrl: 'https://kibana.example.com', + }); + const config: Record = { ...minted }; + + ensureConnectorIngressCredentials({ + config, + connectorTypeId, + 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 }; + + ensureConnectorIngressCredentials({ + config, + connectorTypeId, + 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 = {}; + + ensureConnectorIngressCredentials({ + config, + connectorTypeId, + 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_connector_ingress_credentials.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.ts new file mode 100644 index 0000000000000..22d90d1a77b3d --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/ensure_connector_ingress_credentials.ts @@ -0,0 +1,170 @@ +/* + * 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 { 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 ConnectorIngressCredentials { + readonly ingestTokenHash: string; + readonly webhookUrl: string; +} + +const buildConnectorIngressUrl = ({ + publicBaseUrl, + spaceId, + connectorTypeId, + connectorId, + token, +}: { + publicBaseUrl: string; + spaceId: string; + connectorTypeId: string; + connectorId: string; + token: string; +}): string => { + 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}`; +}; + +export const mintConnectorIngressCredentials = ({ + connectorTypeId, + connectorId, + spaceId, + publicBaseUrl, +}: { + connectorTypeId: string; + connectorId: string; + spaceId: string; + publicBaseUrl: string; +}): ConnectorIngressCredentials => { + const token = randomBytes(32).toString('hex'); + return { + ingestTokenHash: computeIngestTokenHash({ connectorId, spaceId, token }), + webhookUrl: buildConnectorIngressUrl({ + publicBaseUrl, + spaceId, + connectorTypeId, + connectorId, + token, + }), + }; +}; + +/** 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 => { + if (typeof webhookUrl !== 'string' || typeof ingestTokenHash !== 'string') { + return undefined; + } + + try { + const url = new URL(webhookUrl); + const expectedPathEnd = buildConnectorIngressEventsPath({ connectorTypeId, 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: ConnectorIngressCredentials +): void => { + config.ingestTokenHash = credentials.ingestTokenHash; + config.webhookUrl = credentials.webhookUrl; +}; + +export const ensureConnectorIngressCredentials = ({ + config, + connectorTypeId, + connectorId, + spaceId, + publicBaseUrl, + isUpdate, + logger, +}: { + config: Record; + connectorTypeId: string; + connectorId: string; + spaceId: string; + publicBaseUrl: string; + isUpdate: boolean; + logger: Logger; +}): void => { + if (!isUpdate) { + const token = getVerifiedIngestToken({ + webhookUrl: config.webhookUrl, + ingestTokenHash: config.ingestTokenHash, + connectorTypeId, + connectorId, + spaceId, + }); + if (token) { + config.webhookUrl = buildConnectorIngressUrl({ + publicBaseUrl, + spaceId, + connectorTypeId, + connectorId, + token, + }); + return; + } + applyCredentials( + config, + mintConnectorIngressCredentials({ connectorTypeId, connectorId, spaceId, publicBaseUrl }) + ); + return; + } + + if ( + typeof config.ingestTokenHash === 'string' && + config.ingestTokenHash.length > 0 && + typeof config.webhookUrl === 'string' && + config.webhookUrl.length > 0 + ) { + return; + } + + logger.warn( + `Connector ingress credentials missing on update for connector ${connectorId} (${connectorTypeId}); minting new credentials` + ); + applyCredentials( + config, + 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 new file mode 100644 index 0000000000000..40642d011e82b --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/inbound_webhook_api_key_service.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 { + 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, +}; + +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; + }; +} + +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 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 97b593511c8d6..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 @@ -7,16 +7,38 @@ import { type PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; -import { connectorsSpecs } from '@kbn/connector-specs'; +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'; export function registerConnectorTypesFromSpecs({ actions, + getSpaceId, + getPublicBaseUrl, + getSecurity, + logger, }: { actions: ActionsPluginSetupContract; + getSpaceId: (request: KibanaRequest) => string; + getPublicBaseUrl: (request: KibanaRequest) => string; + getSecurity: () => Promise; + logger: Logger; }) { - // Register connector specs + registerInboundWebhookConnectorType({ + actions, + getSpaceId, + getPublicBaseUrl, + getSecurity, + logger, + }); + 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..439aefced69be --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types_from_spec/register_inbound_webhook_connector_type.ts @@ -0,0 +1,112 @@ +/* + * 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 { 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 { ensureConnectorIngressCredentials } from './ensure_connector_ingress_credentials'; +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'; + +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, +}: { + actions: ActionsPluginSetupContract; + getSpaceId: (request: KibanaRequest) => string; + getPublicBaseUrl: (request: KibanaRequest) => string; + getSecurity: () => Promise; + logger: Logger; +}): void { + const connectorType = createConnectorTypeFromSpec(InboundWebhookConnector, actions); + const apiKeyServicePromise = getSecurity().then( + (security) => new InboundWebhookApiKeyService(security, logger) + ); + + actions.registerType({ + ...connectorType, + 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; + + ensureConnectorIngressCredentials({ + config: configRecord, + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + connectorId, + spaceId, + publicBaseUrl: getPublicBaseUrl(request), + isUpdate, + logger, + }); + + const hasDelegatedKey = + typeof secretsRecord[DELEGATED_API_KEY_SECRET] === 'string' && + secretsRecord[DELEGATED_API_KEY_SECRET].length > 0; + + if (hasDelegatedKey) { + return; + } + + 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/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.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/server/plugin.ts b/x-pack/platform/plugins/shared/stack_connectors/server/plugin.ts index 68fc0ba18ce74..e9ec6552a421d 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,14 @@ * 2.0. */ -import type { PluginInitializerContext, Plugin, CoreSetup, CoreStart } from '@kbn/core/server'; +import type { + PluginInitializerContext, + Plugin, + CoreSetup, + CoreStart, + KibanaRequest, + 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'; @@ -15,22 +22,28 @@ 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 { getWellKnownEmailServiceRoute, getWebhookSecretHeadersKeyRoute, getHttpSecretQueryParamsKeyRoute, + rotateConnectorIngressUrlRoute, } from './routes'; 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 { actions: ActionsPluginSetupContract; usageCollection?: UsageCollectionSetup; cloud?: CloudSetup; + spaces?: SpacesPluginSetup; + workflowsExtensions?: WorkflowsExtensionsServerPluginSetup; } export interface ConnectorsPluginsStart { @@ -44,6 +57,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. @@ -55,6 +69,7 @@ export class StackConnectorsPlugin constructor(context: PluginInitializerContext) { this.config = context.config.get(); + this.logger = context.logger.get(); this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []); } @@ -93,7 +108,34 @@ export class StackConnectorsPlugin }); if (this.experimentalFeatures.connectorsFromSpecs) { - registerConnectorTypesFromSpecs({ actions }); + const getSpaceId = (request: KibanaRequest) => + plugins.spaces?.spacesService.getSpaceId(request) ?? 'default'; + const getPublicBaseUrl = (request: KibanaRequest) => + resolveConnectorIngressPublicBaseUrl({ + publicBaseUrl: core.http.basePath.publicBaseUrl, + serverBasePath: core.http.basePath.serverBasePath, + request, + serverInfo: core.http.getServerInfo(), + }); + + registerConnectorTypesFromSpecs({ + actions, + getSpaceId, + getPublicBaseUrl, + getSecurity: async () => { + const [coreStart] = await core.getStartServices(); + return coreStart.security; + }, + logger: this.logger, + }); + rotateConnectorIngressUrlRoute({ + router, + getStartServices: core.getStartServices, + getPublicBaseUrl, + getSpaceId, + logger: this.logger, + }); + registerConnectorEventTriggers(plugins.workflowsExtensions); } if (plugins.usageCollection) { 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..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,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 { rotateConnectorIngressUrlRoute } from './rotate_connector_ingress_url'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.test.ts new file mode 100644 index 0000000000000..1454a33437ff6 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.test.ts @@ -0,0 +1,143 @@ +/* + * 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 { 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 { rotateConnectorIngressUrlRoute } from './rotate_connector_ingress_url'; + +describe('rotateConnectorIngressUrlRoute', () => { + const logger = loggingSystemMock.createLogger(); + + const setup = () => { + const router = httpServiceMock.createRouter(); + const ensureAuthorized = jest.fn().mockResolvedValue(undefined); + const getStartServices = jest.fn().mockResolvedValue([ + {}, + { + actions: { + getActionsAuthorizationWithRequest: () => ({ ensureAuthorized }), + }, + }, + ]); + + rotateConnectorIngressUrlRoute({ + 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/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', + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }, + }); + + 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('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', + 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); + + 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', + connectorTypeId: INBOUND_WEBHOOK_CONNECTOR_TYPE_ID, + }, + }); + + 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_connector_ingress_url.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.ts new file mode 100644 index 0000000000000..dd656a5d8b7bd --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/rotate_connector_ingress_url.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 { isBoom } from '@hapi/boom'; +import { schema } from '@kbn/config-schema'; +import { CONNECTOR_ID_MAX_LENGTH, validateConnectorId } from '@kbn/actions-plugin/common'; +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 { mintConnectorIngressCredentials } from '../connector_types_from_spec/ensure_connector_ingress_credentials'; +import type { ConnectorsPluginsStart } from '../plugin'; + +export const rotateConnectorIngressUrlRoute = ({ + router, + getStartServices, + getPublicBaseUrl, + getSpaceId, + logger, +}: { + router: IRouter; + getStartServices: StartServicesAccessor; + getPublicBaseUrl: (request: KibanaRequest) => string; + getSpaceId: (request: KibanaRequest) => string; + logger: Logger; +}): void => { + router.post( + { + path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/connector_ingress/_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 }), + connectorTypeId: schema.string({ minLength: 1 }), + }), + }, + options: { + access: 'internal', + }, + }, + async (_ctx, req, res) => { + const { connectorId, connectorTypeId: rawConnectorTypeId } = req.body; + + try { + validateConnectorId(connectorId); + } catch (error) { + return res.badRequest({ + body: error instanceof Error ? error.message : 'Invalid connector ID', + }); + } + + 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: connectorTypeId, + }); + + return res.ok({ + body: mintConnectorIngressCredentials({ + connectorTypeId, + connectorId, + spaceId: getSpaceId(req), + publicBaseUrl: getPublicBaseUrl(req), + }), + }); + } catch (error) { + if (isBoom(error)) { + return res.customError({ + statusCode: error.output.statusCode, + body: { message: error.message }, + }); + } + logger.error(`Failed to rotate connector ingress URL: ${error}`); + return res.customError({ + statusCode: 500, + body: { message: 'Failed to rotate connector ingress URL' }, + }); + } + } + ); +}; 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/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 ( <>