diff --git a/src/platform/plugins/shared/workflows_execution_engine/server/step/kibana_action_step.ts b/src/platform/plugins/shared/workflows_execution_engine/server/step/kibana_action_step.ts index 6e291837711d2..6315a3dce4891 100644 --- a/src/platform/plugins/shared/workflows_execution_engine/server/step/kibana_action_step.ts +++ b/src/platform/plugins/shared/workflows_execution_engine/server/step/kibana_action_step.ts @@ -35,6 +35,19 @@ type FetcherOptions = NonNullable> & { [key: string]: any; }; +/** + * Describes a single field in a multipart/form-data upload. + * Used by the `form_data` param of `kibana.request` steps. + */ +interface FormDataFieldSpec { + /** The field value / file content (string). */ + content: string; + /** Optional filename hint (e.g. "export.ndjson"). */ + filename?: string; + /** MIME type of the field value (e.g. "application/ndjson"). */ + content_type?: string; +} + export class KibanaActionStepImpl extends BaseAtomicNodeImplementation { constructor( private node: KibanaGraphNode, @@ -86,18 +99,11 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation }, }); - // Get Kibana base URL (respecting force flags) and authentication + // Get Kibana base URL (respecting force flags) const kibanaUrl = this.getKibanaUrl(use_server_info, use_localhost); - const authHeaders = this.getAuthHeaders(); // Generic approach like Dev Console - just forward the request to Kibana - const result = await this.executeKibanaRequest( - kibanaUrl, - authHeaders, - stepType, - httpParams, - debug - ); + const result = await this.executeKibanaRequest(kibanaUrl, stepType, httpParams, debug); this.workflowLogger.logInfo(`Kibana action completed: ${stepType}`, { event: { action: 'kibana-action', outcome: 'success' }, @@ -142,7 +148,6 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation private getAuthHeaders(): Record { const headers: Record = { - 'Content-Type': 'application/json', 'kbn-xsrf': 'true', }; @@ -152,7 +157,6 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation // Use API key from fakeRequest if available headers.Authorization = fakeRequest.headers.authorization.toString(); } else { - // error throw new Error('No authentication headers found'); } return headers; @@ -160,7 +164,6 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation private async executeKibanaRequest( kibanaUrl: string, - authHeaders: Record, stepType: string, params: any, debug: boolean = false @@ -176,14 +179,42 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation method: string; path: string; body?: any; + formData?: Record; query?: any; headers?: Record; }; + if (cleanParams.body && cleanParams.form_data) { + throw new Error( + 'Cannot set both body and form_data — they are mutually exclusive. ' + + 'Use body for JSON requests, or form_data for multipart/form-data uploads.' + ); + } + + const authHeaders = this.getAuthHeaders(); + const jsonContentType = { 'Content-Type': 'application/json' }; + if (cleanParams.request) { // Raw API format: { request: { method, path, body, query, headers } } - like Dev Console const { method = 'GET', path, body, query, headers: customHeaders } = cleanParams.request; - requestConfig = { method, path, body, query, headers: { ...authHeaders, ...customHeaders } }; + requestConfig = { + method, + path, + body, + query, + headers: { ...authHeaders, ...jsonContentType, ...customHeaders }, + }; + } else if (cleanParams.form_data) { + // form_data mode: POST multipart/form-data (e.g. saved objects import). + // Content-Type is intentionally omitted — fetch sets it automatically with the multipart boundary. + const { form_data, method = 'POST', path, query, headers: customHeaders } = cleanParams; + requestConfig = { + method, + path, + formData: form_data as Record, + query, + headers: { ...authHeaders, ...(customHeaders as Record | undefined) }, + }; } else { // Use generated connector definitions to determine method and path (covers all 454+ Kibana APIs) const { @@ -198,7 +229,7 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation path, body, query, - headers: { ...authHeaders, ...connectorHeaders }, + headers: { ...authHeaders, ...jsonContentType, ...connectorHeaders }, }; } @@ -225,18 +256,40 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation return fullUrl; } + private buildFormData(formData: Record): FormData { + const fd = new FormData(); + for (const [fieldName, spec] of Object.entries(formData)) { + if (spec.filename !== undefined) { + // File field: include filename so the server gets Content-Disposition: form-data; filename="..." + const blob = new Blob([spec.content], { + type: spec.content_type ?? 'application/octet-stream', + }); + fd.append(fieldName, blob, spec.filename); + } else if (spec.content_type !== undefined) { + // Typed blob without a filename (e.g. application/json fragment) + const blob = new Blob([spec.content], { type: spec.content_type }); + fd.append(fieldName, blob); + } else { + // Plain text field — serialize as a string so Content-Disposition has no filename + fd.append(fieldName, spec.content); + } + } + return fd; + } + private async makeHttpRequest( kibanaUrl: string, requestConfig: { method: string; path: string; body?: any; + formData?: Record; query?: any; headers?: Record; }, fetcherOptions?: FetcherOptions ): Promise { - const { method, path, body, query, headers = {} } = requestConfig; + const { method, path, body, formData, query, headers = {} } = requestConfig; // Two paths can lead to emitEvent: (1) In-process: a workflow step (e.g. kibana.createCase) runs in // the same process and gets the fakeRequest from step context; getCasesClient(fakeRequest) and later @@ -261,11 +314,19 @@ export class KibanaActionStepImpl extends BaseAtomicNodeImplementation fullUrl = `${fullUrl}?${queryString}`; } + // Build fetch body: multipart FormData or JSON + let fetchBody: RequestInit['body']; + if (formData) { + fetchBody = this.buildFormData(formData); + } else { + fetchBody = body != null ? JSON.stringify(body) : undefined; + } + // Build fetch options const fetchOptions: RequestInit = { method, headers: outboundHeaders, - body: body ? JSON.stringify(body) : undefined, + body: fetchBody, }; // Apply undici Agent with fetcher options 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 eb1dc873b5f04..ba688379b0de5 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 @@ -486,10 +486,27 @@ export const staticConnectors: BaseConnectorContract[] = [ type: 'kibana.request', summary: 'Kibana Request', paramsSchema: z.object({ - method: z.string(), + method: z.string().optional(), path: z.string(), body: z.any().optional(), headers: z.any().optional(), + query: z.record(z.string(), z.any()).optional(), + form_data: z + .record( + z.string(), + z.object({ + content: z.string().describe('File content or field value'), + filename: z.string().optional().describe('Filename hint (e.g. "export.ndjson")'), + content_type: z + .string() + .optional() + .describe('MIME type of the content (e.g. "application/ndjson")'), + }) + ) + .optional() + .describe( + 'Multipart form-data fields. Use instead of body for APIs that require file uploads (e.g. /api/saved_objects/_import). Mutually exclusive with body.' + ), fetcher: FetcherConfigSchema, ...KibanaStepMetaSchema, }),