diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 76a7aa791c0ba..73f5d154018ca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2885,6 +2885,11 @@ src/platform/packages/shared/kbn-connector-schemas/thehive @elastic/kibana-cases /x-pack/platform/plugins/shared/stack_connectors/server/usage/inference @elastic/appex-ai-infra /src/platform/packages/shared/kbn-connector-schemas/inference @elastic/appex-ai-infra @elastic/security-generative-ai @elastic/obs-ai-team +# HTTP +/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http @elastic/workflows-eng +/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http @elastic/workflows-eng +/src/platform/packages/shared/kbn-connector-schemas/http @elastic/workflows-eng + # Token tracking x-pack/platform/plugins/shared/actions/server/lib/token_tracking @elastic/security-generative-ai diff --git a/src/platform/packages/shared/kbn-connector-schemas/http/constants.ts b/src/platform/packages/shared/kbn-connector-schemas/http/constants.ts new file mode 100644 index 0000000000000..a96d5a0d156e9 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-schemas/http/constants.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 { i18n } from '@kbn/i18n'; + +export const CONNECTOR_ID = '.http'; +export const CONNECTOR_ID_SYSTEM = '.http-system'; + +export const CONNECTOR_NAME = i18n.translate('connectors.http.title', { + defaultMessage: 'HTTP', +}); diff --git a/src/platform/packages/shared/kbn-connector-schemas/http/index.ts b/src/platform/packages/shared/kbn-connector-schemas/http/index.ts new file mode 100644 index 0000000000000..7196485fe26e0 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-schemas/http/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +export * from './constants'; + +export { ConfigSchema, ParamsSchema, HTTP_METHODS } from './schemas/latest'; + +export type { + ConnectorTypeConfigType, + ConnectorTypeSecretsType, + ActionParamsType, + HttpMethod, +} from './types/latest'; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/index.ts b/src/platform/packages/shared/kbn-connector-schemas/http/schemas/latest.ts similarity index 87% rename from src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/index.ts rename to src/platform/packages/shared/kbn-connector-schemas/http/schemas/latest.ts index b65c240cd0e6a..bcb80bae98bcc 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/index.ts +++ b/src/platform/packages/shared/kbn-connector-schemas/http/schemas/latest.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { HttpStepImpl } from './http_step_impl'; +export { ConfigSchema, HTTP_METHODS, ParamsSchema } from './v1'; diff --git a/src/platform/packages/shared/kbn-connector-schemas/http/schemas/v1.ts b/src/platform/packages/shared/kbn-connector-schemas/http/schemas/v1.ts new file mode 100644 index 0000000000000..f68febcab2567 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-schemas/http/schemas/v1.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", 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'; +import { AuthConfiguration } from '../../common/auth'; + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; + +export const HeadersSchema = z.record(z.string(), z.string()); + +export const ConfigSchema = z + .object({ + url: z.string().url(), + headers: HeadersSchema.nullable().default(null), + hasAuth: AuthConfiguration.hasAuth, + authType: AuthConfiguration.authType, + certType: AuthConfiguration.certType, + ca: AuthConfiguration.ca, + verificationMode: AuthConfiguration.verificationMode, + accessTokenUrl: AuthConfiguration.accessTokenUrl, + clientId: AuthConfiguration.clientId, + scope: AuthConfiguration.scope, + additionalFields: AuthConfiguration.additionalFields, + }) + .strict(); + +export const ParamsSchema = z + .object({ + url: z.string().url().optional(), + path: z.string().optional(), + method: z.enum(HTTP_METHODS).default('GET'), + body: z.string().optional(), + query: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + fetcher: z + .object({ + skip_ssl_verification: z.boolean().optional(), + follow_redirects: z.boolean().optional(), + max_redirects: z.number().optional(), + keep_alive: z.boolean().optional(), + }) + .optional(), + }) + .strict(); diff --git a/src/platform/packages/shared/kbn-connector-schemas/http/types/latest.ts b/src/platform/packages/shared/kbn-connector-schemas/http/types/latest.ts new file mode 100644 index 0000000000000..1e42635ccec54 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-schemas/http/types/latest.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 type { + ConnectorTypeConfigType, + ConnectorTypeSecretsType, + ActionParamsType, + HttpMethod, +} from './v1'; diff --git a/src/platform/packages/shared/kbn-connector-schemas/http/types/v1.ts b/src/platform/packages/shared/kbn-connector-schemas/http/types/v1.ts new file mode 100644 index 0000000000000..6d8365dc42ad0 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-schemas/http/types/v1.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", 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'; +import type { SecretConfigurationSchema } from '../../common/auth'; +import type { ConfigSchema, HTTP_METHODS, ParamsSchema } from '../schemas/v1'; + +// http method definition +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +// config definition +export type ConnectorTypeConfigType = z.infer; + +// secrets definition +export type ConnectorTypeSecretsType = z.infer; + +// params definition +export type ActionParamsType = z.infer; diff --git a/src/platform/packages/shared/kbn-connector-schemas/index.ts b/src/platform/packages/shared/kbn-connector-schemas/index.ts index 43a5af80a69a2..bfa8914e5bf61 100644 --- a/src/platform/packages/shared/kbn-connector-schemas/index.ts +++ b/src/platform/packages/shared/kbn-connector-schemas/index.ts @@ -14,6 +14,7 @@ export { CONNECTOR_ID as D3SecurityConnectorTypeId } from './d3security'; export { CONNECTOR_ID as EmailConnectorTypeId } from './email'; export { CONNECTOR_ID as EsIndexConnectorTypeId } from './es_index'; export { CONNECTOR_ID as GeminiConnectorTypeId } from './gemini'; +export { CONNECTOR_ID as HttpConnectorTypeId } from './http'; export { CONNECTOR_ID as InferenceConnectorTypeId } from './inference'; export { CONNECTOR_ID as JiraConnectorTypeId } from './jira'; export { CONNECTOR_ID as JiraServiceManagementConnectorTypeId } from './jira-service-management'; diff --git a/src/platform/packages/shared/kbn-workflows/common/constants.ts b/src/platform/packages/shared/kbn-workflows/common/constants.ts index 35fda94dd360e..e724f1d9074cb 100644 --- a/src/platform/packages/shared/kbn-workflows/common/constants.ts +++ b/src/platform/packages/shared/kbn-workflows/common/constants.ts @@ -24,3 +24,11 @@ export const WORKFLOWS_UI_SHOW_EXECUTOR_SETTING_ID = 'workflows:ui:showExecutor: * Feature flag ID for enabling / disabling the workflow execution stats bar UI */ export const WORKFLOW_EXECUTION_STATS_BAR_SETTING_ID = 'workflows:executionStatsBar:enabled'; + +/** + * Map of regular (saved object) connector types -> their system connector equivalents. + * Use this map to make the `connector-id` step config property optional for a given connector step type, allowing it to be executed via its linked system connector. + * Pre-requisite for this to work: + * - System connectors have empty config/secrets schemas. Make sure these system connectors are able to execute by receiving params alone. + */ +export const SystemConnectorsMap = new Map([['.http', '.http-system']]); diff --git a/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts b/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts index 0aa5209910089..72a94668efcbe 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/build_execution_graph.ts @@ -14,7 +14,6 @@ import type { DataSetStep, ElasticsearchStep, ForEachStep, - HttpStep, IfStep, KibanaStep, StepWithForeach, @@ -50,7 +49,6 @@ import type { ExitTimeoutZoneNode, ExitTryBlockNode, GraphNodeUnion, - HttpGraphNode, KibanaGraphNode, WaitGraphNode, WorkflowGraphType, @@ -142,10 +140,6 @@ function visitAbstractStep(currentStep: BaseStep, context: GraphBuildContext): W return visitDataSetStep(currentStep as DataSetStep, context); } - if ((currentStep as HttpStep).type === 'http') { - return visitHttpStep(currentStep as HttpStep, context); - } - if ((currentStep as ElasticsearchStep).type?.startsWith('elasticsearch.')) { return visitElasticsearchStep(currentStep as ElasticsearchStep, context); } @@ -197,26 +191,6 @@ export function visitDataSetStep( return graph; } -export function visitHttpStep( - currentStep: HttpStep, - context: GraphBuildContext -): WorkflowGraphType { - const stepId = getStepId(currentStep, context); - const graph = createTypedGraph({ directed: true }); - const httpNode: HttpGraphNode = { - id: getStepId(currentStep, context), - type: 'http', - stepId, - stepType: currentStep.type, - configuration: { - ...currentStep, - }, - }; - graph.setNode(httpNode.id, httpNode); - - return graph; -} - export function visitElasticsearchStep( currentStep: ElasticsearchStep, context: GraphBuildContext diff --git a/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/tests/build_execution_graph.test.ts b/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/tests/build_execution_graph.test.ts index e5640b81350ba..d065cd3324398 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/tests/build_execution_graph.test.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/build_execution_graph/tests/build_execution_graph.test.ts @@ -12,7 +12,6 @@ import type { ConnectorStep, ElasticsearchStep, ForEachStep, - HttpStep, IfStep, KibanaStep, WaitStep, @@ -27,7 +26,6 @@ import type { ExitConditionBranchNode, ExitForeachNode, ExitIfNode, - HttpGraphNode, KibanaGraphNode, WaitGraphNode, } from '../../types'; @@ -149,80 +147,6 @@ describe('convertToWorkflowGraph', () => { }); }); - describe('http step', () => { - const workflowDefinition = { - steps: [ - { - name: 'testAtomicStep1', - type: 'slack', - connectorId: 'slack', - with: { - message: 'Hello from atomic step 1', - }, - } as ConnectorStep, - { - name: 'testHttpStep', - type: 'http', - with: { - url: 'https://api.example.com/test', - method: 'GET', - headers: { - Authorization: 'Bearer token', - }, - timeout: '30s', - }, - } as HttpStep, - { - name: 'testAtomicStep2', - type: 'slack', - connectorId: 'slack', - with: { - message: 'Hello from atomic step 2', - }, - } as ConnectorStep, - ], - } as Partial; - - it('should return nodes for http step in correct topological order', () => { - const executionGraph = convertToWorkflowGraph(workflowDefinition as any); - const topSort = graphlib.alg.topsort(executionGraph); - expect(topSort).toHaveLength(3); - expect(topSort).toEqual(['testAtomicStep1', 'testHttpStep', 'testAtomicStep2']); - }); - - it('should return correct edges for http step graph', () => { - const executionGraph = convertToWorkflowGraph(workflowDefinition as any); - const edges = executionGraph.edges(); - expect(edges).toEqual([ - { v: 'testAtomicStep1', w: 'testHttpStep' }, - { v: 'testHttpStep', w: 'testAtomicStep2' }, - ]); - }); - - it('should configure the http step correctly', () => { - const executionGraph = convertToWorkflowGraph(workflowDefinition as any); - const node = executionGraph.node('testHttpStep'); - expect(node).toEqual({ - id: 'testHttpStep', - type: 'http', - stepId: 'testHttpStep', - stepType: 'http', - configuration: { - name: 'testHttpStep', - type: 'http', - with: { - url: 'https://api.example.com/test', - method: 'GET', - headers: { - Authorization: 'Bearer token', - }, - timeout: '30s', - }, - }, - } as HttpGraphNode); - }); - }); - describe('if step', () => { const workflowDefinition = { steps: [ diff --git a/src/platform/packages/shared/kbn-workflows/graph/index.ts b/src/platform/packages/shared/kbn-workflows/graph/index.ts index 0049a8302ebef..4ac4ecff68b12 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/index.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/index.ts @@ -33,8 +33,6 @@ export type { ExitContinueNode, WaitGraphNodeSchema, WaitGraphNode, - HttpGraphNode, - HttpGraphNodeSchema, EnterTryBlockNode, ExitTryBlockNode, EnterNormalPathNode, @@ -51,7 +49,6 @@ export { isDataSet, isElasticsearch, isKibana, - isHttp, isWait, isEnterForeach, isEnterIf, diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts b/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts index b40018b29e2fb..f2a9bb13596ab 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/guards.ts @@ -11,7 +11,6 @@ import type { AtomicGraphNode, DataSetGraphNode, ElasticsearchGraphNode, - HttpGraphNode, KibanaGraphNode, WaitGraphNode, } from './nodes/base'; @@ -44,8 +43,6 @@ export const isElasticsearch = (node: GraphNodeUnion): node is ElasticsearchGrap export const isKibana = (node: GraphNodeUnion): node is KibanaGraphNode => node.type.startsWith('kibana.'); -export const isHttp = (node: GraphNodeUnion): node is HttpGraphNode => node.type === 'http'; - export const isWait = (node: GraphNodeUnion): node is WaitGraphNode => node.type === 'wait'; export const isDataSet = (node: GraphNodeUnion): node is DataSetGraphNode => diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/index.ts b/src/platform/packages/shared/kbn-workflows/graph/types/index.ts index 09bd92bee8871..3ae4812d9fa44 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/index.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/index.ts @@ -12,8 +12,6 @@ export type { AtomicGraphNodeSchema, DataSetGraphNode, DataSetGraphNodeSchema, - HttpGraphNode, - HttpGraphNodeSchema, WaitGraphNode, WaitGraphNodeSchema, ElasticsearchGraphNode, @@ -71,7 +69,6 @@ export { isDataSet, isElasticsearch, isKibana, - isHttp, isWait, isEnterForeach, isEnterIf, diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts index f47e969268075..c38a78128ef26 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/base.ts @@ -11,7 +11,6 @@ import { z } from '@kbn/zod/v4'; import { DataSetStepSchema, ElasticsearchStepSchema, - HttpStepSchema, KibanaStepSchema, WaitStepSchema, } from '../../../spec/schema'; @@ -44,13 +43,6 @@ export const DataSetGraphNodeSchema = GraphNodeSchema.extend({ }); export type DataSetGraphNode = z.infer; -export const HttpGraphNodeSchema = GraphNodeSchema.extend({ - id: z.string(), - type: z.literal('http'), - configuration: HttpStepSchema, -}); -export type HttpGraphNode = z.infer; - export const ElasticsearchGraphNodeSchema = GraphNodeSchema.extend({ id: z.string(), type: z.string().refine((val) => val.startsWith('elasticsearch.'), { diff --git a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts index 656fd92431136..befb95a6e1b7f 100644 --- a/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts +++ b/src/platform/packages/shared/kbn-workflows/graph/types/nodes/union.ts @@ -12,7 +12,6 @@ import { AtomicGraphNodeSchema, DataSetGraphNodeSchema, ElasticsearchGraphNodeSchema, - HttpGraphNodeSchema, KibanaGraphNodeSchema, WaitGraphNodeSchema, } from './base'; @@ -46,7 +45,6 @@ const GraphNodeUnionSchema = z.discriminatedUnion('type', [ DataSetGraphNodeSchema, ElasticsearchGraphNodeSchema, KibanaGraphNodeSchema, - HttpGraphNodeSchema, WaitGraphNodeSchema, EnterIfNodeSchema, ExitIfNodeSchema, 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 32ac0a4ab876b..7aea02c2d98d1 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 @@ -15,7 +15,6 @@ import { BaseConnectorStepSchema, DataSetStepSchema, getForEachStepSchema, - getHttpStepSchema, getIfStepSchema, getMergeStepSchema, getOnFailureStepSchema, @@ -103,7 +102,6 @@ function createRecursiveStepSchema( const ifSchema = getIfStepSchema(stepSchema, loose); const parallelSchema = getParallelStepSchema(stepSchema, loose); const mergeSchema = getMergeStepSchema(stepSchema, loose); - const httpSchema = getHttpStepSchema(stepSchema, loose); const connectorSchemas = connectors.map((c) => generateStepSchemaForConnector(c, stepSchema, loose) @@ -122,7 +120,6 @@ function createRecursiveStepSchema( mergeSchema, WaitStepSchema, DataSetStepSchema, - httpSchema, ...connectorSchemas, ...aliasSchemas, ]); diff --git a/src/platform/packages/shared/kbn-workflows/spec/schema.ts b/src/platform/packages/shared/kbn-workflows/spec/schema.ts index e24061a782862..06822f9f9944f 100644 --- a/src/platform/packages/shared/kbn-workflows/spec/schema.ts +++ b/src/platform/packages/shared/kbn-workflows/spec/schema.ts @@ -232,60 +232,6 @@ export const FetcherConfigSchema = z .meta({ $id: 'fetcher', description: 'Fetcher configuration for HTTP request customization' }) .optional(); -// Extended fetcher config for the HTTP step only — adds proxy support -export const HttpFetcherConfigSchema = z - .object({ - skip_ssl_verification: z - .boolean() - .optional() - .describe('Skip SSL/TLS certificate verification for the request'), - follow_redirects: z - .boolean() - .optional() - .describe('Whether to follow HTTP redirects. Defaults to true'), - max_redirects: z.number().optional().describe('Maximum number of redirects to follow'), - keep_alive: z.boolean().optional().describe('Enable HTTP keep-alive for connection reuse'), - proxy_url: z - .string() - .optional() - .describe( - 'HTTP or HTTPS proxy URL to route the request through (e.g. "http://proxy.example.com:8080")' - ), - proxy_username: z - .string() - .optional() - .describe('Username for authenticated proxy. Must be used together with proxy_password'), - proxy_password: z - .string() - .optional() - .describe('Password for authenticated proxy. Must be used together with proxy_username'), - }) - .meta({ - $id: 'http_fetcher', - description: 'Fetcher configuration for HTTP step requests, including proxy support', - }) - .optional(); - -export const HttpStepSchema = BaseStepSchema.extend({ - type: z.literal('http'), - with: z.object({ - url: z.string().min(1), - method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).optional().default('GET'), - headers: z - .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) - .optional() - .default({}), - body: z.any().optional(), - timeout: z.string().optional().default('30s'), - fetcher: HttpFetcherConfigSchema, - }), -}) - .merge(StepWithIfConditionSchema) - .merge(StepWithForEachSchema) - .merge(TimeoutPropSchema) - .merge(StepWithOnFailureSchema); -export type HttpStep = z.infer; - // Generic Elasticsearch step schema for backend validation export const ElasticsearchStepSchema = BaseStepSchema.extend({ type: z.string().refine((val) => val.startsWith('elasticsearch.'), { @@ -379,19 +325,6 @@ export const KibanaStepSchema = BaseStepSchema.extend({ }); export type KibanaStep = z.infer; -export function getHttpStepSchema(stepSchema: z.ZodType, loose: boolean = false) { - const schema = HttpStepSchema.extend({ - 'on-failure': getOnFailureStepSchema(stepSchema, loose).optional(), - }); - - if (loose) { - // make all fields optional, but require type to be present for discriminated union - return schema.partial().required({ type: true }); - } - - return schema; -} - export const ForEachStepSchema = BaseStepSchema.extend({ type: z.literal('foreach'), foreach: z.union([z.string(), z.array(z.unknown())]), @@ -545,7 +478,6 @@ const StepSchema = z.lazy(() => IfStepSchema, WaitStepSchema, DataSetStepSchema, - HttpStepSchema, ElasticsearchStepSchema, KibanaStepSchema, ParallelStepSchema, @@ -562,7 +494,6 @@ export const BuiltInStepTypes = [ MergeStepSchema.shape.type.value, DataSetStepSchema.shape.type.value, WaitStepSchema.shape.type.value, - HttpStepSchema.shape.type.value, ]; export type BuiltInStepType = (typeof BuiltInStepTypes)[number]; diff --git a/src/platform/packages/shared/kbn-workflows/types/utils.ts b/src/platform/packages/shared/kbn-workflows/types/utils.ts index 5d77a77b14dea..4cb5b62c27cf1 100644 --- a/src/platform/packages/shared/kbn-workflows/types/utils.ts +++ b/src/platform/packages/shared/kbn-workflows/types/utils.ts @@ -20,7 +20,6 @@ import type { BuiltInStepType, ElasticsearchStep, ForEachStep, - HttpStep, IfStep, KibanaStep, MergeStep, @@ -77,7 +76,6 @@ export function isCancelableStatus(status: ExecutionStatus) { // Type guards for steps types export const isWaitStep = (step: Step): step is WaitStep => step.type === 'wait'; -export const isHttpStep = (step: Step): step is HttpStep => step.type === 'http'; export const isElasticsearchStep = (step: Step): step is ElasticsearchStep => step.type === 'elasticsearch'; export const isKibanaStep = (step: Step): step is KibanaStep => step.type === 'kibana'; diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/connector_executor.ts b/src/platform/plugins/shared/workflows_execution_engine/server/connector_executor.ts index d53775542787e..88f6dc3dbe41b 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/connector_executor.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/connector_executor.ts @@ -7,9 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -// TODO: Remove eslint exceptions comments and fix the issues -/* eslint-disable @typescript-eslint/no-explicit-any */ - import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; import type { ActionsClient } from '@kbn/actions-plugin/server'; import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/application/connector/types'; @@ -17,48 +14,62 @@ import type { ConnectorWithExtraFindData } from '@kbn/actions-plugin/server/appl export class ConnectorExecutor { constructor(private actionsClient: ActionsClient) {} - public async execute( - connectorType: string, - connectorName: string, - inputs: Record, - spaceId: string, - abortController: AbortController - ): Promise> { - if (!connectorType) { - throw new Error('Connector type is required'); - } + // Execute a regular connector with a saved object. It will resolve the connector ID from saved objects. + public async execute(params: { + connectorType: string; + connectorNameOrId: string; + input: Record; + abortController: AbortController; + }): Promise> { + const { connectorType, connectorNameOrId, input, abortController } = params; + const actionId = await this.resolveConnectorId(connectorNameOrId); + + return this.runConnector({ actionTypeId: connectorType, actionId, input, abortController }); + } + + // Execute a system connector. It will use the provided connector ID directly. + public async executeSystemConnector(params: { + connectorType: string; + input: Record; + abortController: AbortController; + }): Promise> { + const { connectorType, input, abortController } = params; + // The InMemoryConnector with prefixed "system-connector-" is created by the actions framework + const actionId = `system-connector-${connectorType}`; + + return this.runConnector({ actionTypeId: connectorType, actionId, input, abortController }); + } + + // Execute a connector. It listens for the abort signal and rejects the promise if it is triggered. + private async runConnector(params: { + actionTypeId: string; + actionId: string; + input: Record; + abortController: AbortController; + }): Promise> { + const { actionTypeId, actionId, input, abortController } = params; + // Execute the connector via the actions client + const executeActionPromise = this.actionsClient.execute({ actionId, params: input }); - const runConnectorPromise = this.runConnector(connectorName, inputs, spaceId); - const abortPromise = new Promise((resolve, reject) => { + const abortPromise = new Promise((_resolve, reject) => { abortController.signal.addEventListener('abort', () => - reject(new Error(`"${connectorName}" with type "${connectorType}" was aborted`)) + reject( + new Error(`Action type "${actionTypeId}" with ID "${actionId}" execution was aborted`) + ) ); }); // If the abort signal is triggered, the abortPromise will reject first - // Otherwise, the runConnectorPromise will resolve first + // Otherwise, the executeActionPromise will resolve first // This ensures that we handle cancellation properly. // This is a workaround for the fact that connectors do not natively support cancellation. // In the future, if connectors support cancellation, we can remove this logic. - await Promise.race([abortPromise, runConnectorPromise]); - return runConnectorPromise; - } - - private async runConnector( - connectorName: string, - connectorParams: Record, - spaceId: string - ): Promise> { - const connectorId = await this.resolveConnectorId(connectorName, spaceId); - - return (this.actionsClient as ActionsClient).execute({ - actionId: connectorId, - params: connectorParams, - }); + await Promise.race([abortPromise, executeActionPromise]); + return executeActionPromise; } - private async resolveConnectorId(connectorName: string, spaceId: string): Promise { - const allConnectors = await (this.actionsClient as ActionsClient).getAll(); + private async resolveConnectorId(connectorName: string): Promise { + const allConnectors = await this.actionsClient.getAll(); const connector = allConnectors.find( (c: ConnectorWithExtraFindData) => c.name === connectorName || c.id === connectorName diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/execution_functions/setup_dependencies.ts b/src/platform/plugins/shared/workflows_execution_engine/server/execution_functions/setup_dependencies.ts index b805b70a2ac2f..7111a19f689e9 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/execution_functions/setup_dependencies.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/execution_functions/setup_dependencies.ts @@ -14,7 +14,6 @@ import type { WorkflowsExecutionEngineConfig } from '../config'; import { ConnectorExecutor } from '../connector_executor'; import { WorkflowExecutionTelemetryClient } from '../lib/telemetry/workflow_execution_telemetry_client'; -import { UrlValidator } from '../lib/url_validator'; import { StepExecutionRepository } from '../repositories/step_execution_repository'; import { WorkflowExecutionRepository } from '../repositories/workflow_execution_repository'; import { NodesFactory } from '../step/nodes_factory'; @@ -113,10 +112,6 @@ export async function setupDependencies( const workflowTaskManager = new WorkflowTaskManager(taskManager); - const urlValidator = new UrlValidator({ - allowedHosts: config.http.allowedHosts, - }); - const stepExecutionRuntimeFactory = new StepExecutionRuntimeFactory({ workflowExecutionGraph, workflowExecutionState, @@ -131,7 +126,6 @@ export async function setupDependencies( connectorExecutor, workflowRuntime, workflowLogger, - urlValidator, workflowExecutionGraph, stepExecutionRuntimeFactory, dependencies 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 48740a91b075f..9cda27ab31f75 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 @@ -10,6 +10,8 @@ // TODO: Remove eslint exceptions comments and fix the issues /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import { SystemConnectorsMap } from '@kbn/workflows/common/constants'; import { ExecutionError } from '@kbn/workflows/server'; import type { BaseStep, RunStepResult } from './node_implementation'; import { BaseAtomicNodeImplementation } from './node_implementation'; @@ -78,18 +80,28 @@ export class ConnectorStepImpl extends BaseAtomicNodeImplementation; + if (connectorIdRendered) { + // Use regular connector with saved object + output = await this.connectorExecutor.execute({ + connectorType: stepType, + connectorNameOrId: connectorIdRendered, + input: renderedInputs, + abortController: this.stepExecutionRuntime.abortController, + }); + } else { + const systemConnectorActionTypeId = SystemConnectorsMap.get(`.${stepType}`); + if (systemConnectorActionTypeId) { + output = await this.connectorExecutor.executeSystemConnector({ + connectorType: systemConnectorActionTypeId, + input: renderedInputs, + abortController: this.stepExecutionRuntime.abortController, + }); + } else { + throw new Error(`Connector ID is required for connector type ${stepType}`); + } } - const output = await this.connectorExecutor.execute( - stepType, - connectorIdRendered, - renderedInputs, - step.spaceId, - this.stepExecutionRuntime.abortController - ); - const { data, status, message, serviceMessage } = output; if (status === 'ok') { diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/http_step_impl.test.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/http_step_impl.test.ts deleted file mode 100644 index 40e05062d99a6..0000000000000 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/http_step_impl.test.ts +++ /dev/null @@ -1,908 +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 axios from 'axios'; -import type { HttpGraphNode } from '@kbn/workflows/graph'; -import { ExecutionError } from '@kbn/workflows/server'; -import { HttpStepImpl } from './http_step_impl'; -import { UrlValidator } from '../../lib/url_validator'; -import type { StepExecutionRuntime } from '../../workflow_context_manager/step_execution_runtime'; -import type { WorkflowContextManager } from '../../workflow_context_manager/workflow_context_manager'; -import type { WorkflowExecutionRuntimeManager } from '../../workflow_context_manager/workflow_execution_runtime_manager'; -import type { IWorkflowEventLogger } from '../../workflow_event_logger'; - -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -// Mock the isAxiosError static method -jest.spyOn(axios, 'isAxiosError'); - -describe('HttpStepImpl', () => { - let httpStep: HttpStepImpl; - let mockStepExecutionRuntime: jest.Mocked; - let mockWorkflowRuntime: jest.Mocked; - let mockWorkflowLogger: jest.Mocked; - let mockUrlValidator: UrlValidator; - let mockStep: HttpGraphNode; - - let stepContextAbortController: AbortController; - let mockContextManager: jest.Mocked< - Pick - > & { - abortController: AbortController; - }; - - beforeEach(() => { - stepContextAbortController = new AbortController(); - mockContextManager = { - getContext: jest.fn(), - renderValueAccordingToContext: jest.fn((value: T): T => value), - abortController: stepContextAbortController, - } as any; - mockContextManager.renderValueAccordingToContext.mockReturnValue({ - url: 'rendered({{baseUrl}}/users)', - method: 'rendered(POST)', - headers: { Authorization: 'rendered(Bearer {{authToken}})' }, - body: { - id: 'rendered({{userId}})', - }, - }); - - mockStepExecutionRuntime = { - contextManager: mockContextManager, - startStep: jest.fn().mockResolvedValue(undefined), - finishStep: jest.fn().mockResolvedValue(undefined), - failStep: jest.fn().mockResolvedValue(undefined), - setInput: jest.fn().mockResolvedValue(undefined), - getCurrentStepState: jest.fn(), - setCurrentStepState: jest.fn().mockResolvedValue(undefined), - stepExecutionId: 'test-step-exec-id', - abortController: stepContextAbortController, - flushEventLogs: jest.fn().mockResolvedValue(undefined), - } as any; - - mockWorkflowRuntime = { - navigateToNextNode: jest.fn(), - } as any; - - mockWorkflowLogger = { - logInfo: jest.fn(), - logError: jest.fn(), - logDebug: jest.fn(), - } as any; - - mockUrlValidator = new UrlValidator({ allowedHosts: ['*'] }); - - mockStep = { - id: 'test-http-step', - type: 'http', - stepId: 'test-http-step', - stepType: 'http', - configuration: { - name: 'test-http-step', - type: 'http', - with: { - url: 'https://api.example.com/data', - method: 'POST', - headers: { - Authorization: 'Bearer {{authToken}}', - }, - body: { - id: '{{userId}}', - }, - timeout: '30s', - }, - }, - }; - - httpStep = new HttpStepImpl( - mockStep, - mockStepExecutionRuntime, - mockWorkflowLogger, - mockUrlValidator, - mockWorkflowRuntime - ); - - jest.clearAllMocks(); - }); - - afterEach(() => { - (mockedAxios as unknown as jest.Mock).mockReset(); - (axios.isAxiosError as unknown as jest.Mock).mockReset(); - }); - - describe('getInput', () => { - it('should render http step context', () => { - mockStep.configuration.with.url = '{{baseUrl}}/users'; - - httpStep.getInput(); - - expect(mockContextManager.renderValueAccordingToContext).toHaveBeenCalledWith({ - url: '{{baseUrl}}/users', - method: 'POST', - headers: { Authorization: 'Bearer {{authToken}}' }, - body: { - id: '{{userId}}', - }, - fetcher: undefined, - timeout: '30s', - }); - }); - - it('should return rendered inputs', () => { - const inputs = httpStep.getInput(); - - expect(inputs).toEqual({ - url: 'rendered({{baseUrl}}/users)', - method: 'rendered(POST)', - headers: { Authorization: 'rendered(Bearer {{authToken}})' }, - body: { - id: 'rendered({{userId}})', - }, - }); - }); - - it('should use default method and timeout', () => { - (mockStep.configuration.with as any).method = undefined; - (mockStep.configuration.with as any).timeout = undefined; - - httpStep.getInput(); - - expect(mockContextManager.renderValueAccordingToContext).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'GET', - }) - ); - }); - - it('should throw error when template rendering fails', () => { - const context = { - execution: { id: 'test-run', isTestRun: false, startedAt: new Date() }, - workflow: { id: 'test-workflow', name: 'Test', enabled: true, spaceId: 'default' }, - steps: {}, - }; - mockContextManager.renderValueAccordingToContext = jest.fn().mockImplementation(() => { - throw new Error('Template rendering failed'); - }); - mockContextManager.getContext.mockReturnValue(context as any); - // Use a filter that will throw an error (e.g., accessing undefined property) - mockStep.configuration.with.url = '{{ nonexistent | upper }}'; - - expect(() => httpStep.getInput()).toThrow(new Error('Template rendering failed')); - }); - }); - - describe('executeHttpRequest', () => { - it('should make successful GET request', async () => { - const mockResponse = { - status: 200, - statusText: 'OK', - headers: { 'content-type': 'application/json' }, - data: { success: true }, - }; - (mockedAxios as any).mockResolvedValueOnce(mockResponse); - - const input = { - url: 'https://api.example.com/data', - method: 'GET', - headers: {}, - timeout: '30s', - }; - - const result = await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://api.example.com/data', - method: 'GET', - headers: {}, - }) - ); - - expect(result).toEqual({ - input, - output: { - status: 200, - statusText: 'OK', - headers: { 'content-type': 'application/json' }, - data: { success: true }, - }, - error: undefined, - }); - }); - - it('should make successful with abort controller from step context', async () => { - (mockedAxios as any).mockResolvedValueOnce({}); - - const input = { - url: 'https://api.example.com/data', - method: 'GET', - headers: {}, - timeout: '30s', - }; - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - signal: stepContextAbortController.signal, - }) - ); - }); - - it('should make successful POST request with body', async () => { - const mockResponse = { - status: 201, - statusText: 'Created', - headers: {}, - data: { id: 123 }, - }; - (mockedAxios as any).mockResolvedValueOnce(mockResponse); - - const input = { - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { name: 'John Doe' }, - }; - - const result = await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - data: { name: 'John Doe' }, - }) - ); - - expect(result.output?.status).toBe(201); - expect(result.output?.data).toEqual({ id: 123 }); - }); - - it('should make successful POST request abort signal from step context', async () => { - (mockedAxios as any).mockResolvedValueOnce({}); - - const input = { - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { name: 'John Doe' }, - timeout: '30s', - }; - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - signal: stepContextAbortController.signal, - }) - ); - }); - - it('should support body for all HTTP methods', async () => { - const testBody = { data: 'test' }; - const methods: Array<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'> = [ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'PATCH', - ]; - - for (const method of methods) { - (mockedAxios as any).mockResolvedValueOnce({ - status: 200, - statusText: 'OK', - headers: {}, - data: {}, - }); - - const input = { - url: 'https://api.example.com/data', - method, - headers: { 'Content-Type': 'application/json' }, - body: testBody, - }; - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://api.example.com/data', - method, - data: testBody, - }) - ); - } - }); - }); - - describe('run', () => { - beforeEach(() => { - mockContextManager.renderValueAccordingToContext = jest.fn().mockReturnValue({ - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { name: 'John Doe' }, - }); - }); - - it('should execute the full workflow step lifecycle', async () => { - const mockResponse = { - status: 200, - statusText: 'OK', - headers: {}, - data: { success: true }, - }; - (mockedAxios as any).mockResolvedValueOnce(mockResponse); - - await httpStep.run(); - - expect(mockStepExecutionRuntime.startStep).toHaveBeenCalledWith(); - expect(mockStepExecutionRuntime.setInput).toHaveBeenCalledWith({ - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { name: 'John Doe' }, - }); - expect(mockStepExecutionRuntime.finishStep).toHaveBeenCalledWith({ - status: 200, - statusText: 'OK', - headers: {}, - data: { success: true }, - }); - expect(mockWorkflowRuntime.navigateToNextNode).toHaveBeenCalled(); - }); - - it('should return error about cancelled request if aborted', async () => { - const axiosError = { - code: 'ERR_CANCELED', - message: 'Some error', - }; - - (mockedAxios as unknown as jest.Mock).mockRejectedValueOnce(axiosError); - (mockedAxios as any).isAxiosError.mockReturnValue(true); - const input = { - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { name: 'John Doe' }, - }; - - const result = await (httpStep as any)._run(input); - - expect((mockedAxios as any).isAxiosError).toHaveBeenCalledWith(axiosError); - expect(result.error).toEqual( - new ExecutionError({ - type: 'HttpRequestCancelledError', - message: 'HTTP request was cancelled', - }) - ); - }); - - it('should fail the step and continue workflow when template rendering fails', async () => { - const context = { - execution: { id: 'test-run', isTestRun: false, startedAt: new Date() }, - workflow: { id: 'test-workflow', name: 'Test', enabled: true, spaceId: 'default' }, - steps: {}, - }; - mockContextManager.renderValueAccordingToContext = jest.fn().mockImplementation(() => { - throw new Error('Template rendering failed'); - }); - mockContextManager.getContext.mockReturnValue(context as any); - // Use a filter that will throw an error (strictFilters: true in templating engine) - mockStep.configuration.with.url = '{{ invalidVariable | nonExistentFilter }}'; - - await httpStep.run(); - - // Should not make HTTP request - expect(mockedAxios).not.toHaveBeenCalled(); - - // should start the step once - expect(mockStepExecutionRuntime.startStep).toHaveBeenCalled(); - - // Should start the step with undefined input - expect(mockStepExecutionRuntime.setInput).not.toHaveBeenCalled(); - - // Should fail the step with a clear error message - expect(mockStepExecutionRuntime.failStep).toHaveBeenCalledWith( - new ExecutionError({ - message: 'Template rendering failed', - type: 'Error', - }) - ); - - // Should navigate to next node (workflow continues) - expect(mockWorkflowRuntime.navigateToNextNode).toHaveBeenCalled(); - - // Should NOT call finishStep - expect(mockStepExecutionRuntime.finishStep).not.toHaveBeenCalled(); - }); - }); - - describe('URL validation', () => { - beforeEach(() => { - mockContextManager.renderValueAccordingToContext = jest - .fn() - .mockImplementation(() => mockStep.configuration.with); - }); - - it('should allow requests to permitted hosts', async () => { - mockStep.configuration.with = { - ...mockStep.configuration.with, - url: 'https://api.example.com/data', - method: 'GET', - }; - mockUrlValidator = new UrlValidator({ allowedHosts: ['api.example.com'] }); - httpStep = new HttpStepImpl( - mockStep, - mockStepExecutionRuntime, - mockWorkflowLogger, - mockUrlValidator, - mockWorkflowRuntime - ); - - (mockedAxios as any).mockResolvedValueOnce({ data: { success: true }, status: 200 }); - - await httpStep.run(); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://api.example.com/data', - method: 'GET', - }) - ); - }); - - it('should block requests to non-permitted hosts', async () => { - mockUrlValidator = new UrlValidator({ allowedHosts: ['api.example.com'] }); - mockStep.configuration.with = { - url: 'https://malicious.com/test', - method: 'GET', - headers: {}, - timeout: '30s', - }; - httpStep = new HttpStepImpl( - mockStep, - mockStepExecutionRuntime, - mockWorkflowLogger, - mockUrlValidator, - mockWorkflowRuntime - ); - - await httpStep.run(); - - // Should not make any HTTP request - expect(mockedAxios).not.toHaveBeenCalled(); - - // Should log the security error - expect(mockWorkflowLogger.logError).toHaveBeenCalledWith( - expect.stringContaining('HTTP request blocked'), - expect.any(Error), - expect.objectContaining({ - tags: ['http', 'security', 'blocked'], - }) - ); - - // Should start the step, fail the step, and navigate to next node - expect(mockStepExecutionRuntime.startStep).toHaveBeenCalled(); - expect(mockStepExecutionRuntime.failStep).toHaveBeenCalledWith( - new ExecutionError({ - message: - 'target url "https://malicious.com/test" is not added to the Kibana config workflowsExecutionEngine.http.allowedHosts', - type: 'Error', - }) - ); - expect(mockWorkflowRuntime.navigateToNextNode).toHaveBeenCalled(); - }); - - it('should allow all hosts when wildcard is configured', async () => { - mockStep.configuration.with = { - url: 'https://any-host.com/test', - method: 'GET', - headers: {}, - timeout: '30s', - }; - mockUrlValidator = new UrlValidator({ allowedHosts: ['*'] }); - httpStep = new HttpStepImpl( - mockStep, - mockStepExecutionRuntime, - mockWorkflowLogger, - mockUrlValidator, - mockWorkflowRuntime - ); - - (mockedAxios as any).mockResolvedValueOnce({ data: { success: true }, status: 200 }); - - await httpStep.run(); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://any-host.com/test', - method: 'GET', - }) - ); - }); - }); - - describe('Fetcher configuration', () => { - beforeEach(() => { - mockContextManager.renderValueAccordingToContext = jest.fn().mockReturnValue({ - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { name: 'John Doe' }, - }); - }); - - it('should apply skip_ssl_verification option', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { name: 'John Doe' }, - fetcher: { - skip_ssl_verification: true, - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: false, - }), - }), - }) - ); - }); - - it('should apply keep_alive option', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - keep_alive: true, - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - keepAlive: true, - }), - }), - }) - ); - }); - - it('should apply max_redirects option', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - max_redirects: 5, - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - maxRedirects: 5, - }) - ); - }); - - it('should disable redirects when follow_redirects is false', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - follow_redirects: false, - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - maxRedirects: 0, - }) - ); - }); - - it('should work without fetcher options', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.not.objectContaining({ - httpsAgent: expect.anything(), - }) - ); - }); - - it('should apply proxy_url option', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - proxy_url: 'http://corporate-proxy.example.com:8080', - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - proxy: { - protocol: 'http', - host: 'corporate-proxy.example.com', - port: 8080, - }, - }) - ); - }); - - it('should apply proxy_url with authentication', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - proxy_url: 'http://proxy.internal:3128', - proxy_username: 'proxyuser', - proxy_password: 'proxypass', - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - proxy: { - protocol: 'http', - host: 'proxy.internal', - port: 3128, - auth: { - username: 'proxyuser', - password: 'proxypass', - }, - }, - }) - ); - }); - - it('should not include proxy auth when only username is provided', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - proxy_url: 'http://proxy.internal:3128', - proxy_username: 'proxyuser', - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - proxy: { - protocol: 'http', - host: 'proxy.internal', - port: 3128, - }, - }) - ); - }); - - it('should default proxy port to 443 for https proxy_url', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - proxy_url: 'https://secure-proxy.example.com', - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - proxy: { - protocol: 'https', - host: 'secure-proxy.example.com', - port: 443, - }, - }) - ); - }); - - it('should combine proxy with skip_ssl_verification', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - proxy_url: 'http://proxy.internal:8080', - skip_ssl_verification: true, - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - proxy: { - protocol: 'http', - host: 'proxy.internal', - port: 8080, - }, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ - rejectUnauthorized: false, - }), - }), - }) - ); - }); - - it('should not set proxy when proxy_url is not provided', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - fetcher: { - skip_ssl_verification: true, - }, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - const calledConfig = (mockedAxios as any).mock.calls[0][0]; - expect(calledConfig.proxy).toBeUndefined(); - }); - }); - - describe('timeout', () => { - it('should apply timeout from input as axios timeout in milliseconds', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - timeout: '30s', - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 30000, - }) - ); - }); - - it('should apply custom timeout value', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - timeout: '5s', - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 5000, - }) - ); - }); - - it('should apply timeout in minutes', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - timeout: '2m', - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 120000, - }) - ); - }); - - it('should not set timeout when not provided', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - }; - - (mockedAxios as any).mockResolvedValueOnce({ status: 200, data: { success: true } }); - - await (httpStep as any)._run(input); - - const calledConfig = (mockedAxios as any).mock.calls[0][0]; - expect(calledConfig.timeout).toBeUndefined(); - }); - - it('should handle ECONNABORTED timeout error cleanly', async () => { - const input = { - url: 'https://api.example.com/users', - method: 'GET', - headers: {}, - timeout: '1s', - }; - - const timeoutError = new Error('timeout of 1000ms exceeded') as any; - timeoutError.code = 'ECONNABORTED'; - timeoutError.isAxiosError = true; - (mockedAxios as any).mockRejectedValueOnce(timeoutError); - (axios.isAxiosError as unknown as jest.Mock).mockReturnValue(true); - - const result = await (httpStep as any)._run(input); - - expect(result.error).toBeInstanceOf(ExecutionError); - expect(result.error.type).toBe('HttpRequestTimeout'); - expect(result.error.message).toBe('timeout of 1000ms exceeded'); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/http_step_impl.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/http_step_impl.ts deleted file mode 100644 index e78fb420d31ee..0000000000000 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/http_step/http_step_impl.ts +++ /dev/null @@ -1,267 +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". - */ - -// TODO: Remove eslint exceptions comments and fix the issues -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import axios, { type AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios'; -import https from 'https'; -import type { HttpFetcherConfigSchema } from '@kbn/workflows'; -import type { HttpGraphNode } from '@kbn/workflows/graph'; -import { ExecutionError } from '@kbn/workflows/server'; -import type { z } from '@kbn/zod/v4'; -import type { UrlValidator } from '../../lib/url_validator'; -import { parseDuration } from '../../utils'; -import type { StepExecutionRuntime } from '../../workflow_context_manager/step_execution_runtime'; -import type { WorkflowExecutionRuntimeManager } from '../../workflow_context_manager/workflow_execution_runtime_manager'; -import type { IWorkflowEventLogger } from '../../workflow_event_logger'; -import type { BaseStep, RunStepResult } from '../node_implementation'; -import { BaseAtomicNodeImplementation } from '../node_implementation'; - -type HttpHeaders = Record; - -/** - * Fetcher configuration options for customizing HTTP requests - * Derived from the Zod schema to ensure type safety and avoid duplication - */ -type FetcherOptions = NonNullable> & { - // Allow additional options to be passed through - [key: string]: any; -}; - -// Extend BaseStep for HTTP-specific properties -export interface HttpStep extends BaseStep { - with: { - url: string; - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - headers?: HttpHeaders; - body?: any; - fetcher?: FetcherOptions; - }; -} - -export class HttpStepImpl extends BaseAtomicNodeImplementation { - constructor( - node: HttpGraphNode, - stepExecutionRuntime: StepExecutionRuntime, - private workflowLogger: IWorkflowEventLogger, - private urlValidator: UrlValidator, - workflowRuntime: WorkflowExecutionRuntimeManager - ) { - const httpStep: HttpStep = { - name: node.configuration.name, - type: node.type, - spaceId: '', // TODO: get from context or node - with: node.configuration.with, - }; - super( - httpStep, - stepExecutionRuntime, - undefined, // no connector executor needed for HTTP - workflowRuntime - ); - } - - public getInput() { - const { url, method = 'GET', headers = {}, body, fetcher, timeout } = this.step.with as any; - - return this.stepExecutionRuntime.contextManager.renderValueAccordingToContext({ - url, - method, - headers, - body, - fetcher, - timeout, - }); - } - - protected async _run(input: any): Promise { - try { - return await this.executeHttpRequest(input); - } catch (error) { - return this.handleFailure(input, error); - } - } - - private async executeHttpRequest(input?: any): Promise { - const { url, method, headers, body, fetcher: fetcherOptions, timeout } = input; - - // Validate that the URL is allowed based on the allowedHosts configuration - try { - this.urlValidator.ensureUrlAllowed(url); - } catch (error) { - this.workflowLogger.logError( - `HTTP request blocked: ${error.message}`, - error instanceof Error ? error : new Error(String(error)), - { - workflow: { step_id: this.step.name }, - event: { action: 'http_request', outcome: 'failure' }, - tags: ['http', 'security', 'blocked'], - } - ); - throw error; - } - - this.workflowLogger.logInfo(`Making HTTP ${method} request to ${url}`, { - workflow: { step_id: this.step.name }, - event: { action: 'http_request', outcome: 'unknown' }, - tags: ['http', method.toLowerCase()], - }); - - const config: AxiosRequestConfig = { - url, - method, - headers, - signal: this.stepExecutionRuntime.abortController.signal, - ...(body && { data: body }), - ...(timeout && { timeout: parseDuration(timeout) }), - }; - - // Apply fetcher options if provided - if (fetcherOptions && Object.keys(fetcherOptions).length > 0) { - const { - skip_ssl_verification, - follow_redirects, - max_redirects, - keep_alive, - proxy_url, - proxy_username, - proxy_password, - } = fetcherOptions; - - // Configure HTTPS agent for SSL and keep-alive options - const httpsAgentOptions: https.AgentOptions = {}; - - if (skip_ssl_verification) { - httpsAgentOptions.rejectUnauthorized = false; - } - - if (keep_alive !== undefined) { - httpsAgentOptions.keepAlive = keep_alive; - } - - config.httpsAgent = new https.Agent(httpsAgentOptions); - - // Configure redirect behavior - if (follow_redirects === false) { - config.maxRedirects = 0; - } else if (max_redirects !== undefined) { - config.maxRedirects = max_redirects; - } - - // Configure proxy if provided - if (proxy_url) { - const parsedProxy = new URL(proxy_url); - config.proxy = { - protocol: parsedProxy.protocol.replace(':', ''), - host: parsedProxy.hostname, - port: Number(parsedProxy.port) || (parsedProxy.protocol === 'https:' ? 443 : 80), - ...(proxy_username && proxy_password - ? { auth: { username: proxy_username, password: proxy_password } } - : {}), - }; - } - } - - const response: AxiosResponse = await axios(config); - - this.workflowLogger.logInfo(`HTTP request completed with status ${response.status}`, { - workflow: { step_id: this.step.name }, - event: { action: 'http_request', outcome: 'success' }, - tags: ['http', method.toLowerCase()], - }); - - return { - input, - output: { - status: response.status, - statusText: response.statusText, - headers: response.headers, - data: response.data, - }, - error: undefined, - }; - } - - protected handleFailure(input: any, error: any): RunStepResult { - let executionError: ExecutionError; - - if (axios.isAxiosError(error)) { - executionError = this.mapAxiosError(error); - } else if (error instanceof Error) { - executionError = new ExecutionError({ - type: error.name, - message: error.message, - }); - } else { - executionError = new ExecutionError({ - type: 'UnknownError', - message: String(error), - }); - } - - this.workflowLogger.logError(`HTTP request failed: ${executionError.message}`, executionError, { - workflow: { step_id: this.step.name }, - event: { action: 'http_request', outcome: 'failure' }, - tags: ['http', 'error', executionError.type], - }); - - return { - input, - output: undefined, - error: executionError, - }; - } - - private mapAxiosError(error: AxiosError): ExecutionError { - if (error.code === 'ECONNREFUSED') { - const url = new URL(this.step.with.url); - return new ExecutionError({ - type: 'ConnectionRefused', - message: `Connection refused to ${url.origin}`, - }); - } - - if (error.code === 'ERR_CANCELED') { - return new ExecutionError({ - type: 'HttpRequestCancelledError', - message: 'HTTP request was cancelled', - }); - } - - if (error.code === 'ECONNABORTED') { - return new ExecutionError({ - type: 'HttpRequestTimeout', - message: error.message, - }); - } - - if (error.response) { - return new ExecutionError({ - type: 'HttpRequestError', - message: error.message, - details: { - headers: error.response.headers, - status: error.response.status, - statusText: error.response.statusText, - data: error.response.data, - }, - }); - } - - return new ExecutionError({ - type: error.code || 'UnknownHttpRequestError', - message: error.message, - details: error.config && { - url: error.config.url, - method: error.config.method, - }, - }); - } -} diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts index 756c52b3b5338..400a98841c0ff 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/nodes_factory.ts @@ -20,7 +20,6 @@ import type { ExitForeachNode, ExitNormalPathNode, ExitRetryNode, - HttpGraphNode, WorkflowGraph, } from '@kbn/workflows/graph'; import { @@ -35,7 +34,6 @@ import { CustomStepImpl } from './custom_step_impl'; import { DataSetStepImpl } from './data_set_step'; import { ElasticsearchActionStepImpl } from './elasticsearch_action_step'; import { EnterForeachNodeImpl, ExitForeachNodeImpl } from './foreach_step'; -import { HttpStepImpl } from './http_step'; import { EnterConditionBranchNodeImpl, EnterIfNodeImpl, @@ -62,7 +60,6 @@ import { } from './timeout_zone_step'; import { WaitStepImpl } from './wait_step/wait_step'; import type { ConnectorExecutor } from '../connector_executor'; -import type { UrlValidator } from '../lib/url_validator'; import type { StepExecutionRuntime } from '../workflow_context_manager/step_execution_runtime'; import type { StepExecutionRuntimeFactory } from '../workflow_context_manager/step_execution_runtime_factory'; import type { ContextDependencies } from '../workflow_context_manager/types'; @@ -74,7 +71,6 @@ export class NodesFactory { private connectorExecutor: ConnectorExecutor, // this is temporary, we will remove it when we have a proper connector executor private workflowRuntime: WorkflowExecutionRuntimeManager, private workflowLogger: IWorkflowEventLogger, // Assuming you have a logger interface - private urlValidator: UrlValidator, private workflowGraph: WorkflowGraph, private stepExecutionRuntimeFactory: StepExecutionRuntimeFactory, private dependencies: ContextDependencies @@ -155,6 +151,7 @@ export class NodesFactory { return this.createGenericStepNode(stepExecutionRuntime); } + // eslint-disable-next-line complexity private createGenericStepNode(stepExecutionRuntime: StepExecutionRuntime): NodeImplementation { const node = stepExecutionRuntime.node; const stepLogger = stepExecutionRuntime.stepLogger; @@ -263,12 +260,6 @@ export class NodesFactory { stepLogger ); case 'atomic': - // Default atomic step (connector-based) - // eslint-disable-next-line no-console - console.log( - '[NodesFactory] Creating AtomicStepImpl for node.type=atomic, stepType:', - node.stepType - ); return new AtomicStepImpl( node as AtomicGraphNode, stepExecutionRuntime, @@ -276,14 +267,6 @@ export class NodesFactory { this.workflowRuntime, stepLogger ); - case 'http': - return new HttpStepImpl( - node as HttpGraphNode, - stepExecutionRuntime, - stepLogger, - this.urlValidator, - this.workflowRuntime - ); default: throw new Error(`Unknown node type: ${node.stepType}`); } diff --git a/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts b/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts index 6296d0c8a750e..ee5c98a219226 100644 --- a/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/connector_action_schema.ts @@ -37,6 +37,8 @@ import { GenAIStreamResponseSchema, GenAITestParamsSchema, GenAITestResponseSchema, + HttpParamsSchema, + HttpResponseSchema, InferenceCompletionParamsSchema, InferenceCompletionResponseSchema, InferenceRerankParamsSchema, @@ -115,8 +117,6 @@ import { TinesWebhooksParamsSchema, TorqParamsSchema, TorqResponseSchema, - WebhookParamsSchema, - WebhookResponseSchema, } from './stack_connectors_schema'; /** @@ -137,7 +137,7 @@ export const ConnectorSpecsInputSchemas = new Map([ ['.slack', SlackParamsSchema], ['.email', EmailParamsSchema], - ['.webhook', WebhookParamsSchema], + ['.http', HttpParamsSchema], ['.teams', TeamsParamsSchema], ['.bedrock', BedrockParamsSchema], ['.openai', OpenAIParamsSchema], @@ -296,7 +296,7 @@ export const ConnectorActionInputSchemas = new Map([ ['.slack', SlackResponseSchema], ['.email', EmailResponseSchema], - ['.webhook', WebhookResponseSchema], + ['.http', HttpResponseSchema], ['.teams', TeamsResponseSchema], ['.bedrock', BedrockResponseSchema], ['.openai', OpenAIResponseSchema], diff --git a/src/platform/plugins/shared/workflows_management/common/examples/automated_triaging.yaml b/src/platform/plugins/shared/workflows_management/common/examples/automated_triaging.yaml index 87b24e807a60b..9695de3a24876 100644 --- a/src/platform/plugins/shared/workflows_management/common/examples/automated_triaging.yaml +++ b/src/platform/plugins/shared/workflows_management/common/examples/automated_triaging.yaml @@ -74,53 +74,6 @@ steps: kbn-xsrf: string Content-Type: application/json Authorization: '{{ consts.secret }}' - - name: triage_agent - type: http - with: - url: '{{ consts.kibana }}/api/security_ai_assistant/chat/complete' - method: POST - headers: - kbn-xsrf: string - Content-Type: application/json - Authorization: '{{ consts.secret }}' - body: - isStream: false - persist: false - connectorId: '{{ consts.sonnet }}' - messages: - - role: user - content: >- - - - How would we remediate and respond to the attack below? - - - Reference our knowledge for our in-depth guides, and, knowing this is Elastic Defend, generate any - commands required for remediation that can be run via the Elastic Security Response console (use ONLY - the product documentation tool for this). Leverage the open alerts tool if you need process pid's or - anything else to appropriate stitch these commands together. Output any commands as individual code - blocks. - - - NEVER Render any videos/gifs. - - - Generate sophisticated, performant ES|QL queries with the appropriate ES|QL tool to help me triage - further (format them as "esql" appropriately). Make sure to use the logs-* index pattern - - - ALWAYS Reference Elastic Security labs for any matching attacks - - - Include a confidence score of the true positive ratio of the attack. This output will be shared will - all relevant team members. - - - Make sure to never include references or citations - - - - - - - {{ event | json:2 }} - - - timeout: 10m - name: add_analysis_to_case type: kibana.request with: @@ -183,116 +136,6 @@ steps: kbn-xsrf: string Content-Type: application/json Authorization: '{{ consts.secret }}' - - name: notify_team - type: http - with: - url: '{{ consts.slack_web_hook }}' - method: POST - headers: - content-type: application/json - body: | - { - "blocks": [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "🚨 Attack Discovery: {{foreach.item.attack_discovery.title_with_replacements}}", - "emoji": true - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{{steps.ai_summary.output.data}} \n" - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*📝 Current Status:*\n \n• ✅ Security case created with {{foreach.item.attack_discovery.alerts_context_count}} related alerts consolidated for investigation\n• ✅ Initial threat analysis completed by AI security agent\n• ✅ Affected host `srv-win-defend-07` isolated from network pending further review\n• ✅ Forensic data collection and evidence preservation in progress\n• ✅ Incident response workflow initiated with automated triage complete" - } - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Risk Score:* `{{foreach.item.risk_score}}`" - }, - { - "type": "mrkdwn", - "text": "*Alert Count:* {{foreach.item.attack_discovery.alerts_context_count}}" - }, - { - "type": "mrkdwn", - "text": "*Status:* {{foreach.item.status}}" - }, - { - "type": "mrkdwn", - "text": "*Time:* {{foreach.item.start}}" - } - ] - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Case ID: `{{steps.create_case.output.id}}` | {{foreach.item.attack_discovery.alerts_context_count}} related alerts | MITRE: {{foreach.item.attack_discovery.mitre_attack_tactics}}" - } - ] - }, - { - "type": "divider" - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "🔎 View Attack in Kibana", - "emoji": true - }, - "style": "primary", - "url": "{{foreach.item.url}}" - }, - { - "type": "button", - "text": { - "type": "plain_text", - "text": "📁 Go to Case", - "emoji": true - }, - "url": "{{consts.kibana}}/app/security/cases/{{steps.create_case.output.id}}" - }, - { - "type": "button", - "text": { - "type": "plain_text", - "text": "💻 View Host", - "emoji": true - }, - "url": "{{consts.kibana}}/app/security/administration/endpoints?agent_ids=345d5ffc-80a8-413c-9a5d-829687e8a5f2" - }, - { - "type": "button", - "text": { - "type": "plain_text", - "text": "🤖 View Workflow Execution", - "emoji": true - }, - "url": "{{consts.kibana}}/app/workflows/{{ workflow.id }}?executionId={{ execution.id }}&tab=executions" - } - ] - } - ] - } - timeout: 30s consts: schedule_id: 'REDACTED' kibana: 'REDACTED' diff --git a/src/platform/plugins/shared/workflows_management/common/schema.ts b/src/platform/plugins/shared/workflows_management/common/schema.ts index 5ef3820bf3318..cba24168f51fa 100644 --- a/src/platform/plugins/shared/workflows_management/common/schema.ts +++ b/src/platform/plugins/shared/workflows_management/common/schema.ts @@ -17,6 +17,7 @@ import { generateYamlSchemaFromConnectors, getElasticsearchConnectors, getKibanaConnectors, + SystemConnectorsMap, } from '@kbn/workflows'; import { z } from '@kbn/zod/v4'; @@ -126,6 +127,12 @@ function convertDynamicConnectorsToContractsInternal( Object.values(connectorTypes).forEach((connectorType) => { try { const connectorTypeName = connectorType.actionTypeId.replace(/^\./, ''); + + // If the connector has a system connector associated, it can be executed without a connector-id + const hasConnectorId = SystemConnectorsMap.has(connectorType.actionTypeId) + ? 'optional' + : 'required'; + // If the connector has sub-actions, create separate contracts for each sub-action if (connectorType.subActions && connectorType.subActions.length > 0) { connectorType.subActions.forEach((subAction) => { @@ -140,7 +147,7 @@ function convertDynamicConnectorsToContractsInternal( type: subActionType, summary: subAction.displayName, paramsSchema, - hasConnectorId: 'required', + hasConnectorId, outputSchema, description: `${connectorType.displayName} - ${subAction.displayName}`, instances: connectorType.instances, @@ -157,7 +164,7 @@ function convertDynamicConnectorsToContractsInternal( type: connectorTypeName, summary: connectorType.displayName, paramsSchema, - hasConnectorId: 'required', + hasConnectorId, outputSchema, description: `${connectorType.displayName} connector`, instances: connectorType.instances, @@ -171,7 +178,6 @@ function convertDynamicConnectorsToContractsInternal( type: connectorType.actionTypeId, summary: connectorType.displayName, paramsSchema: z.any(), - hasConnectorId: 'required', outputSchema: z.any(), description: `${connectorType.displayName || connectorType.actionTypeId} connector`, instances: connectorType.instances, diff --git a/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/http.ts b/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/http.ts new file mode 100644 index 0000000000000..4abccc0621057 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/http.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * This was generated based on x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/ + * and will be deprecated once connectors will expose their schemas + */ + +import { z } from '@kbn/zod/v4'; + +// HTTP connector parameter schema +export const HttpParamsSchema = z.object({ + url: z + .url() + .optional() + .describe( + 'The base URL to send the request to. If `connector-id` is provided the configured URL will be used and this value will be ignored.' + ), + path: z.string().optional().describe('The path appended to the base URL.'), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('GET'), + body: z.string().optional(), + query: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + timeout: z.number().positive().optional(), + fetcher: z + .object({ + skip_ssl_verification: z.boolean().optional(), + follow_redirects: z.boolean().optional(), + max_redirects: z.number().optional(), + keep_alive: z.boolean().optional(), + }) + .optional(), +}); + +// HTTP connector response schema +export const HttpResponseSchema = z.object({ + status: z.number(), + statusText: z.string(), + data: z.any(), + headers: z.record(z.string(), z.string()), +}); diff --git a/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/index.ts b/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/index.ts index e16ef95b54442..6d81a1b9a6769 100644 --- a/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/index.ts +++ b/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/index.ts @@ -27,8 +27,8 @@ export { SlackParamsSchema, SlackResponseSchema } from './slack'; // Email connector schemas export { EmailParamsSchema, EmailResponseSchema } from './email'; -// Webhook connector schemas -export { WebhookParamsSchema, WebhookResponseSchema } from './webhook'; +// HTTP connector schemas +export { HttpParamsSchema, HttpResponseSchema } from './http'; // Jira connector schemas export { diff --git a/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/webhook.ts b/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/webhook.ts deleted file mode 100644 index 8a23ddf900a21..0000000000000 --- a/src/platform/plugins/shared/workflows_management/common/stack_connectors_schema/webhook.ts +++ /dev/null @@ -1,29 +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". - */ - -/** - * This was generated based on x-pack/platform/plugins/shared/stack_connectors/server/connector_types/webhook/schema.ts - * and will be deprecated once connectors will expose their schemas - */ - -import { z } from '@kbn/zod/v4'; - -// Webhook connector parameter schema -export const WebhookParamsSchema = z.object({ - body: z.string().optional(), - headers: z.record(z.string(), z.string()).optional(), -}); - -// Webhook connector response schema -export const WebhookResponseSchema = z.object({ - status: z.number(), - statusText: z.string(), - data: z.any(), - headers: z.record(z.string(), z.string()), -}); diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts index a12a764f0957b..2f1efa21ede75 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/hardcoded_icons.ts @@ -12,7 +12,6 @@ import branch from './icons/branch.svg'; import clock from './icons/clock.svg'; import console from './icons/console.svg'; import email from './icons/email.svg'; -import globe from './icons/globe.svg'; import elasticsearchLogoSvg from './icons/logo_elasticsearch.svg'; import kibanaLogoSvg from './icons/logo_kibana.svg'; import slackLogoSvg from './icons/logo_slack.svg'; @@ -31,7 +30,6 @@ export const HardcodedIcons: Record = { elasticsearch: elasticsearchLogoSvg, kibana: kibanaLogoSvg, console, - http: globe, 'data.set': tableOfContents, foreach: refresh, if: branch, diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/globe.svg b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/globe.svg deleted file mode 100644 index 497b070680af5..0000000000000 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/icons/globe.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts index 11a560735451a..72ccae684aba5 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/step_icons/monochrome_icons.ts @@ -11,7 +11,6 @@ export const MonochromeIcons = new Set([ 'manual', 'alert', 'scheduled', - 'http', 'console', 'if', 'foreach', @@ -19,6 +18,7 @@ export const MonochromeIcons = new Set([ 'merge', 'wait', // connector icons, which are monochrome and should be colored with currentColor + '.http', '.inference', '.email', '.gen-ai', diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts index fea9410de29b1..98b4139b3a0a4 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.test.ts @@ -99,10 +99,6 @@ describe('getConnectorTypeSuggestions', () => { label: 'merge', kind: monaco.languages.CompletionItemKind.Interface, }), - expect.objectContaining({ - label: 'http', - kind: monaco.languages.CompletionItemKind.Reference, - }), expect.objectContaining({ label: 'wait', kind: monaco.languages.CompletionItemKind.Constant, @@ -116,13 +112,13 @@ describe('getConnectorTypeSuggestions', () => { // Check for built-in steps const builtInSteps = result.filter((s) => - ['foreach', 'if', 'parallel', 'merge', 'http', 'wait'].includes(s.label as string) + ['foreach', 'if', 'parallel', 'merge', 'wait'].includes(s.label as string) ); - expect(builtInSteps).toHaveLength(6); + expect(builtInSteps).toHaveLength(5); // Check for connectors const connectorSuggestions = result.filter( - (s) => !['foreach', 'if', 'parallel', 'merge', 'http', 'wait'].includes(s.label as string) + (s) => !['foreach', 'if', 'parallel', 'merge', 'wait'].includes(s.label as string) ); expect(connectorSuggestions.length).toBeGreaterThan(0); }); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts index 255a5f9ff820e..4d5da783716f6 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/autocomplete/suggestions/connector_type/get_connector_type_suggestions.ts @@ -12,7 +12,6 @@ import type { BuiltInStepType, ConnectorTypeInfo } from '@kbn/workflows'; import { DataSetStepSchema, ForEachStepSchema, - HttpStepSchema, IfStepSchema, MergeStepSchema, ParallelStepSchema, @@ -229,11 +228,6 @@ function getBuiltInStepTypesFromSchema(): Array<{ description: 'Define or compute variables for use in the workflow', icon: monaco.languages.CompletionItemKind.Variable, }, - { - schema: HttpStepSchema, - description: 'Make HTTP requests', - icon: monaco.languages.CompletionItemKind.Reference, - }, { schema: WaitStepSchema, description: 'Wait for a specified duration', diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/generic_monaco_connector_handler.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/generic_monaco_connector_handler.ts index af412ad2d9eab..ee13d0cd7158c 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/generic_monaco_connector_handler.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/generic_monaco_connector_handler.ts @@ -107,22 +107,6 @@ ${Object.entries(connectorInfo.examples.params || {}) * Categorize connector types to provide better help */ private getConnectorInfo(connectorType: string): ConnectorInfo | null { - // HTTP-related connectors - if (connectorType.includes('http') || connectorType.includes('webhook')) { - return { - name: 'HTTP', - description: 'HTTP request connector for web API integration', - documentation: 'Configure URL, method, headers, and body parameters', - examples: { - params: { - url: 'https://api.example.com/endpoint', - method: 'GET', - headers: { Authorization: 'Bearer token' }, - }, - }, - }; - } - // Slack connectors if (connectorType.includes('slack')) { return { diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/http_connector_step_handler.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/http_connector_step_handler.ts new file mode 100644 index 0000000000000..84c43576b4b74 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/http_connector_step_handler.ts @@ -0,0 +1,239 @@ +/* + * 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 { monaco } from '@kbn/monaco'; +import { BaseMonacoConnectorHandler } from './base_monaco_connector_handler'; +import type { + ConnectorExamples, + ConnectorInfo, + HoverContext, +} from '../monaco_providers/provider_interfaces'; + +/** + * Extended connector info with HTTP-specific examples + */ +interface HttpConnectorInfo extends ConnectorInfo { + examples?: ConnectorExamples & { + connectorIdParams?: Record; + }; +} + +/** + * HTTP Monaco connector handler + * Provides basic hover information and examples for HTTP connector types + */ +export class HttpMonacoConnectorStepHandler extends BaseMonacoConnectorHandler { + constructor() { + super('HttpMonacoConnectorStepHandler', 100, ['http']); + } + + /** + * Generate HTTP hover content + */ + async generateHoverContent(context: HoverContext): Promise { + try { + const { connectorType, stepContext } = context; + + if (!stepContext) { + return null; + } + + // Determine connector category + const connectorInfo = this.getConnectorInfo(connectorType); + if (!connectorInfo) { + return null; + } + + // Create basic hover content + const content = [ + `**Workflow Connector**: \`${connectorType}\``, + '', + this.createConnectorOverview( + connectorType, + `${connectorInfo.name} connector for workflow automation`, + [ + `**Type**: ${connectorInfo.description}`, + connectorInfo.documentation ? `**Documentation**: ${connectorInfo.documentation}` : '', + ].filter(Boolean) + ), + '', + this.generateUsageModesHelp(), + '', + this.generateParameterHelp(connectorType), + '', + '_💡 Tip: Use configured connectors to securely manage authentication credentials_', + ].join('\n'); + + return this.createMarkdownContent(content); + } catch (error) { + // console.warn('HttpMonacoConnectorStepHandler: Error generating hover content', error); + return null; + } + } + + /** + * Get examples for HTTP connector types + */ + getExamples(connectorType: string): ConnectorExamples | null { + const connectorInfo = this.getConnectorInfo(connectorType); + if (!connectorInfo) { + return null; + } + + // Return examples for both usage modes + if (connectorInfo.examples) { + const stepName = `${connectorType.replace(/[^a-zA-Z0-9]/g, '_')}_step`; + + const httpExamples = connectorInfo.examples as HttpConnectorInfo['examples']; + + // Example with connector-id (recommended) + const connectorIdExample = `- name: ${stepName} + type: ${connectorType} + connector-id: 1234-abcd-5678 + with: +${Object.entries(httpExamples?.connectorIdParams || {}) + .map( + ([key, value]) => + ` ${key}: ${typeof value === 'string' ? `"${value}"` : JSON.stringify(value)}` + ) + .join('\n')}`; + + // Example with direct URL (legacy) + const directUrlExample = `- name: ${stepName}_legacy + type: ${connectorType} + with: +${Object.entries(httpExamples?.params || {}) + .map( + ([key, value]) => + ` ${key}: ${typeof value === 'string' ? `"${value}"` : JSON.stringify(value)}` + ) + .join('\n')}`; + + return { + params: httpExamples?.connectorIdParams || httpExamples?.params, + snippet: `${connectorIdExample}\n\n# Legacy format (still supported):\n${directUrlExample}`, + }; + } + + return null; + } + + /** + * Categorize connector types to provide better help + */ + private getConnectorInfo(connectorType: string): HttpConnectorInfo | null { + // HTTP-related connectors + if (connectorType === 'http') { + return { + name: 'HTTP', + description: 'HTTP request connector for web API integration', + documentation: 'Supports both configured connectors (recommended) and direct URL requests', + examples: { + // Direct URL mode (legacy) + params: { + url: 'https://api.example.com/endpoint', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + // Connector-id mode (recommended) + connectorIdParams: { + path: '/endpoint', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + }, + }; + } + + return null; + } + + /** + * Generate usage modes help + */ + private generateUsageModesHelp(): string { + return [ + '**Usage Modes:**', + '', + '**1. Using Configured Connector (Recommended)**', + 'Use a configured connector to securely manage authentication credentials:', + '```yaml', + '- name: http_step', + ' type: http', + ' connector-id: 1234-abcd-5678', + ' with:', + ' path: /api/v1/entity # Base url already configured in the connector configuration', + ' method: POST', + ' headers:', + ' Content-Type: application/json', + '```', + '', + '**2. Direct URL (Legacy)**', + 'Define the full URL directly (credentials must be included in headers):', + '```yaml', + '- name: http_step', + ' type: http', + ' with:', + ' url: https://api.example.com/api/v1/entity', + ' method: POST', + ' headers:', + ' Authorization: Bearer token', + '```', + ].join('\n'); + } + + /** + * Generate parameter help + */ + private generateParameterHelp(connectorType: string): string { + const lines = [ + '**Parameters:**', + '', + '**When using `connector-id`:**', + "- `path` (string, required): API endpoint path appended to the connector's base URL", + '- `method` (string, required): HTTP method (GET, POST, PUT, DELETE, etc.)', + '- `headers` (object, optional): Additional HTTP headers', + '- `body` (string/object, optional): Request body for POST/PUT requests', + '', + '**When using direct URL (legacy):**', + '- `url` (string, required): Full URL including protocol and domain', + '- `method` (string, required): HTTP method (GET, POST, PUT, DELETE, etc.)', + '- `headers` (object, optional): HTTP headers including authentication', + '- `body` (string/object, optional): Request body for POST/PUT requests', + '', + '**Common:**', + '- Use template variables like `{{ inputs.value }}` for dynamic values', + '- Reference previous step outputs with `{{ steps.step_name.output }}`', + ]; + + // Add connector-specific example parameters + const connectorInfo = this.getConnectorInfo(connectorType); + if (connectorInfo?.examples) { + const httpExamples = connectorInfo.examples as HttpConnectorInfo['examples']; + if (httpExamples?.connectorIdParams) { + lines.push('', '**Example with connector-id:**'); + for (const [key, value] of Object.entries(httpExamples.connectorIdParams)) { + lines.push( + `- \`${key}\`: ${typeof value === 'string' ? `"${value}"` : JSON.stringify(value)}` + ); + } + } + if (httpExamples?.params) { + lines.push('', '**Example with direct URL:**'); + for (const [key, value] of Object.entries(httpExamples.params)) { + lines.push( + `- \`${key}\`: ${typeof value === 'string' ? `"${value}"` : JSON.stringify(value)}` + ); + } + } + } + + return lines.join('\n'); + } +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/index.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/index.ts index fa7fda62f527d..0884bd493270c 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/index.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/monaco_connectors/index.ts @@ -14,3 +14,4 @@ export { BaseMonacoConnectorHandler } from './base_monaco_connector_handler'; export { ElasticsearchMonacoConnectorHandler } from './elasticsearch_connector_handler'; export { KibanaMonacoConnectorHandler } from './kibana_monaco_connector_handler'; export { GenericMonacoConnectorHandler } from './generic_monaco_connector_handler'; +export { HttpMonacoConnectorStepHandler } from './http_connector_step_handler'; diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts index f445bf35b8247..34be2aa488fde 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/generate_builtin_step_snippet.ts @@ -71,11 +71,6 @@ export function generateBuiltInStepSnippet( }, }; break; - case 'http': - parameters = { - with: { url: 'https://api.example.com', method: 'GET' }, - }; - break; case 'wait': parameters = { with: { duration: '5s' }, diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts index f95107cc962b2..91d7940e06dc1 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts @@ -34,13 +34,13 @@ describe('insertStepSnippet', () => { const inputYaml = `name: one_step_workflow`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'wait'); + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('wait', { full: true, withStepsSection: true, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('wait', { full: true, withStepsSection: true, }); @@ -59,19 +59,19 @@ describe('insertStepSnippet', () => { it('should insert a step snippet after the last step', () => { const inputYaml = `name: one_step_workflow steps: - - name: get_google - type: http + - name: wait_step + type: wait with: - url: https://google.com`; + duration: 5s`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'wait'); + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('wait', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('wait', { full: true, withStepsSection: false, }); @@ -94,19 +94,19 @@ steps: type: foreach foreach: "{{ context.items }}" steps: - - name: get_google - type: http + - name: wait_step + type: wait with: - url: https://google.com # <- cursor is here`; + duration: 5s # <- cursor is here`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'wait'); + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('wait', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('wait', { full: true, withStepsSection: false, }); @@ -129,10 +129,10 @@ steps: type: foreach foreach: "{{ context.items }}" steps: - - name: get_google - type: http + - name: wait_step + type: wait with: - url: https://google.com # <- cursor is here + duration: 5s # <- cursor is here - name: log_result type: console with: @@ -143,15 +143,15 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'wait', new monaco.Position(10, 33) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('wait', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('wait', { full: true, withStepsSection: false, }); @@ -171,10 +171,10 @@ steps: it('should insert connector snippet if step type is not a built-in', () => { const inputYaml = `name: one_step_workflow steps: - - name: get_google - type: http + - name: wait_step + type: wait with: - url: https://google.com`; + duration: 5s`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); insertStepSnippet( @@ -199,7 +199,7 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', undefined, mockEditor ); @@ -211,14 +211,14 @@ steps: const inputYaml = ``; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'if'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: true, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: true, }); @@ -239,14 +239,14 @@ steps: const inputYaml = `steps:`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'if'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -266,14 +266,14 @@ steps: const inputYaml = `steps: []`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'if'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -295,14 +295,14 @@ steps: -`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'if'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -326,14 +326,14 @@ steps: type: http`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'if'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -362,16 +362,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(3, 1) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -399,16 +399,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(1, 7) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -445,16 +445,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(5, 30) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -485,16 +485,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(6, 1) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -514,16 +514,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(1, 3) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -545,16 +545,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(2, 5) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -582,16 +582,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(2, 1) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -620,16 +620,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(4, 1) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -657,16 +657,16 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(2, 1) ); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -688,14 +688,14 @@ steps: ### Hello world`; const model = createFakeMonacoModel(inputYaml); const yamlDocument = parseDocument(inputYaml); - insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'if'); - expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('if', { full: true, withStepsSection: false, }); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -727,11 +727,11 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(8, 1) ); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -762,11 +762,11 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(8, 1) ); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); @@ -801,11 +801,11 @@ steps: insertStepSnippet( model as unknown as monaco.editor.ITextModel, yamlDocument, - 'http', + 'if', new monaco.Position(12, 1) ); - const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('if', { full: true, withStepsSection: false, }); 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 eb575a651fa57..a2992c1b58557 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 @@ -75,6 +75,7 @@ import { interceptMonacoYamlHoverProvider } from '../lib/hover/intercept_monaco_ import { ElasticsearchMonacoConnectorHandler, GenericMonacoConnectorHandler, + HttpMonacoConnectorStepHandler, KibanaMonacoConnectorHandler, } from '../lib/monaco_connectors'; import { CustomMonacoStepHandler } from '../lib/monaco_connectors/custom_monaco_step_handler'; @@ -369,6 +370,9 @@ export const WorkflowYAMLEditor = ({ const genericHandler = new GenericMonacoConnectorHandler(); registerMonacoConnectorHandler(genericHandler); + const httpHandler = new HttpMonacoConnectorStepHandler(); + registerMonacoConnectorHandler(httpHandler); + // Create unified providers with template expression support const providerConfig = { getYamlDocument: () => yamlDocumentRef.current || null, diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index 994a7cacb83a2..302a58cbb86a9 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -1172,6 +1172,323 @@ Object { } `; +exports[`Connector type config checks detect connector type changes for: .http 1`] = ` +Object { + "$ref": "#/definitions/config", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": Object { + "config": Object { + "additionalProperties": false, + "properties": Object { + "accessTokenUrl": Object { + "type": "string", + }, + "additionalFields": Object { + "type": Array [ + "string", + "null", + ], + }, + "authType": Object { + "default": "webhook-authentication-basic", + "enum": Array [ + "webhook-authentication-basic", + "webhook-authentication-ssl", + "webhook-oauth2-client-credentials", + null, + ], + "type": Array [ + "string", + "null", + ], + }, + "ca": Object { + "type": "string", + }, + "certType": Object { + "enum": Array [ + "ssl-crt-key", + "ssl-pfx", + ], + "type": "string", + }, + "clientId": Object { + "type": "string", + }, + "hasAuth": Object { + "default": true, + "type": "boolean", + }, + "headers": Object { + "anyOf": Array [ + Object { + "additionalProperties": Object { + "type": "string", + }, + "type": "object", + }, + Object { + "type": "null", + }, + ], + "default": null, + }, + "scope": Object { + "type": "string", + }, + "url": Object { + "format": "uri", + "type": "string", + }, + "verificationMode": Object { + "enum": Array [ + "none", + "certificate", + "full", + ], + "type": "string", + }, + }, + "required": Array [ + "url", + ], + "type": "object", + }, + }, +} +`; + +exports[`Connector type config checks detect connector type changes for: .http 2`] = ` +Object { + "$ref": "#/definitions/secrets", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": Object { + "secrets": Object { + "additionalProperties": false, + "properties": Object { + "clientSecret": Object { + "default": null, + "type": Array [ + "string", + "null", + ], + }, + "crt": Object { + "default": null, + "type": Array [ + "string", + "null", + ], + }, + "key": Object { + "default": null, + "type": Array [ + "string", + "null", + ], + }, + "password": Object { + "default": null, + "type": Array [ + "string", + "null", + ], + }, + "pfx": Object { + "default": null, + "type": Array [ + "string", + "null", + ], + }, + "secretHeaders": Object { + "anyOf": Array [ + Object { + "additionalProperties": Object { + "type": "string", + }, + "type": "object", + }, + Object { + "type": "null", + }, + ], + "default": null, + }, + "user": Object { + "default": null, + "type": Array [ + "string", + "null", + ], + }, + }, + "type": "object", + }, + }, +} +`; + +exports[`Connector type config checks detect connector type changes for: .http 3`] = ` +Object { + "$ref": "#/definitions/params", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": Object { + "params": Object { + "additionalProperties": false, + "properties": Object { + "body": Object { + "type": "string", + }, + "fetcher": Object { + "additionalProperties": false, + "properties": Object { + "follow_redirects": Object { + "type": "boolean", + }, + "keep_alive": Object { + "type": "boolean", + }, + "max_redirects": Object { + "type": "number", + }, + "skip_ssl_verification": Object { + "type": "boolean", + }, + }, + "type": "object", + }, + "headers": Object { + "additionalProperties": Object { + "type": "string", + }, + "type": "object", + }, + "method": Object { + "default": "GET", + "enum": Array [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + ], + "type": "string", + }, + "path": Object { + "type": "string", + }, + "query": Object { + "additionalProperties": Object { + "type": "string", + }, + "type": "object", + }, + "url": Object { + "format": "uri", + "type": "string", + }, + }, + "type": "object", + }, + }, +} +`; + +exports[`Connector type config checks detect connector type changes for: .http-system 1`] = ` +Object { + "$ref": "#/definitions/config", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": Object { + "config": Object { + "additionalProperties": false, + "properties": Object {}, + "type": "object", + }, + }, +} +`; + +exports[`Connector type config checks detect connector type changes for: .http-system 2`] = ` +Object { + "$ref": "#/definitions/secrets", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": Object { + "secrets": Object { + "additionalProperties": false, + "properties": Object {}, + "type": "object", + }, + }, +} +`; + +exports[`Connector type config checks detect connector type changes for: .http-system 3`] = ` +Object { + "$ref": "#/definitions/params", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": Object { + "params": Object { + "additionalProperties": false, + "properties": Object { + "body": Object { + "type": "string", + }, + "fetcher": Object { + "additionalProperties": false, + "properties": Object { + "follow_redirects": Object { + "type": "boolean", + }, + "keep_alive": Object { + "type": "boolean", + }, + "max_redirects": Object { + "type": "number", + }, + "skip_ssl_verification": Object { + "type": "boolean", + }, + }, + "type": "object", + }, + "headers": Object { + "additionalProperties": Object { + "type": "string", + }, + "type": "object", + }, + "method": Object { + "default": "GET", + "enum": Array [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + ], + "type": "string", + }, + "path": Object { + "type": "string", + }, + "query": Object { + "additionalProperties": Object { + "type": "string", + }, + "type": "object", + }, + "url": Object { + "format": "uri", + "type": "string", + }, + }, + "type": "object", + }, + }, +} +`; + exports[`Connector type config checks detect connector type changes for: .index 1`] = ` Object { "$ref": "#/definitions/config", diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/mocks/connector_types.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/mocks/connector_types.ts index 01b9f172ee280..23ebb222c8434 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/mocks/connector_types.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/mocks/connector_types.ts @@ -40,4 +40,6 @@ export const connectorTypes: string[] = [ '.cases', '.workflows', '.observability-ai-assistant', + '.http', + '.http-system', ]; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.test.ts index 3b8e0838c6ef1..3cdf279b570a4 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.test.ts @@ -414,6 +414,27 @@ describe('request', () => { expect(axiosMock.mock.calls[1][1]!.timeout).toBe(360001); }); + test('should use keepAlive when provided', async () => { + await request({ + axios, + url: '/test', + data: { id: '123' }, + logger, + configurationUtilities, + keepAlive: true, + }); + expect(axiosMock).toHaveBeenCalledWith( + '/test', + expect.objectContaining({ + method: 'get', + data: { id: '123' }, + httpsAgent: expect.objectContaining({ + options: expect.objectContaining({ keepAlive: true }), + }), + }) + ); + }); + test('throw an error if you use baseUrl in your axios instance', async () => { await expect(async () => { await request({ diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts index 264224af686ed..3ef760c507727 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts @@ -6,6 +6,7 @@ */ import { isObjectLike, isEmpty } from 'lodash'; +import type { Agent } from 'agent-base'; import type { AxiosInstance, Method, @@ -34,6 +35,7 @@ export const request = async ({ sslOverrides, timeout, connectorUsageCollector, + keepAlive, ...config }: { axios: AxiosInstance; @@ -46,6 +48,11 @@ export const request = async ({ timeout?: number; sslOverrides?: SSLSettings; connectorUsageCollector?: ConnectorUsageCollector; + /** + * keep-alive is only supported for https connections or proxied http connections + * It will be ignored for non-proxied http connections, this issue is tracked in https://github.com/elastic/kibana/issues/252991 + **/ + keepAlive?: boolean; } & AxiosRequestConfig): Promise => { if (!isEmpty(axios?.defaults?.baseURL ?? '')) { throw new Error( @@ -58,6 +65,16 @@ export const request = async ({ url, sslOverrides ); + + if (keepAlive) { + if (httpsAgent) { + httpsAgent.options.keepAlive = keepAlive; + } + if (httpAgent) { + (httpAgent as Agent).options.keepAlive = keepAlive; + } + } + const { maxContentLength, timeout: settingsTimeout } = configurationUtilities.getResponseSettings(); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts index 694142eebc7be..6be63f2ffeafb 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts @@ -15,7 +15,7 @@ import type { ActionsConfigurationUtilities } from '../actions_config'; /** * Create http and https proxy agents with custom proxy /hosts/SSL settings from configurationUtilities */ -interface GetCustomAgentsResponse { +export interface CustomAgents { httpAgent: HttpAgent | undefined; httpsAgent: HttpsAgent | undefined; } @@ -25,7 +25,7 @@ export function getCustomAgents( logger: Logger, url: string, sslOverrides?: SSLSettings -): GetCustomAgentsResponse { +): CustomAgents { const generalSSLSettings = configurationUtilities.getSSLSettings(); const proxySettings = configurationUtilities.getProxySettings(); const customHostSettings = configurationUtilities.getCustomHostSettings(url); diff --git a/x-pack/platform/plugins/shared/stack_connectors/__mocks__/eui_icon_assets.js b/x-pack/platform/plugins/shared/stack_connectors/__mocks__/eui_icon_assets.js new file mode 100644 index 0000000000000..6f97a34c0db64 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/__mocks__/eui_icon_assets.js @@ -0,0 +1,11 @@ +/* + * 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. + */ + +// Mock for EUI icon assets - exports a mock icon component +module.exports = { + icon: jest.fn(() => null), +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/jest.config.js b/x-pack/platform/plugins/shared/stack_connectors/jest.config.js index bcca799471aa7..1cbb7295bb2d8 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/jest.config.js +++ b/x-pack/platform/plugins/shared/stack_connectors/jest.config.js @@ -15,4 +15,8 @@ module.exports = { collectCoverageFrom: [ '/x-pack/platform/plugins/shared/stack_connectors/{common,public,server}/**/*.{js,ts,tsx}', ], + moduleNameMapper: { + '^@elastic/eui/es/components/icon/assets/(.*)$': + '/x-pack/platform/plugins/shared/stack_connectors/__mocks__/eui_icon_assets.js', + }, }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/eui_icons.d.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/eui_icons.d.ts new file mode 100644 index 0000000000000..b61cc89a2d9fd --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/eui_icons.d.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +declare module '@elastic/eui/es/components/icon/assets/*' { + import type * as React from 'react'; + import type { SVGProps } from 'react'; + interface SVGRProps { + title?: string; + titleId?: string; + } + export const icon: ({ + title, + titleId, + ...props + }: SVGProps & SVGRProps) => React.JSX.Element; + export {}; +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http.ts new file mode 100644 index 0000000000000..c164fa4ba6761 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http.ts @@ -0,0 +1,45 @@ +/* + * 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 as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { CONNECTOR_ID } from '@kbn/connector-schemas/http/constants'; +import type { + ActionParamsType, + ConnectorTypeConfigType, + ConnectorTypeSecretsType, +} from '@kbn/connector-schemas/http'; +import { icon } from '@elastic/eui/es/components/icon/assets/globe'; +import { formDeserializer, formSerializer } from '../lib/http/form_serialization'; + +export function getConnectorType(): ConnectorTypeModel< + ConnectorTypeConfigType, + ConnectorTypeSecretsType, + ActionParamsType +> { + return { + id: CONNECTOR_ID, + iconClass: icon, // using the globe icon from EUI assets so it works out of the box with workflows + selectMessage: i18n.translate('xpack.stackConnectors.components.http.selectMessageText', { + defaultMessage: 'Send requests to an HTTP endpoint with configurable authentication.', + }), + actionTypeTitle: i18n.translate('xpack.stackConnectors.components.http.connectorTypeTitle', { + defaultMessage: 'HTTP', + }), + getHideInUi: () => true, // hidden from the stack connectors UI, will still be available for workflows UI + actionConnectorFields: lazy(() => import('./http_connectors')), + actionParamsFields: lazy(() => import('./http_params')), + validateParams: async (actionParams: ActionParamsType) => { + return { errors: {} }; + }, + connectorForm: { + serializer: formSerializer, + deserializer: formDeserializer, + }, + }; +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http_connectors.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http_connectors.tsx new file mode 100644 index 0000000000000..febc905084c81 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http_connectors.tsx @@ -0,0 +1,66 @@ +/* + * 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 React from 'react'; + +import { EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { + useConnectorContext, + type ActionConnectorFieldsProps, +} from '@kbn/triggers-actions-ui-plugin/public'; + +import * as i18n from './translations'; + +const { urlField } = fieldValidators; + +const LazyLoadedAuthConfig = React.lazy(() => import('../../common/auth/auth_config')); + +const HttpActionConnectorFields: React.FunctionComponent = ({ + readOnly, +}) => { + const { + services: { isWebhookSslWithPfxEnabled: isPfxEnabled }, + } = useConnectorContext(); + return ( + <> + + + }> + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { HttpActionConnectorFields as default }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http_params.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http_params.tsx new file mode 100644 index 0000000000000..7421b08eb7eb8 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/http_params.tsx @@ -0,0 +1,355 @@ +/* + * 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 React, { useState, useEffect, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { JsonEditorWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; +import { + EuiButton, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiTitle, + type EuiSelectOption, +} from '@elastic/eui'; +import { HTTP_METHODS, type ActionParamsType, type HttpMethod } from '@kbn/connector-schemas/http'; + +const HTTP_METHOD_OPTIONS: EuiSelectOption[] = HTTP_METHODS.map((method) => ({ + value: method, + text: method, +})); + +const methodExpectsBody = (method: HttpMethod): boolean => { + return !['GET', 'DELETE'].includes(method); +}; + +interface KeyValuePair { + key: string; + value: string; +} + +const HttpParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + messageVariables, + errors, +}) => { + const { path, method = 'GET', body, query, headers } = actionParams; + + const [queryParams, setQueryParams] = useState(() => { + if (!query) return [{ key: '', value: '' }]; + return Object.entries(query).map(([key, value]) => ({ key, value })); + }); + + const [headerParams, setHeaderParams] = useState(() => { + if (!headers) return [{ key: '', value: '' }]; + return Object.entries(headers).map(([key, value]) => ({ key, value })); + }); + + const lastQueryRef = useRef(''); + const lastHeadersRef = useRef(''); + + // Sync query params with actionParams + useEffect(() => { + const queryRecord: Record = {}; + queryParams.forEach(({ key, value }) => { + if (key && key.trim()) { + queryRecord[key] = value || ''; + } + }); + const queryString = JSON.stringify(queryRecord); + if (queryString !== lastQueryRef.current) { + lastQueryRef.current = queryString; + const hasQuery = Object.keys(queryRecord).length > 0; + editAction('query', hasQuery ? queryRecord : undefined, index); + } + }, [queryParams, editAction, index]); + + // Sync header params with actionParams + useEffect(() => { + const headersRecord: Record = {}; + headerParams.forEach(({ key, value }) => { + if (key && key.trim()) { + headersRecord[key] = value || ''; + } + }); + const headersString = JSON.stringify(headersRecord); + if (headersString !== lastHeadersRef.current) { + lastHeadersRef.current = headersString; + const hasHeaders = Object.keys(headersRecord).length > 0; + editAction('headers', hasHeaders ? headersRecord : undefined, index); + } + }, [headerParams, editAction, index]); + + const updateQueryParam = (idx: number, field: 'key' | 'value', value: string) => { + const newParams = [...queryParams]; + newParams[idx] = { ...newParams[idx], [field]: value }; + setQueryParams(newParams); + }; + + const addQueryParam = () => { + setQueryParams([...queryParams, { key: '', value: '' }]); + }; + + const removeQueryParam = (idx: number) => { + const newParams = queryParams.filter((_, i) => i !== idx); + setQueryParams(newParams.length > 0 ? newParams : [{ key: '', value: '' }]); + }; + + const updateHeaderParam = (idx: number, field: 'key' | 'value', value: string) => { + const newParams = [...headerParams]; + newParams[idx] = { ...newParams[idx], [field]: value }; + setHeaderParams(newParams); + }; + + const addHeader = () => { + setHeaderParams([...headerParams, { key: '', value: '' }]); + }; + + const removeHeader = (idx: number) => { + const newParams = headerParams.filter((_, i) => i !== idx); + setHeaderParams(newParams.length > 0 ? newParams : [{ key: '', value: '' }]); + }; + + return ( + <> + + + + { + editAction('path', e.target.value || undefined, index); + }} + placeholder="/api/v1/users" + data-test-subj="httpPathInput" + /> + + + + + { + editAction('method', e.target.value, index); + }} + data-test-subj="httpMethodSelect" + /> + + + + + + + { + editAction('body', json || undefined, index); + }} + onBlur={() => { + if (!body) { + editAction('body', undefined, index); + } + }} + dataTestSubj="httpBodyJsonEditor" + /> + + + + + + +
+ {i18n.translate('xpack.stackConnectors.components.http.queryParamsTitle', { + defaultMessage: 'Query Parameters', + })} +
+
+
+ + + {i18n.translate('xpack.stackConnectors.components.http.addQueryParam', { + defaultMessage: 'Add', + })} + + +
+ + {queryParams.map((param, idx) => ( + + + + updateQueryParam(idx, 'key', e.target.value)} + placeholder="param" + data-test-subj={`httpQueryKeyInput-${idx}`} + /> + + + + + updateQueryParam(idx, 'value', e.target.value)} + placeholder="value" + data-test-subj={`httpQueryValueInput-${idx}`} + /> + + + + + removeQueryParam(idx)} + aria-label={i18n.translate( + 'xpack.stackConnectors.components.http.removeQueryParam', + { + defaultMessage: 'Remove query parameter', + } + )} + data-test-subj={`httpQueryRemoveButton-${idx}`} + /> + + + + ))} + + + + + + +
+ {i18n.translate('xpack.stackConnectors.components.http.headersTitle', { + defaultMessage: 'Headers', + })} +
+
+
+ + + {i18n.translate('xpack.stackConnectors.components.http.addHeader', { + defaultMessage: 'Add', + })} + + +
+ + {headerParams.map((header, idx) => ( + + + + updateHeaderParam(idx, 'key', e.target.value)} + placeholder="X-Custom-Header" + data-test-subj={`httpHeaderKeyInput-${idx}`} + /> + + + + + updateHeaderParam(idx, 'value', e.target.value)} + placeholder="value" + data-test-subj={`httpHeaderValueInput-${idx}`} + /> + + + + + removeHeader(idx)} + aria-label={i18n.translate('xpack.stackConnectors.components.http.removeHeader', { + defaultMessage: 'Remove header', + })} + data-test-subj={`httpHeaderRemoveButton-${idx}`} + /> + + + + ))} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { HttpParamsFields as default }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/index.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/index.ts new file mode 100644 index 0000000000000..5a008556e7094 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { getConnectorType as getHttpConnectorType } from './http'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/translations.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/translations.ts new file mode 100644 index 0000000000000..61aafc9be884e --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/http/translations.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 { i18n } from '@kbn/i18n'; + +export const BASE_URL_LABEL = i18n.translate( + 'xpack.stackConnectors.components.http.baseUrlTextFieldLabel', + { defaultMessage: 'Base URL' } +); + +export const BASE_URL_INVALID = i18n.translate( + 'xpack.stackConnectors.components.http.error.invalidBaseUrlTextField', + { defaultMessage: 'Base URL is invalid.' } +); + +export const BASE_URL_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.http.error.requiredBaseUrlText', + { defaultMessage: 'Base URL is required.' } +); + +export const PATH_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.http.pathFieldLabel', + { defaultMessage: 'Path' } +); + +export const METHOD_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.http.methodFieldLabel', + { defaultMessage: 'Method' } +); + +export const BODY_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.http.bodyFieldLabel', + { defaultMessage: 'Body' } +); + +export const QUERY_PARAMS_TITLE = i18n.translate( + 'xpack.stackConnectors.components.http.queryParamsTitle', + { defaultMessage: 'Query Parameters' } +); + +export const HEADERS_TITLE = i18n.translate('xpack.stackConnectors.components.http.headersTitle', { + defaultMessage: 'Headers', +}); + +export const TIMEOUT_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.http.timeoutFieldLabel', + { defaultMessage: 'Timeout (seconds)' } +); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/index.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/index.ts index 262f23baea648..aedb6fc634392 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/index.ts @@ -30,6 +30,7 @@ import { getTeamsConnectorType } from './teams'; import { getTinesConnectorType } from './tines'; import { getTorqConnectorType } from './torq'; import { getWebhookConnectorType } from './webhook'; +import { getHttpConnectorType } from './http'; import { getXmattersConnectorType } from './xmatters'; import { getD3SecurityConnectorType } from './d3security'; import { ExperimentalFeaturesService } from '../common/experimental_features_service'; @@ -63,6 +64,7 @@ export function registerConnectorTypes({ connectorTypeRegistry.register(getSwimlaneConnectorType()); connectorTypeRegistry.register(getCasesWebhookConnectorType()); connectorTypeRegistry.register(getWebhookConnectorType()); + connectorTypeRegistry.register(getHttpConnectorType()); connectorTypeRegistry.register(getXmattersConnectorType()); connectorTypeRegistry.register(getServiceNowITSMConnectorType()); connectorTypeRegistry.register(getServiceNowITOMConnectorType()); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/lib/api/form_serialization.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/lib/api/form_serialization.ts new file mode 100644 index 0000000000000..46a434404a5b8 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/lib/api/form_serialization.ts @@ -0,0 +1,76 @@ +/* + * 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 { InternalConnectorForm } from '@kbn/alerts-ui-shared'; +import type { ConnectorFormSchema } from '@kbn/triggers-actions-ui-plugin/public'; +import { isEmpty } from 'lodash'; + +/** + * The serializer and deserializer are needed to transform the headers of + * the api connectors. The api connector uses the UseArray component + * to add dynamic headers to the form. The UseArray component formats the fields + * as an array of objects. The schema for the headers of the api connector + * is Record. We need to transform the UseArray format to the one + * accepted by the backend. At the moment, the UseArray does not accept + * a serializer and deserializer so it has to be done on the form level. + */ + +export const formDeserializer = (data: ConnectorFormSchema): InternalConnectorForm => { + if (!data.actionTypeId) { + // Hook form lib can call deserializer *also* while editing the form (indicated by actionTypeId + // still being undefined). Changing the reference of form data subproperties causes problems + // with the UseArray that is used to edit the headers. For this reason, we leave the data unchanged. + return data; + } + + const configHeaders = Object.entries(data?.config?.headers ?? {}).map(([key, value]) => ({ + key, + value, + type: 'config' as const, + })); + + return { + ...data, + config: { + ...data.config, + headers: isEmpty(configHeaders) ? undefined : configHeaders, + }, + __internal__: { + headers: configHeaders, + }, + }; +}; + +const buildHeaderRecords = ( + headers: Array<{ key: string; value: string; type: string }>, + type: 'config' | 'secret' +): Record => { + return headers + .filter((header) => header.type === type && header.key && header.key.trim()) + .reduce>((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}); +}; + +export const formSerializer = (formData: InternalConnectorForm): ConnectorFormSchema => { + const headers = formData?.__internal__?.headers ?? []; + const configHeaders = buildHeaderRecords(headers, 'config'); + const secretHeaders = buildHeaderRecords(headers, 'secret'); + + return { + ...formData, + config: { + ...formData.config, + headers: isEmpty(configHeaders) ? null : configHeaders, + }, + secrets: { + ...formData.secrets, + secretHeaders: isEmpty(secretHeaders) ? undefined : secretHeaders, + }, + }; +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/lib/http/form_serialization.ts b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/lib/http/form_serialization.ts new file mode 100644 index 0000000000000..0b1377cd6e7d9 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/lib/http/form_serialization.ts @@ -0,0 +1,76 @@ +/* + * 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 { InternalConnectorForm } from '@kbn/alerts-ui-shared'; +import type { ConnectorFormSchema } from '@kbn/triggers-actions-ui-plugin/public'; +import { isEmpty } from 'lodash'; + +/** + * The serializer and deserializer are needed to transform the headers of + * the http connectors. The http connector uses the UseArray component + * to add dynamic headers to the form. The UseArray component formats the fields + * as an array of objects. The schema for the headers of the http connector + * is Record. We need to transform the UseArray format to the one + * accepted by the backend. At the moment, the UseArray does not accept + * a serializer and deserializer so it has to be done on the form level. + */ + +export const formDeserializer = (data: ConnectorFormSchema): InternalConnectorForm => { + if (!data.actionTypeId) { + // Hook form lib can call deserializer *also* while editing the form (indicated by actionTypeId + // still being undefined). Changing the reference of form data subproperties causes problems + // with the UseArray that is used to edit the headers. For this reason, we leave the data unchanged. + return data; + } + + const configHeaders = Object.entries(data?.config?.headers ?? {}).map(([key, value]) => ({ + key, + value, + type: 'config' as const, + })); + + return { + ...data, + config: { + ...data.config, + headers: isEmpty(configHeaders) ? undefined : configHeaders, + }, + __internal__: { + headers: configHeaders, + }, + }; +}; + +const buildHeaderRecords = ( + headers: Array<{ key: string; value: string; type: string }>, + type: 'config' | 'secret' +): Record => { + return headers + .filter((header) => header.type === type && header.key && header.key.trim()) + .reduce>((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}); +}; + +export const formSerializer = (formData: InternalConnectorForm): ConnectorFormSchema => { + const headers = formData?.__internal__?.headers ?? []; + const configHeaders = buildHeaderRecords(headers, 'config'); + const secretHeaders = buildHeaderRecords(headers, 'secret'); + + return { + ...formData, + config: { + ...formData.config, + headers: isEmpty(configHeaders) ? null : configHeaders, + }, + secrets: { + ...formData.secrets, + secretHeaders: isEmpty(secretHeaders) ? undefined : secretHeaders, + }, + }; +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/__snapshots__/http_connector.test.ts.snap b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/__snapshots__/http_connector.test.ts.snap new file mode 100644 index 0000000000000..aa483a120920d --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/__snapshots__/http_connector.test.ts.snap @@ -0,0 +1,143 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute() execute with secret headers and basic auth 1`] = ` +Object { + "axios": undefined, + "connectorUsageCollector": Object { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from http action \\"some-id\\": [HTTP 200] OK", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, + "data": "some data", + "headers": Object { + "Authorization": "Basic YWJjOjEyMw==", + "aheader": "a value", + "secretKey": "secretValue", + }, + "keepAlive": undefined, + "logger": Any, + "maxRedirects": undefined, + "method": "POST", + "sslOverrides": Object {}, + "url": "https://abc.def/my-endpoint", +} +`; + +exports[`execute() execute with secret headers and basic auth when header keys overlap 1`] = ` +Object { + "axios": undefined, + "connectorUsageCollector": Object { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from http action \\"some-id\\": [HTTP 200] OK", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, + "data": "some data", + "headers": Object { + "Authorization": "secretAuthorizationValue", + "aheader": "a value", + }, + "keepAlive": undefined, + "logger": Any, + "maxRedirects": undefined, + "method": "POST", + "sslOverrides": Object {}, + "url": "https://abc.def/my-endpoint", +} +`; + +exports[`execute() execute with username/password sends request with basic auth 1`] = ` +Object { + "axios": undefined, + "connectorUsageCollector": Object { + "connectorId": "test-connector-id", + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from http action \\"some-id\\": [HTTP 200] OK", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "isLevelEnabled": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "usage": Object { + "requestBodyBytes": 0, + }, + }, + "data": "some data", + "headers": Object { + "Authorization": "Basic YWJjOjEyMw==", + "aheader": "a value", + }, + "keepAlive": undefined, + "logger": Any, + "maxRedirects": undefined, + "method": "POST", + "sslOverrides": Object {}, + "url": "https://abc.def/my-endpoint", +} +`; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/errors.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/errors.ts new file mode 100644 index 0000000000000..280ddf3574fd6 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/errors.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import type { HttpConnectorTypeExecutorResult } from './types'; + +export function errorResultInvalid( + actionId: string, + serviceMessage: string +): HttpConnectorTypeExecutorResult { + const errMessage = i18n.translate('xpack.stackConnectors.http.invalidResponseErrorMessage', { + defaultMessage: 'error calling http, invalid response', + }); + return { + status: 'error', + message: errMessage, + actionId, + serviceMessage, + }; +} + +export function errorResultRequestFailed( + actionId: string, + serviceMessage: string, + errorSource?: TaskErrorSource +): HttpConnectorTypeExecutorResult { + const errMessage = i18n.translate('xpack.stackConnectors.http.requestFailedErrorMessage', { + defaultMessage: 'error calling http, request failed', + }); + return { + status: 'error', + message: errMessage, + actionId, + serviceMessage, + errorSource, + }; +} + +export function errorResultUnexpectedError(actionId: string): HttpConnectorTypeExecutorResult { + const errMessage = i18n.translate('xpack.stackConnectors.http.unreachableErrorMessage', { + defaultMessage: 'error calling http, unexpected error', + }); + return { + status: 'error', + message: errMessage, + actionId, + }; +} + +export function errorResultUnexpectedNullResponse( + actionId: string +): HttpConnectorTypeExecutorResult { + const message = i18n.translate('xpack.stackConnectors.http.unexpectedNullResponseErrorMessage', { + defaultMessage: 'unexpected null response from http', + }); + return { + status: 'error', + actionId, + message, + }; +} + +export function retryResult( + actionId: string, + serviceMessage: string +): HttpConnectorTypeExecutorResult { + const errMessage = i18n.translate( + 'xpack.stackConnectors.http.invalidResponseRetryLaterErrorMessage', + { + defaultMessage: 'error calling http, retry later', + } + ); + return { + status: 'error', + message: errMessage, + retry: true, + actionId, + serviceMessage, + }; +} + +export function retryResultSeconds( + actionId: string, + serviceMessage: string, + retryAfter: number +): HttpConnectorTypeExecutorResult { + const retryEpoch = Date.now() + retryAfter * 1000; + const retry = new Date(retryEpoch); + const retryString = retry.toISOString(); + const errMessage = i18n.translate( + 'xpack.stackConnectors.http.invalidResponseRetryDateErrorMessage', + { + defaultMessage: 'error calling http, retry at {retryString}', + values: { + retryString, + }, + } + ); + return { + status: 'error', + message: errMessage, + retry, + actionId, + serviceMessage, + }; +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/get_axios_config.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/get_axios_config.test.ts new file mode 100644 index 0000000000000..8798a829c0eae --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/get_axios_config.test.ts @@ -0,0 +1,159 @@ +/* + * 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 { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import type { Logger } from '@kbn/core/server'; +import { loggerMock } from '@kbn/logging-mocks'; +import { getAxiosConfig } from './get_axios_config'; +import type { GetAxiosConfigParams, GetAxiosConfigResponse } from './get_axios_config'; +import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { getOAuthClientCredentialsAccessToken } from '@kbn/actions-plugin/server/lib/get_oauth_client_credentials_access_token'; +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { promiseResult } from '../lib/result_type'; +import sinon from 'sinon'; +import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { AuthType } from '@kbn/connector-schemas/common/auth'; + +jest.mock('@kbn/actions-plugin/server/lib/get_oauth_client_credentials_access_token', () => ({ + getOAuthClientCredentialsAccessToken: jest.fn(), +})); + +const createServicesMock = () => { + const mock: jest.Mocked< + Services & { + savedObjectsClient: ReturnType; + } + > = { + savedObjectsClient: savedObjectsClientMock.create(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, + connectorTokenClient: { + deleteConnectorTokens: jest.fn(), + } as unknown as jest.Mocked, + }; + return mock; +}; + +describe('getAxiosConfig', () => { + let server: sinon.SinonFakeServer; + let connectorUsageCollector: ConnectorUsageCollector; + let configurationUtilities: jest.Mocked; + const mockedLogger: jest.Mocked = loggerMock.create(); + const services: Services = createServicesMock(); + + const params: GetAxiosConfigParams = { + connectorId: 'test-action-id', + config: { + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'https://example.com/oauth/token', + clientId: 'test-client-id', + scope: 'test-scope', + additionalFields: '{}', + headers: { 'Content-Type': 'application/json' }, + url: 'https://http.example.com', + hasAuth: true, + }, + secrets: { + clientSecret: 'test-client-secret', + key: null, + user: null, + password: null, + crt: null, + pfx: null, + secretHeaders: null, + }, + services, + configurationUtilities: actionsConfigMock.create(), + logger: mockedLogger, + }; + + beforeEach(() => { + server = sinon.useFakeServer(); + configurationUtilities = actionsConfigMock.create(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); + }); + + afterEach(() => { + server.restore(); + jest.clearAllMocks(); + }); + + it('should delete the token when the status is 401 but succeeds', async () => { + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce('fakeToken'); + server.respondWith('GET', 'https://example.com/oauth/token', [ + 401, + { 'Content-Type': 'application/json' }, + JSON.stringify({}), + ]); + + const config = await getAxiosConfig(params); + const { axiosInstance, headers } = config[0] as GetAxiosConfigResponse; + const requestPromise = promiseResult( + request({ + axios: axiosInstance, + url: 'https://example.com/oauth/token', + logger: mockedLogger, + headers, + configurationUtilities, + connectorUsageCollector, + // Allow all status codes for testing the onFulfilled interceptor + validateStatus: () => true, + }) + ); + + server.respond(); + await requestPromise; + + expect(services.connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({ + connectorId: 'test-action-id', + }); + }); + + it('should delete the token when the request fails', async () => { + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce('fakeToken'); + server.respondWith('GET', 'https://example.com/oauth/token', [ + 401, + { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'fake_error' }), + ]); + + const config = await getAxiosConfig(params); + const { axiosInstance, headers } = config[0] as GetAxiosConfigResponse; + + const requestPromise = promiseResult( + request({ + axios: axiosInstance, + url: 'https://example.com/oauth/token', + logger: mockedLogger, + headers, + configurationUtilities, + connectorUsageCollector, + }) + ); + + server.respond(); + await requestPromise; + + expect(services.connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({ + connectorId: 'test-action-id', + }); + }); + + it('should return error when access token retrieval fails', async () => { + (getOAuthClientCredentialsAccessToken as jest.Mock).mockRejectedValueOnce( + new Error('Failed to retrieve access token') + ); + + expect(((await getAxiosConfig(params))[1] as Error).message).toBe( + 'Unable to retrieve/refresh the access token: Failed to retrieve access token' + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/get_axios_config.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/get_axios_config.ts new file mode 100644 index 0000000000000..982d230677dd3 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/get_axios_config.ts @@ -0,0 +1,164 @@ +/* + * 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 { AxiosHeaderValue, AxiosInstance } from 'axios'; +import axios from 'axios'; +import { getOAuthClientCredentialsAccessToken } from '@kbn/actions-plugin/server/lib/get_oauth_client_credentials_access_token'; +import { + combineHeadersWithBasicAuthHeader, + getDeleteTokenAxiosInterceptor, + mergeConfigHeadersWithSecretHeaders, +} from '@kbn/actions-plugin/server/lib'; +import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import type { Logger } from '@kbn/logging/src/logger'; +import type { SSLSettings } from '@kbn/actions-utils'; +import type { Services } from '@kbn/actions-plugin/server/types'; +import type { + ConnectorTypeConfigType, + ConnectorTypeSecretsType, +} from '@kbn/connector-schemas/http'; +import { AuthType } from '@kbn/connector-schemas/common/auth'; +import { buildConnectorAuth } from '../../../common/auth/utils'; + +interface GetOAuth2AxiosConfigParams { + connectorId: string; + config: ConnectorTypeConfigType; + secrets: ConnectorTypeSecretsType; + services: Services; + configurationUtilities: ActionsConfigurationUtilities; + logger: Logger; +} +const getOAuth2AxiosConfig = async ({ + connectorId, + config, + secrets, + services: { connectorTokenClient }, + logger, + configurationUtilities, +}: GetOAuth2AxiosConfigParams) => { + const { accessTokenUrl, clientId, scope, additionalFields, headers } = config; + const { clientSecret } = secrets; + + // `additionalFields` should be parseable, we do check API schema validation and in + // action config validation step. + let parsedAdditionalFields; + try { + parsedAdditionalFields = additionalFields ? JSON.parse(additionalFields) : undefined; + } catch (error) { + logger.error(`Connector ${connectorId}: error parsing additional fields`); + } + + let accessToken; + try { + accessToken = await getOAuthClientCredentialsAccessToken({ + connectorId, + logger, + configurationUtilities, + oAuthScope: scope, + credentials: { + secrets: { clientSecret: clientSecret! }, + config: { + clientId: clientId!, + ...(parsedAdditionalFields ? { additionalFields: parsedAdditionalFields } : {}), + }, + }, + tokenUrl: accessTokenUrl!, + connectorTokenClient, + }); + } catch (error) { + throw new Error(`Unable to retrieve/refresh the access token: ${error.message}`); + } + + if (!accessToken) { + throw new Error(`Unable to retrieve new access token`); + } + logger.debug(`Successfully retrieved access token`); + + const { onFulfilled, onRejected } = getDeleteTokenAxiosInterceptor({ + connectorTokenClient, + connectorId, + }); + const axiosInstance = axios.create(); + axiosInstance.interceptors.response.use(onFulfilled, onRejected); + + const headersWithAuth = { + ...headers, + Authorization: accessToken, + }; + + return { axiosInstance, headers: headersWithAuth, sslOverrides: {} }; +}; + +interface GetDefaultAxiosConfig { + config: ConnectorTypeConfigType; + secrets: ConnectorTypeSecretsType; +} +const getDefaultAxiosConfig = async ({ config, secrets }: GetDefaultAxiosConfig) => { + const { hasAuth, authType, verificationMode, ca, headers } = config; + + const axiosInstance = axios.create(); + const { basicAuth, sslOverrides } = buildConnectorAuth({ + hasAuth, + authType, + secrets, + verificationMode, + ca, + }); + + const mergedHeaders = mergeConfigHeadersWithSecretHeaders(headers, secrets.secretHeaders); + const headersWithAuth = combineHeadersWithBasicAuthHeader({ + username: basicAuth.auth?.username, + password: basicAuth.auth?.password, + headers: mergedHeaders, + }); + + return { axiosInstance, headers: headersWithAuth, sslOverrides }; +}; + +export interface GetAxiosConfigResponse { + axiosInstance: AxiosInstance; + headers: Record | undefined; + sslOverrides: SSLSettings; +} + +export interface GetAxiosConfigParams { + config: ConnectorTypeConfigType; + secrets: ConnectorTypeSecretsType; + connectorId: string; + logger: Logger; + services: Services; + configurationUtilities: ActionsConfigurationUtilities; +} +export const getAxiosConfig = async ({ + config, + secrets, + connectorId, + services, + configurationUtilities, + logger, +}: GetAxiosConfigParams): Promise<[GetAxiosConfigResponse, null] | [null, Error]> => { + let axiosConfig: GetAxiosConfigResponse; + + try { + if (config.authType === AuthType.OAuth2ClientCredentials) { + axiosConfig = await getOAuth2AxiosConfig({ + connectorId, + logger, + configurationUtilities, + services, + config, + secrets, + }); + } else { + axiosConfig = await getDefaultAxiosConfig({ config, secrets }); + } + + return [axiosConfig, null]; + } catch (error) { + return [null, error]; + } +}; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/http_connector.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/http_connector.test.ts new file mode 100644 index 0000000000000..94206a56fe4de --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/http_connector.test.ts @@ -0,0 +1,1379 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { Services } from '@kbn/actions-plugin/server/types'; +import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import type { Logger } from '@kbn/core/server'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; + +import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; +import { loggerMock } from '@kbn/logging-mocks'; +import { getOAuthClientCredentialsAccessToken } from '@kbn/actions-plugin/server/lib/get_oauth_client_credentials_access_token'; + +import type { HttpConnectorType, HttpConnectorTypeExecutorOptions } from './types'; + +import { getConnectorType, getSystemConnectorType } from '.'; +import { TaskErrorSource, createTaskRunError } from '@kbn/task-manager-plugin/server'; + +jest.mock('axios', () => ({ + create: jest.fn(), + AxiosHeaders: jest.requireActual('axios').AxiosHeaders, + AxiosError: jest.requireActual('axios').AxiosError, +})); +import axios from 'axios'; +import { CRT_FILE, KEY_FILE, PFX_FILE } from '@kbn/connector-schemas/common/auth/mocks'; +import { AuthType, SSLCertType } from '@kbn/connector-schemas/common/auth'; +import type { + ConnectorTypeConfigType, + ConnectorTypeSecretsType, +} from '@kbn/connector-schemas/http'; +import { CONNECTOR_ID, CONNECTOR_ID_SYSTEM } from '@kbn/connector-schemas/http'; +const createAxiosInstanceMock = axios.create as jest.Mock; +const axiosInstanceMock = { + interceptors: { + request: { eject: jest.fn(), use: jest.fn() }, + response: { eject: jest.fn(), use: jest.fn() }, + }, +}; + +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +jest.mock('@kbn/actions-plugin/server/lib/get_oauth_client_credentials_access_token', () => ({ + getOAuthClientCredentialsAccessToken: jest.fn(), +})); + +const requestMock = utils.request as jest.Mock; + +const services: Services = actionsMock.createServices(); +const mockedLogger: jest.Mocked = loggerMock.create(); + +let connectorType: HttpConnectorType; +let configurationUtilities: jest.Mocked; +let connectorUsageCollector: ConnectorUsageCollector; + +beforeEach(() => { + configurationUtilities = actionsConfigMock.create(); + connectorType = getConnectorType(); + connectorUsageCollector = new ConnectorUsageCollector({ + logger: mockedLogger, + connectorId: 'test-connector-id', + }); + jest.restoreAllMocks(); +}); + +describe('connectorType', () => { + test('exposes the connector as `.http` on its Id and Name', () => { + expect(connectorType.id).toEqual(CONNECTOR_ID); + expect(connectorType.name).toEqual('HTTP'); + }); + + test('system connector type exposes the connector as `.http-system` on its Id', () => { + const systemConnectorType = getSystemConnectorType(); + expect(systemConnectorType.id).toEqual(CONNECTOR_ID_SYSTEM); + expect(systemConnectorType.isSystemActionType).toBe(true); + }); +}); + +describe('secrets validation', () => { + test('succeeds when secrets is valid', () => { + const secrets: Record = { + user: 'bob', + password: 'supersecret', + crt: null, + key: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }; + expect(validateSecrets(connectorType, secrets, { configurationUtilities })).toEqual(secrets); + }); + + test('fails when secret user is provided, but password is omitted', () => { + expect(() => { + validateSecrets(connectorType, { user: 'bob' }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type secrets: must specify one of the following schemas: user and password; crt and key (with optional password); pfx (with optional password); or clientSecret (for OAuth2)"` + ); + }); + + test('succeeds when authentication credentials are omitted', () => { + expect(validateSecrets(connectorType, {}, { configurationUtilities })).toEqual({ + crt: null, + key: null, + password: null, + pfx: null, + user: null, + clientSecret: null, + secretHeaders: null, + }); + }); + + test('succeeds when secrets contains a certificate and keyfile', () => { + const secrets: Record = { + password: 'supersecret', + crt: CRT_FILE, + key: KEY_FILE, + pfx: null, + user: null, + clientSecret: null, + secretHeaders: null, + }; + expect(validateSecrets(connectorType, secrets, { configurationUtilities })).toEqual(secrets); + + const secretsWithoutPassword: Record = { + crt: CRT_FILE, + key: KEY_FILE, + pfx: null, + user: null, + password: null, + clientSecret: null, + secretHeaders: null, + }; + + expect( + validateSecrets(connectorType, secretsWithoutPassword, { configurationUtilities }) + ).toEqual(secretsWithoutPassword); + }); + + test('succeeds when secrets contains a pfx', () => { + const secrets: Record = { + password: 'supersecret', + pfx: PFX_FILE, + user: null, + crt: null, + key: null, + clientSecret: null, + secretHeaders: null, + }; + expect(validateSecrets(connectorType, secrets, { configurationUtilities })).toEqual(secrets); + + const secretsWithoutPassword: Record = { + pfx: PFX_FILE, + user: null, + password: null, + crt: null, + key: null, + clientSecret: null, + secretHeaders: null, + }; + + expect( + validateSecrets(connectorType, secretsWithoutPassword, { configurationUtilities }) + ).toEqual(secretsWithoutPassword); + }); + + test('fails when secret crt is provided but key omitted, or vice versa', () => { + expect(() => { + validateSecrets(connectorType, { crt: CRT_FILE }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type secrets: must specify one of the following schemas: user and password; crt and key (with optional password); pfx (with optional password); or clientSecret (for OAuth2)"` + ); + expect(() => { + validateSecrets(connectorType, { key: KEY_FILE }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type secrets: must specify one of the following schemas: user and password; crt and key (with optional password); pfx (with optional password); or clientSecret (for OAuth2)"` + ); + }); +}); + +describe('config validation', () => { + test('config validation passes when only required fields are provided', () => { + const config: Record = { + url: 'http://mylisteningserver:9200/endpoint', + authType: AuthType.Basic, + hasAuth: true, + headers: null, + }; + expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual(config); + }); + + test('config validation passes when a url is specified', () => { + const config: Record = { + url: 'http://mylisteningserver:9200/endpoint', + authType: AuthType.Basic, + hasAuth: true, + headers: null, + }; + expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual(config); + }); + + test('config validation failed when a url is invalid', () => { + const config: Record = { + url: 'example.com/do-something', + }; + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: Field \\"url\\": Invalid url"` + ); + }); + + test('config validation passes when valid headers are provided', () => { + // any for testing + + const config: Record = { + url: 'http://mylisteningserver:9200/endpoint', + headers: { + 'Content-Type': 'application/json', + }, + authType: AuthType.Basic, + hasAuth: true, + }; + expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual(config); + }); + + test('should validate and throw error when headers on config is invalid', () => { + const config: Record = { + url: 'http://mylisteningserver:9200/endpoint', + headers: 'application/json', + }; + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: Field \\"headers\\": Expected object, received string"` + ); + }); + + test('config validation passes when kibana config url does not present in allowedHosts', () => { + // any for testing + + const config: Record = { + url: 'http://mylisteningserver.com:9200/endpoint', + headers: { + 'Content-Type': 'application/json', + }, + authType: AuthType.Basic, + hasAuth: true, + }; + + expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual(config); + }); + + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { + const configUtils = { + ...actionsConfigMock.create(), + ensureUriAllowed: (_: string) => { + throw new Error(`target url is not present in allowedHosts`); + }, + }; + + // any for testing + + const config: Record = { + url: 'http://mylisteningserver.com:9200/endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + expect(() => { + validateConfig(connectorType, config, { configurationUtilities: configUtils }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: error validation http action config: target url is not present in allowedHosts"` + ); + }); + + test('config validation fails when using disabled pfx certType', () => { + const config: Record = { + url: 'https://mylisteningserver:9200/endpoint', + authType: AuthType.SSL, + certType: SSLCertType.PFX, + hasAuth: true, + }; + configurationUtilities.getWebhookSettings = jest.fn(() => ({ + ssl: { pfx: { enabled: false } }, + })); + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: error validation http action config: certType \\"ssl-pfx\\" is disabled"` + ); + }); + + describe('OAuth2 Client Credentials', () => { + test('throws if required OAuth2 config is missing', async () => { + const config = { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + // missing accessTokenUrl, clientId + }; + + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: error validation http action config: missing Access Token URL (accessTokenUrl), Client ID (clientId) fields"` + ); + }); + + test('throws when additionalFields is no valid JSON', async () => { + const config = { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'http://fake.test', + clientId: 'fake-client-id', + additionalFields: 'invalid-json', + }; + + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: error validation http action config: additionalFields must be a valid JSON object"` + ); + }); + + test('throws when additionalFields is "null"', async () => { + const config = { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'http://fake.test', + clientId: 'fake-client-id', + additionalFields: 'null', + }; + + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: error validation http action config: additionalFields must be a valid JSON object"` + ); + }); + + test('throws when additionalFields is empty', async () => { + const config = { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'http://fake.test', + clientId: 'fake-client-id', + additionalFields: '{}', + }; + + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: error validation http action config: additionalFields must be a valid JSON object"` + ); + }); + + test('throws when additionalFields is an array', async () => { + const config = { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'http://fake.test', + clientId: 'fake-client-id', + additionalFields: '[]', + }; + + expect(() => { + validateConfig(connectorType, config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating connector type config: error validation http action config: additionalFields must be a valid JSON object"` + ); + }); + }); +}); + +describe('params validation', () => { + test('param validation passes when no fields are provided as none are required', () => { + const params: Record = {}; + expect(validateParams(connectorType, params, { configurationUtilities })).toEqual({ + method: 'GET', + }); + }); + + test('params validation passes when a valid body is provided', () => { + const params: Record = { + body: 'count: {{ctx.payload.hits.total}}', + }; + expect(validateParams(connectorType, params, { configurationUtilities })).toEqual({ + method: 'GET', + ...params, + }); + }); + + test('params validation passes when valid method is provided', () => { + const params: Record = { + method: 'POST', + body: 'some data', + }; + expect(validateParams(connectorType, params, { configurationUtilities })).toEqual(params); + }); + + test('params validation passes when path is provided', () => { + const params: Record = { + path: '/api/v1/endpoint', + }; + expect(validateParams(connectorType, params, { configurationUtilities })).toEqual({ + method: 'GET', + ...params, + }); + }); + + test('params validation passes when query is provided', () => { + const params: Record = { + query: { + key1: 'value1', + key2: 'value2', + }, + }; + expect(validateParams(connectorType, params, { configurationUtilities })).toEqual({ + method: 'GET', + ...params, + }); + }); + + test('params validation passes when headers are provided', () => { + const params: Record = { + headers: { + 'X-Custom': 'value', + }, + }; + expect(validateParams(connectorType, params, { configurationUtilities })).toEqual({ + method: 'GET', + ...params, + }); + }); + + test('params validation passes when fetcher options are provided', () => { + const params: Record = { + fetcher: { + skip_ssl_verification: true, + follow_redirects: false, + max_redirects: 5, + keep_alive: true, + }, + }; + expect(validateParams(connectorType, params, { configurationUtilities })).toEqual({ + method: 'GET', + ...params, + }); + }); +}); + +describe('execute()', () => { + beforeAll(() => { + requestMock.mockReset(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + requestMock.mockReset(); + requestMock.mockResolvedValue({ + status: 200, + statusText: 'OK', + data: { result: 'success' }, + headers: { 'content-type': 'application/json' }, + config: {}, + }); + }); + + test('execute with username/password sends request with basic auth', async () => { + const config: ConnectorTypeConfigType = { + url: 'https://abc.def', + headers: { + aheader: 'a value', + }, + authType: AuthType.Basic, + hasAuth: true, + }; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + delete requestMock.mock.calls[0][0].configurationUtilities; + expect(requestMock.mock.calls[0][0]).toMatchSnapshot({ + axios: undefined, + connectorUsageCollector: { + usage: { + requestBodyBytes: 0, + }, + }, + data: 'some data', + headers: { + Authorization: 'Basic YWJjOjEyMw==', + aheader: 'a value', + }, + logger: expect.any(Object), + method: 'POST', + sslOverrides: {}, + url: 'https://abc.def/my-endpoint', + }); + }); + + test('execute with secret headers and basic auth', async () => { + const config: ConnectorTypeConfigType = { + url: 'https://abc.def', + headers: { + aheader: 'a value', + }, + authType: AuthType.Basic, + hasAuth: true, + }; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + secretHeaders: { secretKey: 'secretValue' }, + clientSecret: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + delete requestMock.mock.calls[0][0].configurationUtilities; + expect(requestMock.mock.calls[0][0]).toMatchSnapshot({ + axios: undefined, + connectorUsageCollector: { + usage: { + requestBodyBytes: 0, + }, + }, + data: 'some data', + headers: { + Authorization: 'Basic YWJjOjEyMw==', + aheader: 'a value', + secretKey: 'secretValue', + }, + logger: expect.any(Object), + method: 'POST', + sslOverrides: {}, + url: 'https://abc.def/my-endpoint', + }); + }); + + test('execute with secret headers and basic auth when header keys overlap', async () => { + const config: ConnectorTypeConfigType = { + url: 'https://abc.def', + headers: { + aheader: 'a value', + }, + authType: AuthType.Basic, + hasAuth: true, + }; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + secretHeaders: { Authorization: 'secretAuthorizationValue' }, + clientSecret: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + delete requestMock.mock.calls[0][0].configurationUtilities; + expect(requestMock.mock.calls[0][0]).toMatchSnapshot({ + axios: undefined, + connectorUsageCollector: { + usage: { + requestBodyBytes: 0, + }, + }, + data: 'some data', + headers: { + Authorization: 'secretAuthorizationValue', + aheader: 'a value', + }, + logger: expect.any(Object), + method: 'POST', + sslOverrides: {}, + url: 'https://abc.def/my-endpoint', + }); + }); + + test('execute with ssl adds ssl settings to sslOverrides', async () => { + const config: ConnectorTypeConfigType = { + url: 'https://abc.def', + headers: { + aheader: 'a value', + }, + authType: AuthType.SSL, + certType: SSLCertType.CRT, + hasAuth: true, + }; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + crt: CRT_FILE, + key: KEY_FILE, + password: 'passss', + user: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + delete requestMock.mock.calls[0][0].configurationUtilities; + + expect(requestMock.mock.calls[0][0].sslOverrides).toMatchObject({ + cert: expect.any(Object), + key: expect.any(Object), + passphrase: 'passss', + }); + }); + + test('execute with exception maxContentLength size exceeded should log the proper error', async () => { + const config: ConnectorTypeConfigType = { + url: 'https://abc.def', + headers: { + aheader: 'a value', + }, + authType: AuthType.Basic, + hasAuth: true, + }; + requestMock.mockReset(); + requestMock.mockRejectedValueOnce({ + tag: 'err', + isAxiosError: true, + message: 'maxContentLength size of 1000000 exceeded', + }); + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + expect(mockedLogger.error).toBeCalledWith( + 'error on some-id http event: maxContentLength size of 1000000 exceeded' + ); + }); + + test('execute without username/password sends request without basic auth', async () => { + const config: ConnectorTypeConfigType = { + url: 'https://abc.def', + headers: { + aheader: 'a value', + }, + hasAuth: false, + }; + const secrets: ConnectorTypeSecretsType = { + user: null, + password: null, + pfx: null, + crt: null, + key: null, + clientSecret: null, + secretHeaders: null, + }; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + delete requestMock.mock.calls[0][0].configurationUtilities; + expect(requestMock.mock.calls[0][0].headers).toEqual({ + aheader: 'a value', + }); + expect(requestMock.mock.calls[0][0].url).toBe('https://abc.def/my-endpoint'); + }); + + test('renders parameter templates as expected', async () => { + const rogue = `double-quote:"; line-break->\n`; + + expect(connectorType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + body: '{"x": "{{rogue}}"}', + url: 'https://example.com/{{path}}', + method: 'GET' as const, + path: '/api/{{version}}', + query: { + key: '{{value}}', + }, + headers: { + 'X-Custom': '{{headerValue}}', + }, + }; + const variables = { + rogue, + path: 'test', + version: 'v1', + value: 'test-value', + headerValue: 'test-header', + }; + const params = connectorType.renderParameterTemplates!( + mockedLogger, + paramsWithTemplates, + variables + ); + + let paramsObject: any; + try { + paramsObject = JSON.parse(`${params.body}`); + } catch (err) { + expect(err).toBe(null); // kinda weird, but test should fail if it can't parse + } + + expect(paramsObject.x).toBe(rogue); + expect(params.body).toBe(`{"x": "double-quote:\\"; line-break->\\n"}`); + expect(params.url).toBe('https://example.com/test'); + expect(params.path).toBe('/api/v1'); + expect(params.query?.key).toBe('test-value'); + expect(params.headers?.['X-Custom']).toBe('test-header'); + }); + + test('execute combines base URL and path correctly', async () => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'GET', + path: '/api/v1/endpoint', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].url).toBe('https://abc.def/api/v1/endpoint'); + }); + + test('execute combines base URL and path with query string', async () => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'GET', + path: '/api/v1/endpoint', + query: { + key1: 'value1', + key2: 'value2', + }, + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].url).toContain('https://abc.def/api/v1/endpoint?'); + expect(requestMock.mock.calls[0][0].url).toContain('key1=value1'); + expect(requestMock.mock.calls[0][0].url).toContain('key2=value2'); + }); + + test('execute uses params.url when config.url is not provided', async () => { + const config = { + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'GET', + url: 'https://example.com', + path: '/api/endpoint', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].url).toBe('https://example.com/api/endpoint'); + }); + + test('execute returns error when URL is missing', async () => { + const config = { + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + const result = await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'GET', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(result?.status).toBe('error'); + expect(result?.serviceMessage).toBe('URL is required'); + }); + + test('execute merges params headers with config headers', async () => { + const config: ConnectorTypeConfigType = { + url: 'https://abc.def', + headers: { + 'Config-Header': 'config-value', + }, + authType: AuthType.Basic, + hasAuth: true, + }; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + headers: { + 'Params-Header': 'params-value', + }, + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].headers).toMatchObject({ + Authorization: 'Basic YWJjOjEyMw==', + 'Config-Header': 'config-value', + 'Params-Header': 'params-value', + }); + }); + + test('execute handles fetcher skip_ssl_verification', async () => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + fetcher: { + skip_ssl_verification: true, + }, + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].sslOverrides).toMatchObject({ + verificationMode: 'none', + }); + }); + + test('execute handles fetcher follow_redirects false', async () => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + fetcher: { + follow_redirects: false, + }, + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].maxRedirects).toBe(0); + }); + + test('execute handles fetcher max_redirects', async () => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + fetcher: { + max_redirects: 5, + }, + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].maxRedirects).toBe(5); + }); + + test('execute handles fetcher keep_alive', async () => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + fetcher: { + keep_alive: true, + }, + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(requestMock.mock.calls[0][0].keepAlive).toBe(true); + }); + + test('execute returns response with status, statusText, headers, and data', async () => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + const result = await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(result?.status).toBe('ok'); + expect(result?.data).toMatchObject({ + status: 200, + statusText: 'OK', + headers: expect.any(Object), + data: expect.any(Object), + }); + }); + + describe('error handling', () => { + test.each([400, 404, 405, 406, 410, 411, 414, 428, 431])( + 'forwards user error source in result for %s error responses', + async (status) => { + const config = { + url: 'https://abc.def', + authType: AuthType.Basic, + hasAuth: true, + } as ConnectorTypeConfigType; + + requestMock.mockRejectedValueOnce( + createTaskRunError( + { + tag: 'err', + isAxiosError: true, + response: { + status, + statusText: 'Not Found', + data: { + message: 'The requested resource is not found.', + }, + }, + } as unknown as Error, + TaskErrorSource.USER + ) + ); + const result = await connectorType.executor?.({ + actionId: 'some-id', + services, + config, + secrets: { + user: 'abc', + password: '123', + key: null, + crt: null, + pfx: null, + clientSecret: null, + secretHeaders: null, + }, + params: { + method: 'POST', + path: '/my-endpoint', + body: 'some data', + }, + configurationUtilities, + logger: mockedLogger, + connectorUsageCollector, + }); + + expect(result?.errorSource).toBe('user'); + } + ); + + it('should log an error if refreshing access token fails', async () => { + const errorMessage = 'Invalid client or Invalid client credentials'; + (getOAuthClientCredentialsAccessToken as jest.Mock).mockRejectedValueOnce( + new Error(errorMessage) + ); + createAxiosInstanceMock.mockReturnValue(axiosInstanceMock); + + const execOptions: HttpConnectorTypeExecutorOptions = { + actionId: 'test-id', + config: { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'https://token.url', + clientId: 'client', + headers: { 'X-Custom': 'value' }, + }, + params: { + method: 'POST', + path: '/endpoint', + body: '{}', + }, + secrets: { + clientSecret: 'secret', + key: null, + user: null, + password: null, + crt: null, + pfx: null, + secretHeaders: null, + }, + configurationUtilities, + logger: mockedLogger, + services, + connectorUsageCollector, + }; + + await connectorType.executor?.(execOptions); + + expect(mockedLogger.error.mock.calls[0][0]).toMatchInlineSnapshot( + `"ConnectorId \\"test-id\\": error \\"Unable to retrieve/refresh the access token: Invalid client or Invalid client credentials\\""` + ); + }); + }); + + describe('oauth2 client credentials', () => { + it('returns error result if refresh token fails', async () => { + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValue(undefined); + + const execOptions: HttpConnectorTypeExecutorOptions = { + actionId: 'test-id', + config: { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'https://token.url', + clientId: 'client', + headers: null, + }, + params: { + method: 'POST', + path: '/endpoint', + body: '{}', + }, + secrets: { + clientSecret: 'secret', + key: null, + user: null, + password: null, + crt: null, + pfx: null, + secretHeaders: null, + }, + configurationUtilities, + logger: mockedLogger, + services, + connectorUsageCollector, + }; + + const result = await connectorType.executor?.(execOptions); + + expect(result?.status).toBe('error'); + expect(result?.serviceMessage).toBe('Unable to retrieve new access token'); + }); + + it('adds access token to headers', async () => { + const accessToken = 'Bearer my-access-token'; + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce(accessToken); + createAxiosInstanceMock.mockReturnValue(axiosInstanceMock); + + const execOptions: HttpConnectorTypeExecutorOptions = { + actionId: 'test-id', + config: { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'https://token.url', + clientId: 'client', + headers: null, + }, + params: { + method: 'POST', + path: '/endpoint', + body: '{}', + }, + secrets: { + clientSecret: 'secret', + key: null, + user: null, + password: null, + crt: null, + pfx: null, + secretHeaders: null, + }, + configurationUtilities, + logger: mockedLogger, + services, + connectorUsageCollector, + }; + + await connectorType.executor?.(execOptions); + + expect((utils.request as jest.Mock).mock.calls[0][0].headers.Authorization).toBe(accessToken); + }); + + it('merges custom headers with Authorization header', async () => { + const accessToken = 'Bearer token123'; + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce(accessToken); + createAxiosInstanceMock.mockReturnValue(axiosInstanceMock); + + const execOptions: HttpConnectorTypeExecutorOptions = { + actionId: 'test-id', + config: { + url: 'https://test.com', + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: 'https://token.url', + clientId: 'client', + headers: { 'X-Custom': 'value' }, + }, + params: { + method: 'POST', + path: '/endpoint', + body: '{}', + }, + secrets: { + clientSecret: 'secret', + key: null, + user: null, + password: null, + crt: null, + pfx: null, + secretHeaders: null, + }, + configurationUtilities, + logger: mockedLogger, + services, + connectorUsageCollector, + }; + + await connectorType.executor?.(execOptions); + + const headers = (utils.request as jest.Mock).mock.calls[0][0].headers; + expect(headers.Authorization).toBe(accessToken); + expect(headers['X-Custom']).toBe('value'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/http_connector.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/http_connector.ts new file mode 100644 index 0000000000000..d3abaa743ee97 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/http_connector.ts @@ -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 type { AxiosError, AxiosResponse } from 'axios'; +import type { Logger } from '@kbn/core/server'; +import { pipe } from 'fp-ts/pipeable'; +import { getOrElse, map } from 'fp-ts/Option'; + +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { WorkflowsConnectorFeatureId } from '@kbn/actions-plugin/common'; +import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer'; +import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; + +import { SecretConfigurationSchema } from '@kbn/connector-schemas/common/auth'; +import type { ActionParamsType } from '@kbn/connector-schemas/http'; +import { + CONNECTOR_ID, + CONNECTOR_ID_SYSTEM, + CONNECTOR_NAME, + ConfigSchema, + ParamsSchema, +} from '@kbn/connector-schemas/http'; +import { z } from '@kbn/zod'; +import type { + HttpConnectorType, + HttpConnectorTypeExecutorOptions, + HttpConnectorTypeExecutorResult, +} from './types'; +import type { Result } from '../lib/result_type'; + +import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header'; +import { isOk, promiseResult } from '../lib/result_type'; +import { getAxiosConfig } from './get_axios_config'; +import { validateConnectorTypeConfig } from './validations'; +import { + errorResultRequestFailed, + errorResultUnexpectedNullResponse, + errorResultInvalid, + errorResultUnexpectedError, + retryResult, + retryResultSeconds, +} from './errors'; + +const userErrorCodes = [400, 404, 405, 406, 410, 411, 414, 428, 431]; + +// connector type definition + +// This is currently a Workflows-only connector. +// Ownership can be extended to support other features if needed. +const connectorTypeDefinition: Omit = { + minimumLicenseRequired: 'gold', + name: CONNECTOR_NAME, + supportedFeatureIds: [WorkflowsConnectorFeatureId], + renderParameterTemplates, + executor, +}; + +/** + * Exports 2 connector types: + * - The regular connector type: To be executed with saved objects instance, manages authentication and secrets configuration + * - The system connector type: To be executed as system action without the need of creating a saved objects instance, doesn't manage authentication and secrets configuration + */ + +export const getConnectorType = (): HttpConnectorType => ({ + id: CONNECTOR_ID, + ...connectorTypeDefinition, + validate: { + config: { + schema: ConfigSchema, + customValidator: validateConnectorTypeConfig, + }, + secrets: { + schema: SecretConfigurationSchema, + }, + params: { + schema: ParamsSchema, + }, + }, +}); + +export const getSystemConnectorType = (): HttpConnectorType => ({ + id: CONNECTOR_ID_SYSTEM, + ...connectorTypeDefinition, + isSystemActionType: true, + validate: { + config: { + schema: z.object({}).strict(), + }, + secrets: { + schema: z.object({}).strict(), + }, + params: { + schema: ParamsSchema, + }, + }, +}); + +// Helper functions + +function renderParameterTemplates( + logger: Logger, + params: ActionParamsType, + variables: Record +): ActionParamsType { + const renderedParams: ActionParamsType = { ...params }; + + if (params.body) { + renderedParams.body = renderMustacheString(logger, params.body, variables, 'json'); + } + + if (params.url) { + renderedParams.url = renderMustacheString(logger, params.url, variables, 'json'); + } + + if (params.path) { + renderedParams.path = renderMustacheString(logger, params.path, variables, 'json'); + } + + if (params.query) { + const renderedQuery: Record = {}; + for (const [key, value] of Object.entries(params.query)) { + renderedQuery[key] = renderMustacheString(logger, value, variables, 'json'); + } + renderedParams.query = renderedQuery; + } + + if (params.headers) { + const renderedHeaders: Record = {}; + for (const [key, value] of Object.entries(params.headers)) { + renderedHeaders[key] = renderMustacheString(logger, value, variables, 'json'); + } + renderedParams.headers = renderedHeaders; + } + + return renderedParams; +} + +function combineUrl(basePath: string, path?: string): string { + const basePathNormalized = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; + const pathNormalized = path?.startsWith('/') ? path : path ? `/${path}` : ''; + return `${basePathNormalized}${pathNormalized}`; +} + +function buildQueryString(query?: Record): string { + if (!query || Object.keys(query).length === 0) { + return ''; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + params.append(key, value); + } + return `?${params.toString()}`; +} + +// action executor +export async function executor( + execOptions: HttpConnectorTypeExecutorOptions +): Promise { + const { + actionId, + config, + params, + configurationUtilities, + logger, + connectorUsageCollector, + services, + } = execOptions; + + const { method, path, body, query, headers: paramsHeaders, fetcher } = params; + + // params always takes precedence over config + const baseUrl = params.url || config.url; + if (!baseUrl) { + return errorResultInvalid(actionId, 'URL is required'); + } + + // Combine base url and path + const url = combineUrl(baseUrl, path) + buildQueryString(query); + + const [axiosConfig, axiosConfigError] = await getAxiosConfig({ + connectorId: actionId, + services, + config, + secrets: execOptions.secrets, + configurationUtilities, + logger, + }); + + if (axiosConfigError) { + logger.error( + `ConnectorId "${actionId}": error "${ + axiosConfigError.message ?? 'unknown error - couldnt load axios config' + }"` + ); + return errorResultRequestFailed( + actionId, + axiosConfigError.message ?? 'unknown error - couldnt load axios config' + ); + } + + const { axiosInstance, headers: configHeaders, sslOverrides: baseSslOverrides } = axiosConfig; + + // Merge headers: params headers take precedence over config headers + const finalHeaders = { ...configHeaders, ...(paramsHeaders || {}) }; + + // Handle fetcher options + let sslOverrides = baseSslOverrides; + let maxRedirects: number | undefined; + let keepAlive: boolean | undefined; + + if (fetcher && Object.keys(fetcher).length > 0) { + // Map skip_ssl_verification to sslOverrides + if (fetcher.skip_ssl_verification) { + sslOverrides = { ...sslOverrides, verificationMode: 'none' }; + } + + // Handle redirect configuration + maxRedirects = fetcher.max_redirects; + if (fetcher.follow_redirects === false) { + maxRedirects = 0; + } + + keepAlive = fetcher.keep_alive; + } + + const result: Result> = await promiseResult( + request({ + axios: axiosInstance, + method, + url, + logger, + headers: finalHeaders, + data: body, + configurationUtilities, + sslOverrides, + connectorUsageCollector, + keepAlive, + maxRedirects, + }) + ); + + if (result == null) { + return errorResultUnexpectedNullResponse(actionId); + } + + if (isOk(result)) { + const { + value: { status, statusText, data }, + } = result; + logger.debug(`response from http action "${actionId}": [HTTP ${status}] ${statusText}`); + + const headers = Object.entries(result.value.headers || {}).reduce>( + (acc, [key, value]) => { + if (value != null) { + acc[key] = String(value); + } + return acc; + }, + {} + ); + + return { status: 'ok', actionId, data: { status, statusText, headers, data } }; + } else { + const { error } = result; + if (error.response) { + const { status, statusText, headers: responseHeaders, data: responseData } = error.response; + const responseMessage = responseData?.message; + const responseMessageAsSuffix = responseMessage ? `: ${responseMessage}` : ''; + const message = `[${status}] ${statusText}${responseMessageAsSuffix}`; + logger.error(`error on ${actionId} http event: ${message}`); + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + // special handling for 5xx + if (status >= 500) { + return retryResult(actionId, message); + } + + // special handling for rate limiting + if (status === 429) { + return pipe( + getRetryAfterIntervalFromHeaders(responseHeaders), + map((retry) => retryResultSeconds(actionId, message, retry)), + getOrElse(() => retryResult(actionId, message)) + ); + } + + const errorResult = errorResultInvalid(actionId, message); + + if (userErrorCodes.includes(status)) { + errorResult.errorSource = TaskErrorSource.USER; + } + + return errorResult; + } else if (error.code) { + const message = `[${error.code}] ${error.message}`; + logger.error(`error on ${actionId} http event: ${message}`); + return errorResultRequestFailed(actionId, message); + } else if (error.isAxiosError) { + const message = `${error.message}`; + logger.error(`error on ${actionId} http event: ${message}`); + return errorResultRequestFailed(actionId, message); + } + + logger.error(`error on ${actionId} http action: unexpected error`); + return errorResultUnexpectedError(actionId); + } +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/index.ts new file mode 100644 index 0000000000000..b69cf75fe89c6 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { getConnectorType, getSystemConnectorType } from './http_connector'; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/translations.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/translations.ts new file mode 100644 index 0000000000000..506abf951514c --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ADDITIONAL_FIELD_CONFIG_ERROR = i18n.translate( + 'xpack.stackConnectors.http.configurationErrorAdditionalFields', + { + defaultMessage: + 'error validation http action config: additionalFields must be a valid JSON object', + } +); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/types.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/types.ts new file mode 100644 index 0000000000000..de99a6d538db6 --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + ActionType as ConnectorType, + ActionTypeExecutorOptions as ConnectorTypeExecutorOptions, +} from '@kbn/actions-plugin/server/types'; +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import type { + ConnectorTypeConfigType, + ConnectorTypeSecretsType, + ActionParamsType, +} from '@kbn/connector-schemas/http'; + +export type HttpConnectorType = ConnectorType< + ConnectorTypeConfigType, + ConnectorTypeSecretsType, + ActionParamsType, + unknown +>; +export type HttpConnectorTypeExecutorOptions = ConnectorTypeExecutorOptions< + ConnectorTypeConfigType, + ConnectorTypeSecretsType, + ActionParamsType +>; + +export interface HttpConnectorResponse { + status: number; + statusText: string; + data: unknown; + headers: Record; +} + +export type HttpConnectorTypeExecutorResult = ActionTypeExecutorResult; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/validations.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/validations.ts new file mode 100644 index 0000000000000..f11bf9417e28c --- /dev/null +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/http/validations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { ValidatorServices } from '@kbn/actions-plugin/server/types'; + +import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; + +import type { ConnectorTypeConfigType } from '@kbn/connector-schemas/http'; +import { AuthType, SSLCertType } from '@kbn/connector-schemas/common/auth'; +import { ADDITIONAL_FIELD_CONFIG_ERROR } from './translations'; + +function validateUrl(configuredUrl: string) { + try { + new URL(configuredUrl); + } catch (err) { + throw new Error( + i18n.translate('xpack.stackConnectors.http.configurationErrorNoHostname', { + defaultMessage: 'error validation http action config: unable to parse url: {err}', + values: { + err: err.toString(), + }, + }) + ); + } +} + +function ensureUriAllowed( + configuredUrl: string, + configurationUtilities: ActionsConfigurationUtilities +) { + try { + configurationUtilities.ensureUriAllowed(configuredUrl); + } catch (allowListError) { + throw new Error( + i18n.translate('xpack.stackConnectors.http.configurationError', { + defaultMessage: 'error validation http action config: {message}', + values: { + message: allowListError.message, + }, + }) + ); + } +} + +function validateAuthType(configObject: ConnectorTypeConfigType) { + if (Boolean(configObject.authType) && !configObject.hasAuth) { + throw new Error( + i18n.translate('xpack.stackConnectors.http.authConfigurationError', { + defaultMessage: + 'error validation http action config: authType must be null or undefined if hasAuth is false', + }) + ); + } +} + +function validateCertType( + configObject: ConnectorTypeConfigType, + configurationUtilities: ActionsConfigurationUtilities +) { + if (configObject.certType === SSLCertType.PFX) { + const webhookSettings = configurationUtilities.getWebhookSettings(); + if (!webhookSettings.ssl.pfx.enabled) { + throw new Error( + i18n.translate('xpack.stackConnectors.http.pfxConfigurationError', { + defaultMessage: 'error validation http action config: certType "{certType}" is disabled', + values: { + certType: SSLCertType.PFX, + }, + }) + ); + } + } +} + +function validateAdditionalFields(configObject: ConnectorTypeConfigType) { + if (configObject.additionalFields) { + try { + const parsedAdditionalFields = JSON.parse(configObject.additionalFields); + + if ( + typeof parsedAdditionalFields !== 'object' || + Array.isArray(parsedAdditionalFields) || + Object.keys(parsedAdditionalFields).length === 0 + ) { + throw new Error(ADDITIONAL_FIELD_CONFIG_ERROR); + } + } catch (e) { + throw new Error(ADDITIONAL_FIELD_CONFIG_ERROR); + } + } +} + +function validateOAuth2(configObject: ConnectorTypeConfigType) { + if ( + configObject.authType === AuthType.OAuth2ClientCredentials && + (!configObject.accessTokenUrl || !configObject.clientId) + ) { + const missingFields = []; + if (!configObject.accessTokenUrl) { + missingFields.push('Access Token URL (accessTokenUrl)'); + } + if (!configObject.clientId) { + missingFields.push('Client ID (clientId)'); + } + + throw new Error( + i18n.translate('xpack.stackConnectors.http.oauth2ConfigurationError', { + defaultMessage: `error validation http action config: missing {missingItems} fields`, + values: { + missingItems: missingFields.join(', '), + }, + }) + ); + } +} + +export function validateConnectorTypeConfig( + configObject: ConnectorTypeConfigType, + validatorServices: ValidatorServices +) { + const { configurationUtilities } = validatorServices; + const configuredBasePath = configObject.url; + + validateUrl(configuredBasePath); + ensureUriAllowed(configuredBasePath, configurationUtilities); + validateAuthType(configObject); + validateCertType(configObject, configurationUtilities); + validateAdditionalFields(configObject); + validateOAuth2(configObject); +} diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/index.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/index.ts index 7a5176792feda..ad15631bd853f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/index.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/index.ts @@ -34,6 +34,10 @@ import { getConnectorType as getTeamsConnectorType } from './teams'; import { getConnectorType as getD3SecurityConnectorType } from './d3security'; import { getConnectorType as getTheHiveConnectorType } from './thehive'; import { getConnectorType as getXSOARConnectorType } from './xsoar'; +import { + getConnectorType as getHttpConnectorType, + getSystemConnectorType as getHttpSystemConnectorType, +} from './http'; import { getOpsgenieConnectorType } from './opsgenie'; import { getSentinelOneConnectorType } from './sentinelone'; import { getCrowdstrikeConnectorType } from './crowdstrike'; @@ -59,6 +63,8 @@ export function registerConnectorTypes({ actions.registerType(getSlackWebhookConnectorType({})); actions.registerType(getSlackApiConnectorType()); actions.registerType(getWebhookConnectorType()); + actions.registerType(getHttpConnectorType()); + actions.registerType(getHttpSystemConnectorType()); actions.registerType(getCasesWebhookConnectorType()); actions.registerType(getXmattersConnectorType()); actions.registerType(getServiceNowITSMConnectorType()); 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 fa194adbac963..c82a8f589d684 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 @@ -43,7 +43,7 @@ describe('Stack Connectors Plugin', () => { plugin.setup(coreSetup, { actions: actionsSetup }); const specConnectorTypes = Object.values(connectorsSpecs); - const builtInConnectorTypesCount = 16; + const builtInConnectorTypesCount = 18; expect(actionsSetup.registerType).toHaveBeenCalledTimes( builtInConnectorTypesCount + specConnectorTypes.length @@ -99,55 +99,69 @@ describe('Stack Connectors Plugin', () => { ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( 9, + expect.objectContaining({ + id: '.http', + name: 'HTTP', + }) + ); + expect(actionsSetup.registerType).toHaveBeenNthCalledWith( + 10, + expect.objectContaining({ + id: '.http-system', + name: 'HTTP', + }) + ); + expect(actionsSetup.registerType).toHaveBeenNthCalledWith( + 11, expect.objectContaining({ id: '.cases-webhook', name: 'Webhook - Case Management', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 10, + 12, expect.objectContaining({ id: '.xmatters', name: 'xMatters', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 11, + 13, expect.objectContaining({ id: '.servicenow', name: 'ServiceNow ITSM', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 12, + 14, expect.objectContaining({ id: '.servicenow-sir', name: 'ServiceNow SecOps', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 13, + 15, expect.objectContaining({ id: '.servicenow-itom', name: 'ServiceNow ITOM', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 14, + 16, expect.objectContaining({ id: '.jira', name: 'Jira', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 15, + 17, expect.objectContaining({ id: '.teams', name: 'Microsoft Teams', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 16, + 18, expect.objectContaining({ id: '.torq', name: 'Torq', diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.test.ts index c645c8bbae171..0970592e492b5 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.test.ts @@ -108,7 +108,8 @@ describe('getWebhookSecretHeadersKeyRoute', () => { expect(mockResponse.badRequest).toHaveBeenCalledWith({ body: { - message: 'Connector must be one of the following types: .webhook, .cases-webhook, .mcp', + message: + 'Connector must be one of the following types: .webhook, .cases-webhook, .mcp, .http', }, }); }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.ts b/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.ts index 063be5c1d0e85..3991ba56dcc6a 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/routes/get_webhook_secret_headers_key.ts @@ -73,7 +73,7 @@ export const getWebhookSecretHeadersKeyRoute = ( const connector = await actionsClient.get({ id }); const spaceId = spaces.spacesService.getSpaceId(req); - const allowedConnectorTypes = ['.webhook', '.cases-webhook', '.mcp']; + const allowedConnectorTypes = ['.webhook', '.cases-webhook', '.mcp', '.http']; if (!allowedConnectorTypes.includes(connector.actionTypeId)) { return res.badRequest({ diff --git a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/__snapshots__/task_cost_check.test.ts.snap b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/__snapshots__/task_cost_check.test.ts.snap index ae292dfa5b842..c329c50611e09 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/__snapshots__/task_cost_check.test.ts.snap +++ b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/__snapshots__/task_cost_check.test.ts.snap @@ -34,6 +34,14 @@ Array [ "cost": 1, "taskType": "actions:.gen-ai", }, + Object { + "cost": 1, + "taskType": "actions:.http", + }, + Object { + "cost": 1, + "taskType": "actions:.http-system", + }, Object { "cost": 1, "taskType": "actions:.index", diff --git a/x-pack/platform/test/alerting_api_integration/common/config.ts b/x-pack/platform/test/alerting_api_integration/common/config.ts index 8d3b4c0be42de..9c265b2cfce1e 100644 --- a/x-pack/platform/test/alerting_api_integration/common/config.ts +++ b/x-pack/platform/test/alerting_api_integration/common/config.ts @@ -71,6 +71,7 @@ const enabledActionTypes = [ '.thehive', '.tines', '.webhook', + '.http', '.xmatters', '.xsoar', '.torq', diff --git a/x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators/server/http_simulation.ts b/x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators/server/http_simulation.ts new file mode 100644 index 0000000000000..779b3cbc20f4b --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators/server/http_simulation.ts @@ -0,0 +1,148 @@ +/* + * 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 fs from 'fs'; +import expect from '@kbn/expect'; +import http from 'http'; +import https from 'https'; +import { promisify } from 'util'; +import { fromNullable, map, filter, getOrElse } from 'fp-ts/Option'; +import { pipe } from 'fp-ts/pipeable'; +import { constant } from 'fp-ts/function'; +import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils'; + +export async function initPlugin() { + const httpsServerKey = await promisify(fs.readFile)(KBN_KEY_PATH, 'utf8'); + const httpsServerCert = await promisify(fs.readFile)(KBN_CERT_PATH, 'utf8'); + + return { + httpServer: http.createServer(createServerCallback()), + httpsServer: https.createServer( + { + key: httpsServerKey, + cert: httpsServerCert, + }, + createServerCallback() + ), + }; +} + +function createServerCallback() { + let payloads: string[] = []; + return (request: http.IncomingMessage, response: http.ServerResponse) => { + const credentials = pipe( + fromNullable(request.headers.authorization), + map((authorization) => authorization.split(/\s+/)), + filter((parts) => parts.length > 1), + map((parts) => Buffer.from(parts[1], 'base64').toString()), + filter((credentialsPart) => credentialsPart.indexOf(':') !== -1), + map((credentialsPart) => { + const [username, password] = credentialsPart.split(':'); + return { username, password }; + }), + getOrElse(constant({ username: '', password: '' })) + ); + + // return the payloads that were posted to be remembered (e.g. from header_as_payload) + if (request.method === 'GET') { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(payloads, null, 4)); + payloads = []; + return; + } + + let data = ''; + request.on('data', (chunk) => { + data += chunk; + }); + request.on('end', () => { + switch (data) { + case 'success': + response.statusCode = 200; + response.end('OK'); + return; + case 'authenticate': + return validateAuthentication(credentials, response); + case 'success_post_method': + return validateRequestUsesMethod(request.method ?? '', 'post', response); + case 'success_put_method': + return validateRequestUsesMethod(request.method ?? '', 'put', response); + case 'success_patch_method': + return validateRequestUsesMethod(request.method ?? '', 'patch', response); + case 'success_delete_method': + return validateRequestUsesMethod(request.method ?? '', 'delete', response); + case 'success_get_method': + return validateRequestUsesMethod(request.method ?? '', 'get', response); + case 'success_config_secret_headers': + return validateReceivedHeaders(request.headers, response); + case 'failure': + response.statusCode = 500; + response.end('Error'); + return; + case 'header_as_payload': + payloads.push(JSON.stringify(request.headers)); + response.statusCode = 200; + response.end('OK'); + return; + } + + // store a payload that was posted to be remembered + const match = data.match(/^payload ([\S\s]*)$/); + if (match) { + payloads.push(match[1]); + response.statusCode = 200; + response.end('ok'); + return; + } + + response.statusCode = 400; + response.end(`unexpected body ${data}`); + // eslint-disable-next-line no-console + console.log(`http simulator received unexpected body: ${data}`); + return; + }); + }; +} + +function validateAuthentication(credentials: any, res: any) { + try { + expect(credentials).to.eql({ + username: 'elastic', + password: 'changeme', + }); + res.statusCode = 200; + res.end('OK'); + } catch (ex) { + res.statusCode = 403; + res.end(`the validateAuthentication operation failed. ${ex.message}`); + } +} + +function validateRequestUsesMethod(requestMethod: string, method: string, res: any) { + try { + expect(requestMethod.toLowerCase()).to.eql(method); + res.statusCode = 200; + res.end('OK'); + } catch (ex) { + res.statusCode = 403; + res.end(`the validateAuthentication operation failed. ${ex.message}`); + } +} + +function validateReceivedHeaders(headers: any, res: any) { + try { + const hasValidHeader = headers.config === 'configValue' && headers.secret === 'secretValue'; + + expect(hasValidHeader).to.eql(true); + res.statusCode = 200; + res.end('OK'); + } catch (ex) { + res.statusCode = 400; + res.end(`Header validation failed: ${ex.message}`); + } +} diff --git a/x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators/server/plugin.ts b/x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators/server/plugin.ts index 74ceb5b993f30..760dfa43f18a4 100644 --- a/x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/platform/test/alerting_api_integration/common/plugins/actions_simulators/server/plugin.ts @@ -25,6 +25,7 @@ import { initPlugin as initJira } from './jira_simulation'; import { initPlugin as initResilient } from './resilient_simulation'; import { initPlugin as initSlack } from './slack_simulation'; import { initPlugin as initWebhook } from './webhook_simulation'; +import { initPlugin as initHttp } from './http_simulation'; import { initPlugin as initSFCServer } from './single_file_connector_simulation'; import { initPlugin as initMSExchange } from './ms_exchage_server_simulation'; import { initPlugin as initXmatters } from './xmatters_simulation'; @@ -81,6 +82,11 @@ export async function getWebhookServer(): Promise { return httpServer; } +export async function getHttpServer(): Promise { + const { httpServer } = await initHttp(); + return httpServer; +} + export async function getHttpsWebhookServer(): Promise { const { httpsServer } = await initWebhook(); return httpsServer; diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts index 56631dd2b9fcc..2687d803db79b 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/cases_webhook.ts @@ -991,9 +991,7 @@ export default function casesWebhookTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'test') .expect(400); - expect(result.message).to.match( - /Connector must be one of the following types: \.webhook, \.cases-webhook, \.mcp/ - ); + expect(result.message).to.match(/Connector must be one of the following types/); }); after(() => { diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/http.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/http.ts new file mode 100644 index 0000000000000..5943975077b0e --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/http.ts @@ -0,0 +1,623 @@ +/* + * 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 httpProxy from 'http-proxy'; +import type http from 'http'; +import expect from '@kbn/expect'; +import type { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import { URL, format as formatUrl } from 'url'; +import getPort from 'get-port'; +import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; +import type { OAuth2Server } from '@kbn/alerting-api-integration-helpers/get_auth_server'; +import { getOAuth2Server } from '@kbn/alerting-api-integration-helpers/get_auth_server'; +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, + getHttpServer, +} from '@kbn/actions-simulators-plugin/server/plugin'; +import { AuthType } from '@kbn/connector-schemas/common/auth/constants'; +import type { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog, ObjectRemover } from '../../../../../common/lib'; + +const defaultValues: Record = { + headers: null, + hasAuth: true, +}; + +function parsePort(url: Record): Record { + return { + ...url, + port: url.port ? parseInt(url.port, 10) : url.port, + }; +} + +export default function httpTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const configService = getService('config'); + const retry = getService('retry'); + const objectRemover = new ObjectRemover(supertest); + + async function createHttpAction( + httpSimulatorURL: string, + kibanaUrlWithCreds: string, + config: Record> = {} + ): Promise { + const { user, password } = extractCredentialsFromUrl(kibanaUrlWithCreds); + const url = + config.url && typeof config.url === 'object' ? parsePort(config.url) : httpSimulatorURL; + const composedConfig = { + headers: { + 'Content-Type': 'text/plain', + }, + ...config, + url, + }; + + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Http action', + connector_type_id: '.http', + secrets: { + user, + password, + }, + config: composedConfig, + }) + .expect(200); + + objectRemover.add('default', createdAction.id, 'connector', 'actions', false); + + return createdAction.id; + } + + describe('http action', () => { + let httpSimulatorURL: string = ''; + let httpServer: http.Server; + let kibanaURL: string = ''; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + // need to wait for kibanaServer to settle ... + before(async () => { + httpServer = await getHttpServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + httpServer.listen(availablePort); + httpSimulatorURL = `http://localhost:${availablePort}`; + + kibanaURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK) + ); + + proxyServer = await getHttpProxyServer( + httpSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + }); + + it('should return 200 when creating a http connector successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Http action', + connector_type_id: '.http', + secrets: { + user: 'username', + password: 'mypassphrase', + }, + config: { + url: httpSimulatorURL, + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + is_system_action: false, + is_deprecated: false, + name: 'A generic Http action', + connector_type_id: '.http', + is_missing_secrets: false, + config: { + ...defaultValues, + url: httpSimulatorURL, + }, + is_connector_type_deprecated: false, + }); + + expect(typeof createdAction.id).to.be('string'); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + is_system_action: false, + is_deprecated: false, + name: 'A generic Http action', + connector_type_id: '.http', + is_missing_secrets: false, + config: { + ...defaultValues, + url: httpSimulatorURL, + }, + is_connector_type_deprecated: false, + }); + }); + + it('should remove headers when a http is updated', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Http action', + connector_type_id: '.http', + secrets: { + user: 'username', + password: 'mypassphrase', + }, + config: { + url: httpSimulatorURL, + headers: { + someHeader: '123', + }, + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + is_system_action: false, + is_deprecated: false, + name: 'A generic Http action', + connector_type_id: '.http', + is_missing_secrets: false, + config: { + ...defaultValues, + url: httpSimulatorURL, + headers: { + someHeader: '123', + }, + }, + is_connector_type_deprecated: false, + }); + + await supertest + .put(`/api/actions/connector/${createdAction.id}`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'A generic Http action', + secrets: { + user: 'username', + password: 'mypassphrase', + }, + config: { + url: httpSimulatorURL, + headers: { + someOtherHeader: '456', + }, + }, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + is_system_action: false, + is_deprecated: false, + name: 'A generic Http action', + connector_type_id: '.http', + is_missing_secrets: false, + config: { + ...defaultValues, + url: httpSimulatorURL, + headers: { + someOtherHeader: '456', + }, + }, + is_connector_type_deprecated: false, + }); + }); + + it('should send authentication to the http target', async () => { + const httpActionId = await createHttpAction(httpSimulatorURL, kibanaURL); + const { body: result } = await supertest + .post(`/api/actions/connector/${httpActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'authenticate', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + for (const method of ['POST', 'PUT', 'PATCH', 'GET', 'DELETE']) { + it(`should support the ${method} method against http target`, async () => { + const httpActionId = await createHttpAction(httpSimulatorURL, kibanaURL); + const { body: result } = await supertest + .post(`/api/actions/connector/${httpActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: `success_${method.toLowerCase()}_method`, + method, + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + + const events: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'action', + id: httpActionId, + provider: 'actions', + actions: new Map([ + ['execute-start', { gte: 1 }], + ['execute', { gte: 1 }], + ]), + }); + }); + + expect(proxyHaveBeenCalled).to.equal(true); + const executeEvent = events[1]; + expect(executeEvent?.kibana?.action?.execution?.source).to.be('http_request'); + }); + } + + it('should handle target https that are not added to allowedHosts', async () => { + const { body: result } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Http action', + connector_type_id: '.http', + secrets: { + user: 'username', + password: 'mypassphrase', + }, + config: { + url: 'http://a.none.allowedHosts.http/endpoint', + }, + }) + .expect(400); + + expect(result.error).to.eql('Bad Request'); + expect(result.message).to.match(/is not added to the Kibana config/); + }); + + it('should handle unreachable http targets', async () => { + const httpActionId = await createHttpAction( + 'http://some.non.existent.com/endpoint', + kibanaURL + ); + const { body: result } = await supertest + .post(`/api/actions/connector/${httpActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + method: 'POST', + body: 'failure', + }, + }) + .expect(200); + + expect(result.status).to.eql('error'); + expect(result.message).to.match(/error calling http, retry later/); + }); + + it('should handle failing http targets', async () => { + const httpActionId = await createHttpAction(httpSimulatorURL, kibanaURL); + const { body: result } = await supertest + .post(`/api/actions/connector/${httpActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + method: 'POST', + body: 'failure', + }, + }) + .expect(200); + + expect(result.status).to.eql('error'); + expect(result.message).to.match(/error calling http, retry later/); + expect(result.service_message).to.eql('[500] Internal Server Error'); + }); + + it('sends both config and secret headers in the http request', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Http action', + connector_type_id: '.http', + secrets: { + user: 'username', + password: 'mypassphrase', + secretHeaders: { + secret: 'secretValue', + }, + }, + config: { + url: httpSimulatorURL, + headers: { + config: 'configValue', + }, + }, + }) + .expect(200); + + // execute the connector + const actionId = createdAction.id; + const { body: result } = await supertest + .post(`/api/actions/connector/${actionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'success_config_secret_headers', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + describe('getHttpSecretHeadersKeyRoute', () => { + it('returns only secret headers keys for the http connector', async () => { + const { body: httpConnector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Http action', + connector_type_id: '.http', + secrets: { + user: 'user', + password: 'pass', + secretHeaders: { + secretKey1: 'secretValue1', + secretKey2: 'secretValue2', + secretKey3: 'secretValue3', + }, + }, + config: { + url: httpSimulatorURL, + }, + }) + .expect(200); + + const connectorId = httpConnector.id; + + // get secret headers from getHttpSecretHeadersKeyRoute + const { body: result } = await supertest + .get(`/internal/stack_connectors/${connectorId}/secret_headers`) + .set('kbn-xsrf', 'test') + .expect(200); + + expect(result).to.eql(['secretKey1', 'secretKey2', 'secretKey3']); + }); + + it('returns empty array if no secret headers provided', async () => { + const { body: httpConnector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Http action', + connector_type_id: '.http', + secrets: { + user: 'user', + password: 'pass', + }, + config: { + url: httpSimulatorURL, + }, + }) + .expect(200); + + const connectorId = httpConnector.id; + + const { body: result } = await supertest + .get(`/internal/stack_connectors/${connectorId}/secret_headers`) + .set('kbn-xsrf', 'test') + .expect(200); + + expect(result).to.eql([]); + }); + + it('rejects non-http connector types', async () => { + const { body: emailConnector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'An email action', + connector_type_id: '.email', + config: { + service: '__json', + from: 'bob@example.com', + hasAuth: true, + }, + secrets: { + user: 'bob', + password: 'supersecret', + }, + }) + .expect(200); + + const connectorId = emailConnector.id; + + const { body: result } = await supertest + .get(`/internal/stack_connectors/${connectorId}/secret_headers`) + .set('kbn-xsrf', 'test') + .expect(400); + + expect(result.message).to.match(/Connector must be one of the following types/); + }); + }); + + describe('OAuth2 client credentials', () => { + let oauth2Server: OAuth2Server; + let httpActionId: string = ''; + const clientId = 'test-client-id'; + const clientSecret = 'test-client-secret'; + + before(async () => { + oauth2Server = await getOAuth2Server(); + const { body: connector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'test') + .send({ + name: 'A OAuth2 Http action', + connector_type_id: '.http', + config: { + headers: { 'Content-Type': 'text/plain' }, + url: httpSimulatorURL, + hasAuth: true, + authType: AuthType.OAuth2ClientCredentials, + accessTokenUrl: oauth2Server.getAccessTokenUrl(), + clientId, + }, + secrets: { + clientSecret, + }, + }) + .expect(200); + + httpActionId = connector.id; + }); + + after(() => { + oauth2Server.server.close(); + }); + + afterEach(() => { + oauth2Server.reset(); + }); + + it('should get access token with client credentials', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${httpActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + method: 'POST', + body: 'header_as_payload', + }, + }) + .expect(200); + + // HTTP connector returns data: { status, statusText, headers, data }; simulator responds with body 'OK' + expect(result.status).to.eql('ok'); + expect(result.connector_id).to.eql(httpActionId); + expect(result.data?.data).to.eql('OK'); + + // this is the request that Kibana did to the auth server + // before calling the http server + const tokenRequests = oauth2Server.getTokenRequests(); + expect(tokenRequests.length).to.be(1); + expect(tokenRequests[0].client_id).to.be(clientId); + expect(tokenRequests[0].client_secret).to.be(clientSecret); + expect(tokenRequests[0].grant_type).to.be('client_credentials'); + expect(tokenRequests[0].token).to.be('test-token-1'); + + // this is the request Kibana did to the http server + // it returns headers because we are sending body: 'header_as_payload' + const httpSimulatorHeadersRaw = await fetch(httpSimulatorURL); + const httpSimulatorHeaders = await httpSimulatorHeadersRaw.json(); + expect(httpSimulatorHeaders.length).to.be(1); + expect(JSON.parse(httpSimulatorHeaders[0]).authorization).to.equal('Bearer test-token-1'); + }); + + it('should refresh the token once the previous one has expired', async () => { + // first call will generate a token as we could see in the previous test + await supertest + .post(`/api/actions/connector/${httpActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + method: 'POST', + body: 'header_as_payload', + }, + }) + .expect(200); + + // waits enough for the token to be expired plus some buffer + await new Promise((resolve) => + setTimeout(resolve, oauth2Server.getTokenExpirationTime() * 2 * 1000) + ); + + // this second call should trigger a second call to the auth server because + // the token will be expired + const { body: result } = await supertest + .post(`/api/actions/connector/${httpActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + method: 'POST', + body: 'header_as_payload', + }, + }) + .expect(200); + + // HTTP connector returns data: { status, statusText, headers, data }; simulator responds with body 'OK' + expect(result.status).to.eql('ok'); + expect(result.connector_id).to.eql(httpActionId); + expect(result.data?.data).to.eql('OK'); + + // this is the request that Kibana did to the auth server + // before calling the http server + const tokenRequests = oauth2Server.getTokenRequests(); + expect(tokenRequests.length).to.be(2); + expect(tokenRequests[1].client_id).to.be(clientId); + expect(tokenRequests[1].client_secret).to.be(clientSecret); + expect(tokenRequests[1].grant_type).to.be('client_credentials'); + expect(tokenRequests[1].token).to.be('test-token-2'); + + // this is the request Kibana did to the http server + // it returns headers because we are sending body: 'header_as_payload' + const httpSimulatorHeadersRaw = await fetch(httpSimulatorURL); + const httpSimulatorHeaders = await httpSimulatorHeadersRaw.json(); + expect(httpSimulatorHeaders.length).to.be(2); + expect(JSON.parse(httpSimulatorHeaders[1]).authorization).to.equal('Bearer test-token-2'); + }); + }); + + after(() => { + httpServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + }); +} + +function extractCredentialsFromUrl(url: string): { url: string; user: string; password: string } { + const parsedUrl = new URL(url); + const { password, username: user } = parsedUrl; + return { url: formatUrl(parsedUrl, { auth: false }), user, password }; +} diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts index 5e0aa9416ac77..4805c5be9053c 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/webhook.ts @@ -592,9 +592,7 @@ export default function webhookTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'test') .expect(400); - expect(result.message).to.match( - /Connector must be one of the following types: \.webhook, \.cases-webhook, \.mcp/ - ); + expect(result.message).to.match(/Connector must be one of the following types/); }); }); diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts index 8db9b67e0235d..1f7769504d47d 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts @@ -76,6 +76,16 @@ export default function getAllConnectorTests({ getService }: FtrProviderContext) referenced_by_count: 0, is_connector_type_deprecated: false, }, + { + id: 'system-connector-.http-system', + name: 'HTTP', + connector_type_id: '.http-system', + is_preconfigured: false, + is_deprecated: false, + referenced_by_count: 0, + is_system_action: true, + is_connector_type_deprecated: false, + }, { id: createdConnector.id, is_preconfigured: false, @@ -315,6 +325,16 @@ export default function getAllConnectorTests({ getService }: FtrProviderContext) referenced_by_count: 0, is_connector_type_deprecated: false, }, + { + id: 'system-connector-.http-system', + name: 'HTTP', + connector_type_id: '.http-system', + is_preconfigured: false, + is_deprecated: false, + referenced_by_count: 0, + is_system_action: true, + is_connector_type_deprecated: false, + }, { id: createdConnector.id, is_preconfigured: false, @@ -531,6 +551,16 @@ export default function getAllConnectorTests({ getService }: FtrProviderContext) referenced_by_count: 0, is_connector_type_deprecated: false, }, + { + id: 'system-connector-.http-system', + name: 'HTTP', + connector_type_id: '.http-system', + is_preconfigured: false, + is_deprecated: false, + referenced_by_count: 0, + is_system_action: true, + is_connector_type_deprecated: false, + }, { connector_type_id: '.email', id: 'notification-email', diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 9acadc7a987ca..75eac3e033747 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -36,6 +36,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide loadTestFile(require.resolve('./connector_types/slack_webhook')); loadTestFile(require.resolve('./connector_types/slack_api')); loadTestFile(require.resolve('./connector_types/webhook')); + loadTestFile(require.resolve('./connector_types/http')); loadTestFile(require.resolve('./connector_types/xmatters')); loadTestFile(require.resolve('./connector_types/tines')); loadTestFile(require.resolve('./connector_types/torq')); diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index 2cce660b5ba48..a67a983eb4938 100644 --- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -62,6 +62,8 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.torq', '.opsgenie', '.gen-ai', + '.http', + '.http-system', '.bedrock', '.gemini', '.sentinelone', diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts index 11308ca2367cd..d4731482aa2ff 100644 --- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts @@ -66,6 +66,16 @@ export default function getAllConnectorsTests({ getService }: FtrProviderContext referenced_by_count: 0, is_connector_type_deprecated: false, }, + { + id: 'system-connector-.http-system', + name: 'HTTP', + connector_type_id: '.http-system', + is_preconfigured: false, + is_deprecated: false, + referenced_by_count: 0, + is_system_action: true, + is_connector_type_deprecated: false, + }, { id: createdConnector.id, is_preconfigured: false, @@ -272,6 +282,16 @@ export default function getAllConnectorsTests({ getService }: FtrProviderContext referenced_by_count: 0, is_connector_type_deprecated: false, }, + { + id: 'system-connector-.http-system', + name: 'HTTP', + connector_type_id: '.http-system', + is_preconfigured: false, + is_deprecated: false, + referenced_by_count: 0, + is_system_action: true, + is_connector_type_deprecated: false, + }, { connector_type_id: '.email', id: 'notification-email', diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 79b9e0c0f4d86..9bbbbf663b714 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -78,6 +78,8 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.email', 'actions:.gemini', 'actions:.gen-ai', + 'actions:.http', + 'actions:.http-system', 'actions:.index', 'actions:.inference', 'actions:.jira',