diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index aa19442bce77b..c906596a6eafc 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -356,6 +356,9 @@ paths: allow_multiple_system_actions: description: Indicates whether multiple instances of the same system action connector can be used in a single rule. type: boolean + description: + description: Description of the connector type. + type: string enabled: description: Indicates whether the connector is enabled. type: boolean @@ -371,6 +374,9 @@ paths: is_deprecated: description: Indicates whether the connector type is deprecated. type: boolean + is_experimental: + description: When true, the connector type is a technical preview in the UI. + type: boolean is_system_action_type: description: Indicates whether the action is a system action. type: boolean diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 4afbe0037e49e..3676e53c59515 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -426,6 +426,9 @@ paths: allow_multiple_system_actions: description: Indicates whether multiple instances of the same system action connector can be used in a single rule. type: boolean + description: + description: Description of the connector type. + type: string enabled: description: Indicates whether the connector is enabled. type: boolean @@ -441,6 +444,9 @@ paths: is_deprecated: description: Indicates whether the connector type is deprecated. type: boolean + is_experimental: + description: When true, the connector type is a technical preview in the UI. + type: boolean is_system_action_type: description: Indicates whether the action is a system action. type: boolean diff --git a/src/platform/packages/shared/kbn-actions-types/action_types.ts b/src/platform/packages/shared/kbn-actions-types/action_types.ts index 4aabedb44d0d5..82d7375c37d30 100644 --- a/src/platform/packages/shared/kbn-actions-types/action_types.ts +++ b/src/platform/packages/shared/kbn-actions-types/action_types.ts @@ -34,6 +34,8 @@ export interface ActionType { subFeature?: SubFeature; isDeprecated: boolean; allowMultipleSystemActions?: boolean; + description?: string; + isExperimental?: boolean; } export type ConnectorUserAuthStatus = 'connected' | 'not_connected' | 'not_applicable'; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/index.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/index.ts index 1e747bd86d0c4..78961cf93a10a 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/index.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/index.ts @@ -15,6 +15,7 @@ export { AddMessageVariables } from './src/add_message_variables'; export * from './src/common/formatters'; export * from './src/common/hooks'; +export * from './src/common/utils'; export { AlertsSearchBar } from './src/alerts_search_bar'; export type { AlertsSearchBarProps } from './src/alerts_search_bar/types'; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/moon.yml b/src/platform/packages/shared/kbn-alerts-ui-shared/moon.yml index 4a8d92ec5240e..4461819f69330 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/moon.yml +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/moon.yml @@ -25,6 +25,7 @@ dependsOn: - '@kbn/triggers-actions-ui-types' - '@kbn/alerting-types' - '@kbn/actions-types' + - '@kbn/connector-specs' - '@kbn/data-views-plugin' - '@kbn/unified-search-plugin' - '@kbn/es-query' @@ -41,6 +42,7 @@ dependsOn: - '@kbn/core-notifications-browser-mocks' - '@kbn/shared-ux-table-persist' - '@kbn/presentation-publishing' + - '@kbn/response-ops-form-generator' - '@kbn/response-ops-rules-apis' - '@kbn/controls-constants' - '@kbn/controls-schemas' @@ -51,6 +53,7 @@ dependsOn: - '@kbn/licensing-plugin' - '@kbn/licensing-types' - '@kbn/test-jest-helpers' + - '@kbn/zod' tags: - shared-browser - package diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/index.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/index.ts new file mode 100644 index 0000000000000..f9cd50d32df40 --- /dev/null +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + ConnectorSpecResponse, + ConnectorSpecWireResponse, +} from './transform_connector_spec_response'; +export { transformConnectorSpecResponse } from './transform_connector_spec_response'; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/transform_connector_spec_response.test.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/transform_connector_spec_response.test.ts new file mode 100644 index 0000000000000..7063bb81a4daa --- /dev/null +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/transform_connector_spec_response.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { transformConnectorSpecResponse } from './transform_connector_spec_response'; + +describe('transformConnectorSpecResponse', () => { + it('maps snake_case metadata to ConnectorMetadata', () => { + const result = transformConnectorSpecResponse({ + metadata: { + id: '.my-connector', + display_name: 'My connector', + description: 'Does things', + minimum_license: 'basic', + supported_feature_ids: ['alerting'], + icon: 'logoElastic', + docs_url: 'https://example.com/docs', + is_technical_preview: true, + }, + schema: { type: 'object', properties: {} }, + }); + + expect(result.metadata).toEqual({ + id: '.my-connector', + displayName: 'My connector', + description: 'Does things', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + icon: 'logoElastic', + docsUrl: 'https://example.com/docs', + isTechnicalPreview: true, + }); + expect(result.schema).toEqual({ type: 'object', properties: {} }); + }); + + it('omits optional metadata fields when absent on the wire', () => { + const result = transformConnectorSpecResponse({ + metadata: { + id: 'c', + display_name: 'C', + description: 'D', + minimum_license: 'gold', + supported_feature_ids: ['cases'], + }, + schema: {}, + }); + + expect(result.metadata).toEqual({ + id: 'c', + displayName: 'C', + description: 'D', + minimumLicense: 'gold', + supportedFeatureIds: ['cases'], + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/transform_connector_spec_response.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/transform_connector_spec_response.ts new file mode 100644 index 0000000000000..71dc23f5288c5 --- /dev/null +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_spec/transform_connector_spec_response.ts @@ -0,0 +1,63 @@ +/* + * 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 { ConnectorMetadata } from '@kbn/connector-specs'; + +/** + * Wire JSON from GET /internal/actions/connector_types/{id}/spec + * (snake_case metadata; matches server `GetConnectorSpecResponseV1`). + */ +export interface ConnectorSpecWireResponse { + metadata: { + id: string; + display_name: string; + description: string; + minimum_license: string; + supported_feature_ids: string[]; + icon?: string; + docs_url?: string; + is_technical_preview?: boolean; + }; + schema: Record; +} + +/** Client-side connector spec after normalising API casing. */ +export interface ConnectorSpecResponse { + metadata: ConnectorMetadata; + schema: Record; +} + +export function transformConnectorSpecResponse( + wire: ConnectorSpecWireResponse +): ConnectorSpecResponse { + const { + display_name: displayName, + minimum_license: minimumLicense, + supported_feature_ids: supportedFeatureIds, + docs_url: docsUrl, + is_technical_preview: isTechnicalPreview, + icon, + description, + id, + } = wire.metadata; + + return { + metadata: { + id, + displayName, + description, + minimumLicense: minimumLicense as ConnectorMetadata['minimumLicense'], + supportedFeatureIds: supportedFeatureIds as ConnectorMetadata['supportedFeatureIds'], + ...(icon !== undefined ? { icon } : {}), + ...(docsUrl !== undefined ? { docsUrl } : {}), + ...(isTechnicalPreview !== undefined ? { isTechnicalPreview } : {}), + }, + schema: wire.schema, + }; +} diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.test.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.test.ts index 62e006eac13e7..b45c45e8db80a 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.test.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.test.ts @@ -24,6 +24,8 @@ describe('transformConnectorTypesResponse', () => { sub_feature: 'endpointSecurity', is_deprecated: false, allow_multiple_system_actions: true, + description: 'Card subtitle from list API', + is_experimental: true, }, { id: 'actionType2Id', @@ -51,6 +53,8 @@ describe('transformConnectorTypesResponse', () => { subFeature: 'endpointSecurity', isDeprecated: false, allowMultipleSystemActions: true, + description: 'Card subtitle from list API', + isExperimental: true, }, { id: 'actionType2Id', diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.ts index c045968f487c7..0698cf11fe341 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/fetch_connector_types/transform_connector_types_response.ts @@ -18,6 +18,8 @@ const transformConnectorType: RewriteRequestCase = ({ sub_feature: subFeature, is_deprecated: isDeprecated, allow_multiple_system_actions: allowMultipleSystemActions, + description, + is_experimental: isExperimental, ...res }: AsApiContract) => ({ enabledInConfig, @@ -28,6 +30,8 @@ const transformConnectorType: RewriteRequestCase = ({ subFeature, isDeprecated, allowMultipleSystemActions, + description, + isExperimental, ...res, }); diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/index.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/index.ts index dbea65b913d71..fdec941a5c6af 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/index.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/apis/index.ts @@ -13,5 +13,6 @@ export * from './fetch_alerts_index_names'; export * from './fetch_connector'; export * from './fetch_connectors'; export * from './fetch_connector_types'; +export * from './fetch_connector_spec'; export * from './fetch_rule_type_alert_fields'; export * from './fetch_ui_health_status'; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/index.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/index.ts index 62b1dd2eaf42c..72d27e4e49233 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/index.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/index.ts @@ -15,3 +15,4 @@ export * from './use_load_alerting_framework_health'; export * from './use_get_rule_types_permissions'; export * from './use_load_ui_health'; export * from './use_fetch_unified_alerts_fields'; +export * from './use_action_type_model'; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_action_type_model.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_action_type_model.ts new file mode 100644 index 0000000000000..52d168fdcf319 --- /dev/null +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_action_type_model.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { useMemo } from 'react'; +import { useQuery } from '@kbn/react-query'; +import { ACTION_TYPE_SOURCES } from '@kbn/actions-types'; +import type { ActionType } from '@kbn/actions-types'; +import { fromConnectorSpecSchema } from '@kbn/connector-specs'; +import type { HttpSetup, IUiSettingsClient } from '@kbn/core/public'; +import type { ActionTypeModel, ActionTypeRegistryContract } from '../types'; +import { + fetchConnectorSpec, + transformSpecToActionTypeModel, +} from '../utils/action_type_model_utils'; + +const CONNECTOR_SPEC_QUERY_KEY = 'connectorSpec'; + +export interface UseActionTypeModelResult { + /** The action type model, either from registry or derived from spec */ + actionTypeModel: ActionTypeModel | null; + /** Whether the spec is currently being fetched */ + isLoading: boolean; + /** Error if fetching the spec failed */ + error: Error | null; + /** Whether the model was derived from a spec (vs from registry) */ + isFromSpec: boolean; + /** Re-runs the connector spec query (no-op when the model is from the registry) */ + refetch: () => void; +} + +/** + * Hook to get an ActionTypeModel for a given ActionType. + * + * For stack connectors (registered in the actionTypeRegistry), returns the model synchronously. + * For spec-based connectors, fetches the spec from the API and transforms it into an ActionTypeModel. + */ +export function useActionTypeModel({ + actionTypeRegistry, + actionType, + http, + uiSettings, +}: { + actionTypeRegistry: ActionTypeRegistryContract; + actionType: ActionType | null; + http: HttpSetup; + uiSettings?: IUiSettingsClient; +}): UseActionTypeModelResult { + const registeredModel = useMemo(() => { + if (actionType == null) { + return null; + } + if (actionTypeRegistry.has(actionType.id)) { + return actionTypeRegistry.get(actionType.id); + } + return null; + }, [actionType, actionTypeRegistry]); + + const shouldFetchSpec = actionType != null && actionType.source === ACTION_TYPE_SOURCES.spec; + + const { + data: specData, + isLoading, + error, + refetch, + } = useQuery>, Error>({ + queryKey: [CONNECTOR_SPEC_QUERY_KEY, actionType?.id], + queryFn: async ({ signal }) => { + const spec = await fetchConnectorSpec(http, actionType!.id, signal); + // Validate eagerly — fail fast before caching. The schema is re-parsed + // lazily inside actionConnectorFields when the form component mounts. + if (!fromConnectorSpecSchema(spec.schema)) { + throw new Error(`Failed to parse connector spec schema for "${actionType!.id}"`); + } + return spec; + }, + enabled: shouldFetchSpec, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + const specBasedModel = useMemo(() => { + if (!specData) { + return null; + } + return transformSpecToActionTypeModel(specData, uiSettings); + }, [specData, uiSettings]); + + return { + actionTypeModel: shouldFetchSpec ? specBasedModel : registeredModel, + isLoading: shouldFetchSpec && isLoading, + error, + isFromSpec: shouldFetchSpec && specBasedModel != null, + refetch: () => { + void refetch(); + }, + }; +} diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.test.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.test.ts new file mode 100644 index 0000000000000..bde87be268a09 --- /dev/null +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.test.ts @@ -0,0 +1,195 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/public/mocks'; +import { ACTION_TYPE_SOURCES } from '@kbn/actions-types'; +import { connectorsSpecs, serializeConnectorSpec } from '@kbn/connector-specs'; +import type { ConnectorSpecWireResponse } from '../apis/fetch_connector_spec'; +import { + fetchConnectorSpec, + transformSpecToActionTypeModel, + type ConnectorSpecResponse, +} from './action_type_model_utils'; + +type LooseConnectorFormTransform = (data: Record) => Record; + +function minimalConnectorSpecForForm(): ConnectorSpecResponse { + return { + metadata: { + id: 'test-connector', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + }, + schema: { type: 'object', properties: {} }, + }; +} + +describe('action_type_model_utils', () => { + describe('fetchConnectorSpec', () => { + const http = httpServiceMock.createStartContract(); + + const mockWireResponse = (): ConnectorSpecWireResponse => ({ + metadata: { + id: 'test-connector', + display_name: 'Test Connector', + description: 'A test connector', + minimum_license: 'basic', + supported_feature_ids: ['alerting'], + }, + schema: { type: 'object', properties: {} }, + }); + + const expectedClientSpec = (): ConnectorSpecResponse => ({ + metadata: { + id: 'test-connector', + displayName: 'Test Connector', + description: 'A test connector', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + }, + schema: { type: 'object', properties: {} }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the connector spec API and returns the response', async () => { + http.get.mockResolvedValue(mockWireResponse()); + + const result = await fetchConnectorSpec(http, 'test-connector'); + + expect(http.get.mock.calls[0][0]).toBe( + '/internal/actions/connector_types/test-connector/spec' + ); + expect(result).toEqual(expectedClientSpec()); + }); + }); + + describe('transformSpecToActionTypeModel', () => { + const baseSpec: ConnectorSpecResponse = { + metadata: { + id: 'test-connector', + displayName: 'Test Connector', + description: 'A test connector description', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + }, + schema: { + type: 'object', + properties: { + config: { type: 'object', properties: {} }, + secrets: { type: 'object', properties: {} }, + }, + }, + }; + + it('maps base spec metadata, source, subtype, and validateParams', async () => { + const model = transformSpecToActionTypeModel(baseSpec); + expect(model.id).toBe('test-connector'); + expect(model.actionTypeTitle).toBe('Test Connector'); + expect(model.selectMessage).toBe('A test connector description'); + expect(model.source).toBe(ACTION_TYPE_SOURCES.spec); + expect(model.subtype).toBeUndefined(); + expect(await model.validateParams({}, null)).toEqual({ errors: {} }); + }); + + it('sets isExperimental from is_technical_preview metadata', () => { + const validSchema = serializeConnectorSpec(connectorsSpecs.AlienVaultOTXConnector) + .schema as Record; + const modelDefault = transformSpecToActionTypeModel({ ...baseSpec, schema: validSchema }); + expect(modelDefault.isExperimental).toBe(false); + + const modelPreview = transformSpecToActionTypeModel({ + ...baseSpec, + schema: validSchema, + metadata: { ...baseSpec.metadata, isTechnicalPreview: true }, + }); + expect(modelPreview.isExperimental).toBe(true); + }); + + it('uses metadata icon when set, otherwise plugs when id is not in the icon map', () => { + const withIcon = transformSpecToActionTypeModel({ + ...baseSpec, + metadata: { ...baseSpec.metadata, icon: 'custom-icon' }, + }); + expect(withIcon.iconClass).toBe('custom-icon'); + expect(transformSpecToActionTypeModel(baseSpec).iconClass).toBe('plugs'); + }); + }); + + describe('connectorForm serializer and deserializer', () => { + const formModel = () => transformSpecToActionTypeModel(minimalConnectorSpecForForm()); + + it('serializer copies secrets.authType into config or returns data unchanged', () => { + const serializer = formModel().connectorForm?.serializer as + | LooseConnectorFormTransform + | undefined; + + const withAuthType = { + name: 'My Connector', + config: { someField: 'value' }, + secrets: { authType: 'api_key', apiKey: 'secret' }, + }; + expect(serializer?.(withAuthType)).toEqual({ + ...withAuthType, + config: { someField: 'value', authType: 'api_key' }, + }); + + const withoutAuthType = { + name: 'My Connector', + config: { someField: 'value' }, + secrets: { apiKey: 'secret' }, + }; + expect(serializer?.(withoutAuthType)).toEqual(withoutAuthType); + }); + + it('deserializer merges config.authType into secrets when absent, preserves existing secrets.authType, or no-ops', () => { + const deserializer = formModel().connectorForm?.deserializer as + | LooseConnectorFormTransform + | undefined; + + const emptySecrets = { + name: 'My Connector', + config: { someField: 'value', authType: 'api_key' }, + secrets: {}, + }; + expect(deserializer?.(emptySecrets)?.secrets).toEqual({ authType: 'api_key' }); + expect(deserializer?.(emptySecrets)?.config).toEqual({ + someField: 'value', + authType: 'api_key', + }); + + const mergeSecrets = { + name: 'My Connector', + config: { authType: 'api_key' }, + secrets: { apiKey: 'stored-secret' }, + }; + expect(deserializer?.(mergeSecrets)?.secrets).toEqual({ + apiKey: 'stored-secret', + authType: 'api_key', + }); + + const secretsAlreadyHasAuth = { + name: 'My Connector', + config: { authType: 'api_key' }, + secrets: { authType: 'bearer_token' }, + }; + expect(deserializer?.(secretsAlreadyHasAuth)).toEqual(secretsAlreadyHasAuth); + + const noAuthInConfig = { + name: 'My Connector', + config: { someField: 'value' }, + secrets: { apiKey: 'secret' }, + }; + expect(deserializer?.(noAuthInConfig)).toEqual(noAuthInConfig); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.ts new file mode 100644 index 0000000000000..5fe92e997daf8 --- /dev/null +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/action_type_model_utils.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { lazy, useMemo } from 'react'; +import { ACTION_TYPE_SOURCES, type ActionType } from '@kbn/actions-types'; +import type { HttpSetup, IUiSettingsClient } from '@kbn/core/public'; +import type { IconType } from '@elastic/eui'; +import { ConnectorIconsMap } from '@kbn/connector-specs/icons'; +import { + fromConnectorSpecSchema, + getMeta, + narrowSecretsSchemaForAuthMode, + setMeta, + type ConnectorZodSchema, +} from '@kbn/connector-specs'; +import { generateFormFields } from '@kbn/response-ops-form-generator'; +import type { + ConnectorSpecResponse, + ConnectorSpecWireResponse, +} from '../apis/fetch_connector_spec'; +import { transformConnectorSpecResponse } from '../apis/fetch_connector_spec'; +import type { ActionConnectorFieldsProps, ActionTypeModel } from '../types/action_types'; + +export type { ConnectorSpecResponse } from '../apis/fetch_connector_spec'; + +const WORKFLOWS_CONNECTOR_FEATURE_ID = 'workflows'; + +export function shouldHideWorkflowsOnlyConnector( + supportedFeatureIds: string[], + uiSettings?: IUiSettingsClient +): boolean { + if ( + supportedFeatureIds.length !== 1 || + supportedFeatureIds[0] !== WORKFLOWS_CONNECTOR_FEATURE_ID + ) { + return false; + } + return !(uiSettings?.get('workflows:ui:enabled', true) ?? true); +} + +/** + * Fetches a connector spec from the API. + */ +export async function fetchConnectorSpec( + http: HttpSetup, + connectorTypeId: string, + signal?: AbortSignal +): Promise { + const wire = await http.get( + `/internal/actions/connector_types/${encodeURIComponent(connectorTypeId)}/spec`, + { signal } + ); + return transformConnectorSpecResponse(wire); +} + +/** + * Resolves the icon for a connector based on the spec metadata. + */ +function getIconFromSpec(spec: ConnectorSpecResponse): IconType { + if (spec.metadata.icon) { + return spec.metadata.icon; + } + + const lazyIcon = ConnectorIconsMap.get(spec.metadata.id); + if (lazyIcon) { + return lazyIcon; + } + + return 'plugs'; +} + +/** + * Transforms a ConnectorSpecResponse into an ActionTypeModel. + * + * This creates a model that can be used by the connector form components, + * with dynamically generated form fields from the JSON schema. + */ +export function transformSpecToActionTypeModel( + spec: ConnectorSpecResponse, + uiSettings?: IUiSettingsClient +): ActionTypeModel { + return { + id: spec.metadata.id, + actionTypeTitle: spec.metadata.displayName, + source: ACTION_TYPE_SOURCES.spec, + selectMessage: spec.metadata.description, + iconClass: getIconFromSpec(spec), + subtype: undefined, + isExperimental: spec.metadata.isTechnicalPreview ?? false, + getHideInUi: (_actionTypes: ActionType[]) => + shouldHideWorkflowsOnlyConnector(spec.metadata.supportedFeatureIds, uiSettings), + actionConnectorFields: lazy(() => { + const parsedZodSchema = fromConnectorSpecSchema(spec.schema); + if (!parsedZodSchema) { + return Promise.reject(new Error(`Invalid connector spec schema for "${spec.metadata.id}"`)); + } + const connectorZodSchema: ConnectorZodSchema = parsedZodSchema; + function SpecConnectorFormFields({ readOnly, isEdit, authMode }: ActionConnectorFieldsProps) { + const narrowedSchema = useMemo( + () => narrowSecretsSchemaForAuthMode(connectorZodSchema, authMode), + // connectorZodSchema is stable for the lifetime of this lazy-loaded module; it is intentionally omitted from deps. + + [authMode] + ); + return generateFormFields({ + schema: narrowedSchema, + formConfig: { disabled: readOnly, isEdit }, + metaFunctions: { getMeta, setMeta }, + }); + } + return Promise.resolve({ + default: SpecConnectorFormFields, + }); + }), + // Spec-based connectors don't have custom action params UI + actionParamsFields: lazy(() => Promise.resolve({ default: () => null })), + // Validation is handled server-side via the Zod schema + validateParams: async () => ({ errors: {} }), + connectorForm: { + serializer: createConnectorFormSerializer() as unknown as NonNullable< + ActionTypeModel['connectorForm'] + >['serializer'], + deserializer: createConnectorFormDeserializer() as unknown as NonNullable< + ActionTypeModel['connectorForm'] + >['deserializer'], + }, + }; +} + +/** + * Copy secrets.authType to config.authType when saving the connector. + * This ensures authType persists since secrets are stripped by the API. + */ +function createConnectorFormSerializer() { + return (formData: Record) => { + const secrets = formData?.secrets as Record | undefined; + if (!secrets?.authType) { + return formData; + } + + const config = formData?.config as Record | undefined; + return { + ...formData, + config: { ...config, authType: secrets.authType }, + }; + }; +} + +/** + * Copies config.authType to secrets.authType when loading the connector. + * This allows the discriminated union widget to display the correct option on + * connector edit. + */ +function createConnectorFormDeserializer() { + return (apiData: Record) => { + const config = apiData?.config as Record | undefined; + const secrets = apiData?.secrets as Record | undefined; + + if (!config?.authType || secrets?.authType) { + return apiData; + } + + return { ...apiData, secrets: { ...(secrets ?? {}), authType: config.authType } }; + }; +} diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/index.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/index.ts new file mode 100644 index 0000000000000..b3bc66e3c73c4 --- /dev/null +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/common/utils/index.ts @@ -0,0 +1,10 @@ +/* + * 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 './action_type_model_utils'; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/tsconfig.json b/src/platform/packages/shared/kbn-alerts-ui-shared/tsconfig.json index 38e7b17098d61..f77f8ad1e095d 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/tsconfig.json +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/tsconfig.json @@ -15,6 +15,7 @@ "@kbn/triggers-actions-ui-types", "@kbn/alerting-types", "@kbn/actions-types", + "@kbn/connector-specs", "@kbn/data-views-plugin", "@kbn/unified-search-plugin", "@kbn/es-query", @@ -31,6 +32,7 @@ "@kbn/core-notifications-browser-mocks", "@kbn/shared-ux-table-persist", "@kbn/presentation-publishing", + "@kbn/response-ops-form-generator", "@kbn/response-ops-rules-apis", "@kbn/controls-constants", "@kbn/controls-schemas", @@ -40,6 +42,7 @@ "@kbn/es-ui-shared-plugin", "@kbn/licensing-plugin", "@kbn/licensing-types", - "@kbn/test-jest-helpers" + "@kbn/test-jest-helpers", + "@kbn/zod" ] } diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts index a2ce26ad3709a..168c1fdb490fa 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -9,7 +9,6 @@ export * as connectorsSpecs from './src/all_specs'; export type * from './src/connector_spec'; - export * as authTypeSpecs from './src/all_auth_types'; export { EARS_AUTH_ID, EARS_PROVIDERS } from './src/auth_types/ears'; export { OAUTH_AUTHORIZATION_CODE_AUTH_ID } from './src/auth_types/oauth_authorization_code'; @@ -20,3 +19,18 @@ export { normalizeAuthorizationHeaderValue } from './src/auth_types/oauth_authz_ export { ConnectorAuthorizationError, isConnectorAuthorizationError } from './src/errors'; export type { ConnectorAuthorizationReason } from './src/errors'; +export { + getSchemaForAuthType, + AUTH_TYPE_DISCRIMINATOR, + generateSecretsSchemaFromSpec, + configureAxiosInstanceWithSsl, + getMeta, + serializeConnectorSpec, + fromConnectorSpecSchema, + narrowSecretsSchemaForAuthMode, + AUTH_MODE_BY_AUTH_TYPE_ID, + type SerializeConnectorSpecOptions, + type ConnectorZodSchema, +} from './src/lib'; +export { setMeta, addMeta } from './src/connector_spec_ui'; +export type { BaseMetadata } from './src/connector_spec_ui'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/connector_spec_serialization.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/connector_spec_serialization.test.ts new file mode 100644 index 0000000000000..2b5a678992193 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/connector_spec_serialization.test.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import { fromJSONSchema } from '@kbn/zod/v4/from_json_schema'; +import * as connectorsSpecs from '../all_specs'; +import { serializeConnectorSpec } from './serialize_connector_spec'; +import { getMeta } from '../connector_spec_ui'; + +describe('connector spec serialization integration tests', () => { + describe('round-trip serialization', () => { + it('round-trips AlienVault OTX connector with single-option discriminated union', () => { + const spec = connectorsSpecs.AlienVaultOTXConnector; + const serialized = serializeConnectorSpec(spec); + + expect(serialized.schema).toBeDefined(); + + const rootSchema = fromJSONSchema(serialized.schema, { preserveMeta: true }) as unknown as { + shape?: Record; + parse: (data: unknown) => unknown; + }; + expect(rootSchema).toBeDefined(); + expect(rootSchema.shape).toBeDefined(); + + const validData = { + config: {}, + secrets: { authType: 'api_key_header', 'X-OTX-API-KEY': 'test-key' }, + }; + expect(() => rootSchema?.parse(validData)).not.toThrow(); + + const secretsSchema = rootSchema.shape?.secrets; + expect(secretsSchema).toBeDefined(); + expect(secretsSchema).toBeInstanceOf(z.ZodDiscriminatedUnion); + const secretsUnion = secretsSchema as z.ZodDiscriminatedUnion; + + // the schema looks like: + // { + // secrets: z.discriminatedUnion('authType', [ + // z.object({ + // X-OTX-API-KEY: z.string().min(1).meta({ label: 'API key', sensitive: true }), + // authType: z.literal('api_key_header'), + // }).meta({ label: 'API key header authentication' }), + // ]).meta({ label: 'Authentication' }), + // } + + // root level meta + const metadata = getMeta(secretsUnion); + expect(metadata.label).toBe('Authentication'); + + // option level meta + expect(secretsUnion.options.length).toBe(1); + const authOption = secretsUnion.options[0] as z.ZodObject; + expect(authOption).toBeInstanceOf(z.ZodObject); + const optionMeta = getMeta(authOption); + expect(optionMeta.label).toBe('API key header authentication'); + + // field level meta within the option + const optionShape = (authOption as z.ZodObject).shape as Record; + + const apiKeyField = optionShape['X-OTX-API-KEY']; + expect(apiKeyField).toBeDefined(); + const apiKeyMeta = getMeta(apiKeyField); + expect(apiKeyMeta.label).toBe('API key'); + expect(apiKeyMeta.sensitive).toBe(true); + + const discriminatorField = optionShape.authType; + expect((discriminatorField as z.ZodLiteral).value).toBe('api_key_header'); + }); + + it('round-trips all available connector specs without throwing', () => { + const specNames = Object.keys(connectorsSpecs); + expect(specNames.length).toBeGreaterThan(0); + + const skipUntilZodJsonSchemaSupportsTransforms = new Set(['SharepointServer', 'Snowflake']); + + for (const specName of specNames) { + if (skipUntilZodJsonSchemaSupportsTransforms.has(specName)) { + continue; + } + const spec = connectorsSpecs[specName as keyof typeof connectorsSpecs]; + const serialized = serializeConnectorSpec(spec); + + expect(serialized.schema).toBeDefined(); + + const combinedZod = fromJSONSchema(serialized.schema); + expect(combinedZod).toBeDefined(); + } + }); + + it('preserves validation behavior after round-trip', () => { + const spec = connectorsSpecs.VirusTotalConnector; + const serialized = serializeConnectorSpec(spec); + + const combinedZod = fromJSONSchema(serialized.schema) as z.ZodObject<{ + config: z._ZodType; + secrets: z._ZodType; + }>; + expect(combinedZod).toBeDefined(); + + const validData = { + config: {}, + secrets: { authType: 'api_key_header', 'x-apikey': 'apikey' }, + }; + + const parseResult = combinedZod.safeParse(validData); + expect(parseResult.success).toBe(true); + + const invalidData = { + config: {}, + secrets: { authType: 'api_key_header' }, + }; + const invalidResult = combinedZod.safeParse(invalidData); + expect(invalidResult.success).toBe(false); + }); + }); + + describe('meta information preservation', () => { + it('serializeConnectorSpec → JSON → fromJSONSchema → getMeta', () => { + const spec = connectorsSpecs.AlienVaultOTXConnector; + const serialized = serializeConnectorSpec(spec); + + const jsonString = JSON.stringify(serialized); + const apiResponse = JSON.parse(jsonString); + + const zodSchema = fromJSONSchema(apiResponse.schema, { preserveMeta: true }) as z.ZodObject<{ + config: z._ZodType; + secrets: z._ZodType; + }>; + expect(zodSchema).toBeDefined(); + + const secrets = zodSchema?.shape?.secrets; + expect(secrets).toBeInstanceOf(z.ZodDiscriminatedUnion); + + const secretsMeta = getMeta(secrets); + expect(secretsMeta.label).toBe('Authentication'); + + const option = (secrets as z.ZodDiscriminatedUnion).options[0] as z.ZodObject; + const optionMeta = getMeta(option); + expect(optionMeta.label).toBe('API key header authentication'); + }); + + it('preserves sensitive meta flag through round-trip', () => { + const spec = connectorsSpecs.VirusTotalConnector; + const serialized = serializeConnectorSpec(spec); + + const zodSchema = fromJSONSchema(serialized.schema, { preserveMeta: true }) as z.ZodObject<{ + config: z.ZodType; + secrets: z.ZodDiscriminatedUnion; + }>; + + const secrets = zodSchema.shape.secrets; + const option = secrets.options[0] as z.ZodObject<{ + 'x-apikey': z.ZodType; + authType: z.ZodType; + }>; + const apiKeyField = option.shape['x-apikey']; + + const meta = getMeta(apiKeyField); + expect(meta.sensitive).toBe(true); + }); + + it('preserves all meta attributes through network round-trip', () => { + const spec = connectorsSpecs.AlienVaultOTXConnector; + const serialized = serializeConnectorSpec(spec); + + const jsonString = JSON.stringify(serialized); + const parsed = JSON.parse(jsonString); + + const zodSchema = fromJSONSchema(parsed.schema, { preserveMeta: true }) as z.ZodObject<{ + config: z.ZodType; + secrets: z.ZodDiscriminatedUnion; + }>; + + const secrets = zodSchema.shape.secrets; + const option = secrets.options[0] as z.ZodObject; + const optionShape = option.shape as Record; + const apiKeyField = optionShape['X-OTX-API-KEY']; + + const fieldMeta = getMeta(apiKeyField); + expect(fieldMeta.label).toBe('API key'); + expect(fieldMeta.sensitive).toBe(true); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/deserialize_connector_spec.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/deserialize_connector_spec.test.ts new file mode 100644 index 0000000000000..6cfc5aa3465e4 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/deserialize_connector_spec.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import * as connectorsSpecs from '../all_specs'; +import { serializeConnectorSpec } from './serialize_connector_spec'; +import { fromConnectorSpecSchema } from './deserialize_connector_spec'; + +describe('fromConnectorSpecSchema', () => { + describe('validation', () => { + it('returns undefined for non-object schema', () => { + const stringSchema = { type: 'string' }; + + const result = fromConnectorSpecSchema(stringSchema); + expect(result).toBeUndefined(); + }); + + it('returns undefined for object without config/secrets', () => { + const incompleteSchema = { + type: 'object', + properties: { + foo: { type: 'string' }, + }, + }; + + const result = fromConnectorSpecSchema(incompleteSchema); + expect(result).toBeUndefined(); + }); + + it('returns undefined for schema with only config (missing secrets)', () => { + const schema = { + type: 'object', + properties: { + config: { type: 'object', properties: {} }, + }, + }; + + const result = fromConnectorSpecSchema(schema); + expect(result).toBeUndefined(); + }); + + it('returns undefined for schema with only secrets (missing config)', () => { + const schema = { + type: 'object', + properties: { + secrets: { type: 'object', properties: {} }, + }, + }; + + const result = fromConnectorSpecSchema(schema); + expect(result).toBeUndefined(); + }); + }); + + describe('valid schemas', () => { + it('returns valid ConnectorZodSchema for properly structured connector schemas', () => { + const spec = connectorsSpecs.VirusTotalConnector; + const serialized = serializeConnectorSpec(spec); + const zodSchema = fromConnectorSpecSchema(serialized.schema); + + expect(zodSchema).toBeDefined(); + expect(zodSchema?.shape.config).toBeInstanceOf(z.ZodObject); + expect(zodSchema?.shape.secrets).toBeInstanceOf(z.ZodDiscriminatedUnion); + }); + + it('preserves schema structure for AlienVault OTX connector', () => { + const spec = connectorsSpecs.AlienVaultOTXConnector; + const serialized = serializeConnectorSpec(spec); + const zodSchema = fromConnectorSpecSchema(serialized.schema); + + expect(zodSchema).toBeDefined(); + expect(zodSchema?.shape.config).toBeDefined(); + expect(zodSchema?.shape.secrets).toBeDefined(); + }); + + it('handles connector with multiple auth types', () => { + const testSpec = { + metadata: { + id: '.test-multi-auth', + displayName: 'Test Multi Auth', + description: 'Test connector with multiple auth types', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + auth: { + types: ['basic', 'bearer', 'api_key_header'], + }, + actions: { + testAction: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const serialized = serializeConnectorSpec(testSpec); + const zodSchema = fromConnectorSpecSchema(serialized.schema); + + expect(zodSchema).toBeDefined(); + expect(zodSchema?.shape.secrets).toBeInstanceOf(z.ZodDiscriminatedUnion); + + const secretsUnion = (zodSchema as z.ZodObject).shape.secrets; + expect(secretsUnion.options.length).toBe(3); + + const authTypes = secretsUnion.options.map((zodObj: z.ZodObject) => { + const shape = zodObj.shape as { authType: z.ZodLiteral }; + return shape.authType.value; + }); + + expect(authTypes).toEqual(expect.arrayContaining(['basic', 'bearer', 'api_key_header'])); + }); + + it('handles connector without auth types', () => { + const testSpec = { + metadata: { + id: '.test-no-auth', + displayName: 'Test No Auth', + description: 'Test connector without auth', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + actions: { + testAction: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const serialized = serializeConnectorSpec(testSpec); + const zodSchema = fromConnectorSpecSchema(serialized.schema); + + expect(zodSchema).toBeDefined(); + expect(zodSchema?.shape.config).toBeInstanceOf(z.ZodObject); + expect(zodSchema?.shape.secrets).toBeInstanceOf(z.ZodObject); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/deserialize_connector_spec.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/deserialize_connector_spec.ts new file mode 100644 index 0000000000000..024f48e9d0c97 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/deserialize_connector_spec.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ZodDiscriminatedUnion, ZodObject, type ZodRawShape } from '@kbn/zod/v4'; +import { fromJSONSchema } from '@kbn/zod/v4/from_json_schema'; + +interface ConnectorShape { + config: ZodObject; + secrets: ZodObject | ZodDiscriminatedUnion; +} + +export type ConnectorZodSchema = ZodObject & { + shape: ConnectorShape; +}; + +export function fromConnectorSpecSchema( + jsonSchema: Record +): ConnectorZodSchema | undefined { + const schema = fromJSONSchema(jsonSchema, { preserveMeta: true }); + + if (!schema || !(schema instanceof ZodObject)) { + return undefined; + } + + const { config, secrets } = schema.shape; + const hasValidSecrets = secrets instanceof ZodObject || secrets instanceof ZodDiscriminatedUnion; + + if (!(config instanceof ZodObject) || !hasValidSecrets) { + return undefined; + } + return schema.strict() as ConnectorZodSchema; +} diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/index.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/index.ts index 79a42c03d4b2e..0423f5920c217 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/lib/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/index.ts @@ -11,3 +11,12 @@ export { getSchemaForAuthType, AUTH_TYPE_DISCRIMINATOR } from './get_schema_for_ export { generateSecretsSchemaFromSpec } from './generate_secrets_schema_from_spec'; export { configureAxiosInstanceWithSsl } from './configure_axios_instance_with_ssl'; export { getMeta } from '../connector_spec_ui'; +export { + serializeConnectorSpec, + type SerializeConnectorSpecOptions, +} from './serialize_connector_spec'; +export { fromConnectorSpecSchema, type ConnectorZodSchema } from './deserialize_connector_spec'; +export { + narrowSecretsSchemaForAuthMode, + AUTH_MODE_BY_AUTH_TYPE_ID, +} from './narrow_secrets_schema_for_auth_mode'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/narrow_secrets_schema_for_auth_mode.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/narrow_secrets_schema_for_auth_mode.test.ts new file mode 100644 index 0000000000000..04d1c0caa2b34 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/narrow_secrets_schema_for_auth_mode.test.ts @@ -0,0 +1,87 @@ +/* + * 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, ZodDiscriminatedUnion, ZodObject } from '@kbn/zod/v4'; +import { SharepointOnline } from '../specs/sharepoint_online/sharepoint_online'; +import { fromConnectorSpecSchema, type ConnectorZodSchema } from './deserialize_connector_spec'; +import { generateSecretsSchemaFromSpec } from './generate_secrets_schema_from_spec'; +import { + AUTH_MODE_BY_AUTH_TYPE_ID, + narrowSecretsSchemaForAuthMode, +} from './narrow_secrets_schema_for_auth_mode'; +import { serializeConnectorSpec } from './serialize_connector_spec'; + +// Extracts allowed `authType` discriminator values from `secrets` so tests can assert that +// authMode narrowing keeps only the expected auth branches. +function getAllowedSecretsAuthTypes(schema: ConnectorZodSchema): string[] { + const { secrets } = schema.shape; + if (!(secrets instanceof ZodDiscriminatedUnion)) { + throw new Error('expected secrets to be a discriminated union'); + } + const discriminator = secrets.def.discriminator; + return secrets.options.map((option) => { + if (!(option instanceof ZodObject)) { + throw new Error('expected discriminated union branches to be objects'); + } + const discField = option.shape[discriminator]; + if (!(discField instanceof z.ZodLiteral) || typeof discField.value !== 'string') { + throw new Error('expected auth discriminator values to be string literals'); + } + return discField.value; + }); +} + +describe('narrowSecretsSchemaForAuthMode', () => { + const sharepointSchema = fromConnectorSpecSchema( + serializeConnectorSpec(SharepointOnline).schema as Record + ); + + it('returns the same schema reference when authMode is undefined', () => { + if (!sharepointSchema) { + throw new Error('expected SharePoint schema'); + } + const narrowed = narrowSecretsSchemaForAuthMode(sharepointSchema, undefined); + expect(narrowed).toBe(sharepointSchema); + }); + + it("narrows to shared branches when authMode is 'shared'", () => { + if (!sharepointSchema) { + throw new Error('expected SharePoint schema'); + } + const narrowed = narrowSecretsSchemaForAuthMode(sharepointSchema, 'shared'); + expect(narrowed).not.toBe(sharepointSchema); + const literals = getAllowedSecretsAuthTypes(narrowed); + expect(literals).toContain('oauth_client_credentials'); + expect(literals).not.toContain('oauth_authorization_code'); + expect(literals).not.toContain('ears'); + }); + + it("narrows to per-user branches when authMode is 'per-user'", () => { + if (!sharepointSchema) { + throw new Error('expected SharePoint schema'); + } + const narrowed = narrowSecretsSchemaForAuthMode(sharepointSchema, 'per-user'); + expect(narrowed).not.toBe(sharepointSchema); + const literals = getAllowedSecretsAuthTypes(narrowed); + expect(literals.length).toBeGreaterThan(0); + expect(literals.every((id) => AUTH_MODE_BY_AUTH_TYPE_ID[id] === 'per-user')).toBe(true); + expect(literals).not.toContain('oauth_client_credentials'); + }); + + it('returns the same schema reference when no branches match the requested authMode', () => { + const schema = z + .object({ + config: z.object({}), + secrets: generateSecretsSchemaFromSpec({ types: ['basic'] }), + }) + .strict() as unknown as ConnectorZodSchema; + const narrowed = narrowSecretsSchemaForAuthMode(schema, 'per-user'); + expect(narrowed).toBe(schema); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/narrow_secrets_schema_for_auth_mode.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/narrow_secrets_schema_for_auth_mode.ts new file mode 100644 index 0000000000000..010ae2da29889 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/narrow_secrets_schema_for_auth_mode.ts @@ -0,0 +1,86 @@ +/* + * 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, ZodDiscriminatedUnion, ZodObject, type ZodRawShape } from '@kbn/zod/v4'; +import type { AuthMode } from '../connector_spec'; +import * as allAuthTypes from '../all_auth_types'; +import type { ConnectorZodSchema } from './deserialize_connector_spec'; +import { getMeta } from '../connector_spec_ui'; + +function isAuthTypeSpecEntry(value: unknown): value is { id: string; authMode?: AuthMode } { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof (value as { id: unknown }).id === 'string' + ); +} + +export const AUTH_MODE_BY_AUTH_TYPE_ID: Record = Object.fromEntries( + Object.values(allAuthTypes) + .filter(isAuthTypeSpecEntry) + .map((spec) => [spec.id, spec.authMode ?? 'shared']) +) as Record; + +function resolveAuthModeForAuthTypeId(authTypeId: string): AuthMode { + return AUTH_MODE_BY_AUTH_TYPE_ID[authTypeId] ?? 'shared'; +} + +function toDiscriminatedUnionOptions( + filteredOptions: Array> +): [ZodObject, ...ZodObject[]] { + const [first, ...rest] = filteredOptions; + return [first, ...rest] as [ZodObject, ...ZodObject[]]; +} + +export function narrowSecretsSchemaForAuthMode( + schema: ConnectorZodSchema, + authMode: AuthMode | undefined +): ConnectorZodSchema { + if (authMode === undefined) { + return schema; + } + + const { secrets } = schema.shape; + if (!(secrets instanceof ZodDiscriminatedUnion)) { + return schema; + } + + // Zod v4: discriminator key lives on `def`, not a stable public accessor. We rely on the same + // shape as `fromConnectorSpecSchema` / `ZodDiscriminatedUnion`; if Zod internals change, update here. + const discriminator = secrets.def.discriminator; + const filteredOptions: Array> = []; + for (const option of secrets.options) { + if (!(option instanceof ZodObject)) { + continue; + } + const discField = option.shape[discriminator]; + if (!(discField instanceof z.ZodLiteral) || typeof discField.value !== 'string') { + continue; + } + if (resolveAuthModeForAuthTypeId(discField.value) === authMode) { + filteredOptions.push(option); + } + } + + if (filteredOptions.length === 0 || filteredOptions.length === secrets.options.length) { + return schema; + } + + const newSecrets = z + .discriminatedUnion(discriminator, toDiscriminatedUnionOptions(filteredOptions)) + .meta(getMeta(secrets)); + + return z + .object({ + ...schema.shape, + secrets: newSecrets, + }) + .strict() as ConnectorZodSchema; +} diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts new file mode 100644 index 0000000000000..4516d90441430 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.test.ts @@ -0,0 +1,395 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import * as connectorsSpecs from '../all_specs'; +import * as generateSecretsModule from './generate_secrets_schema_from_spec'; +import { serializeConnectorSpec } from './serialize_connector_spec'; + +describe('serializeConnectorSpec', () => { + describe('basic structure', () => { + it('returns object with metadata and schema properties', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test Connector', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + expect(result).toHaveProperty('metadata'); + expect(result).toHaveProperty('schema'); + }); + + it('passes through metadata unchanged', () => { + const spec = { + metadata: { + id: '.test-connector', + displayName: 'Test Connector', + description: 'A test connector', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + expect(result.metadata).toEqual(spec.metadata); + }); + + it('creates JSON Schema with config and secrets properties', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + auth: { + types: ['basic'], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + expect(result.schema).toHaveProperty('type', 'object'); + expect(result.schema).toHaveProperty('properties'); + const properties = result.schema.properties as Record; + expect(properties).toHaveProperty('config'); + expect(properties).toHaveProperty('secrets'); + }); + }); + + describe('config schema handling', () => { + it('creates empty config object when schema is undefined', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + const properties = result.schema.properties as Record; + const config = properties.config as Record; + expect(config.type).toBe('object'); + }); + + it('includes config schema when provided', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + schema: z.object({ + apiUrl: z.url(), + timeout: z.number().optional(), + }), + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + const properties = result.schema.properties as Record; + const config = properties.config as Record; + const configProps = config.properties as Record; + + expect(configProps).toHaveProperty('apiUrl'); + expect(configProps).toHaveProperty('timeout'); + }); + }); + + describe('Secrets schema generation', () => { + it('creates empty secrets object when no auth types provided', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + const properties = result.schema.properties as Record; + const secrets = properties.secrets as Record; + expect(secrets.type).toBe('object'); + expect(secrets.properties).toEqual({}); + }); + + it('creates discriminated union for single auth type', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + auth: { + types: ['basic'], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + const properties = result.schema.properties as Record; + const secrets = properties.secrets as { + anyOf?: unknown[]; + oneOf?: unknown[]; + }; + + const union = secrets.anyOf ?? secrets.oneOf; + expect(union).toBeDefined(); + expect(union?.length).toBe(1); + + const options = union as Array<{ properties?: Record }>; + expect(options[0].properties).toHaveProperty('authType'); + }); + + it('creates discriminated union for multiple auth types', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + auth: { + types: ['basic', 'bearer', 'api_key_header'], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const result = serializeConnectorSpec(spec); + + const properties = result.schema.properties as Record; + const secrets = properties.secrets as Record & { + anyOf?: Array<{ properties: unknown }>; + oneOf?: Array<{ properties: unknown }>; + }; + + const union = secrets.anyOf ?? secrets.oneOf; + expect(union).toBeDefined(); + expect(union?.length).toBe(3); + + for (const option of union ?? []) { + expect(option.properties).toHaveProperty('authType'); + } + }); + + it('passes isPfxEnabled to generateSecretsSchemaFromSpec (PFX auth type is not registered in all_auth_types)', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + auth: { + types: ['basic', 'bearer'], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const spy = jest.spyOn(generateSecretsModule, 'generateSecretsSchemaFromSpec'); + + serializeConnectorSpec(spec, { isPfxEnabled: false }); + + expect(spy).toHaveBeenCalledWith(spec.auth, { + isPfxEnabled: false, + isEarsEnabled: false, + }); + + spy.mockRestore(); + }); + + it('with isEarsEnabled: true includes ears in secrets union when spec lists ears', () => { + const spec = { + metadata: { + id: '.test', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['workflows' as const], + }, + auth: { + types: ['basic', 'ears'], + }, + actions: { + test: { + input: z.object({}), + handler: async () => ({ success: true }), + }, + }, + }; + + const defaultEars = serializeConnectorSpec(spec); + const earsOn = serializeConnectorSpec(spec, { isEarsEnabled: true }); + interface SecretBranch { + properties?: { authType?: { const?: string } }; + } + + const defaultProperties = defaultEars.schema.properties as Record; + const defaultSecrets = defaultProperties.secrets as Record; + const defaultBranches = defaultSecrets.anyOf ?? defaultSecrets.oneOf ?? []; + + const earsOnProperties = earsOn.schema.properties as Record; + const earsOnSecrets = earsOnProperties.secrets as Record; + const earsOnBranches = earsOnSecrets.anyOf ?? earsOnSecrets.oneOf ?? []; + + expect(defaultBranches.map((branch) => branch.properties?.authType?.const)).toEqual([ + 'basic', + ]); + expect(earsOnBranches.map((branch) => branch.properties?.authType?.const)).toEqual([ + 'basic', + 'ears', + ]); + }); + }); + + describe('Meta information preservation', () => { + it('preserves meta (label, sensitive, placeholder) in JSON Schema output', () => { + const originalSchema = z.object({ + apiKey: z.string().meta({ + label: 'API Key', + sensitive: true, + placeholder: 'sk-...', + }), + url: z.string().meta({ + label: 'Server URL', + helpText: 'The base URL of the API', + placeholder: 'https://api.example.com', + }), + timeout: z.number().optional().meta({ + label: 'Timeout (seconds)', + disabled: false, + }), + }); + + const jsonSchema = z.toJSONSchema(originalSchema) as Record; + const properties = jsonSchema.properties as Record>; + + expect(properties.apiKey.label).toBe('API Key'); + expect(properties.apiKey.sensitive).toBe(true); + expect(properties.apiKey.placeholder).toBe('sk-...'); + expect(properties.apiKey.type).toBe('string'); + + expect(properties.url.label).toBe('Server URL'); + expect(properties.url.helpText).toBe('The base URL of the API'); + expect(properties.url.placeholder).toBe('https://api.example.com'); + + expect(properties.timeout.label).toBe('Timeout (seconds)'); + expect(properties.timeout.disabled).toBe(false); + expect(properties.timeout.type).toBe('number'); + }); + }); + + describe('Real-world connectors', () => { + it('successfully serializes AlienVault OTX connector', () => { + const spec = connectorsSpecs.AlienVaultOTXConnector; + const serialized = serializeConnectorSpec(spec); + + expect(serialized.metadata).toBeDefined(); + expect(serialized.schema).toBeDefined(); + expect(serialized.schema.type).toBe('object'); + }); + + it('successfully serializes VirusTotal connector', () => { + const spec = connectorsSpecs.VirusTotalConnector; + const serialized = serializeConnectorSpec(spec); + + expect(serialized.metadata).toBeDefined(); + expect(serialized.schema).toBeDefined(); + expect(serialized.schema.type).toBe('object'); + }); + + it('successfully serializes all available connector specs', () => { + const specNames = Object.keys(connectorsSpecs); + expect(specNames.length).toBeGreaterThan(0); + + const skipUntilZodJsonSchemaSupportsTransforms = new Set(['SharepointServer', 'Snowflake']); + + for (const specName of specNames) { + if (skipUntilZodJsonSchemaSupportsTransforms.has(specName)) { + continue; + } + const spec = connectorsSpecs[specName as keyof typeof connectorsSpecs]; + expect(() => serializeConnectorSpec(spec)).not.toThrow(); + } + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts new file mode 100644 index 0000000000000..0db1a5c4cedc0 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/lib/serialize_connector_spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import type { ConnectorSpec } from '../connector_spec'; +import { generateSecretsSchemaFromSpec } from './generate_secrets_schema_from_spec'; + +export interface SerializeConnectorSpecOptions { + isPfxEnabled?: boolean; + isEarsEnabled?: boolean; +} + +export function serializeConnectorSpec( + spec: ConnectorSpec, + options?: SerializeConnectorSpecOptions +) { + const combinedZodSchema = z.object({ + config: spec.schema ?? z.object({}), + secrets: generateSecretsSchemaFromSpec(spec.auth, { + isPfxEnabled: options?.isPfxEnabled ?? true, + isEarsEnabled: options?.isEarsEnabled ?? false, + }), + }); + + const jsonSchema = z.toJSONSchema(combinedZodSchema); + + return { + metadata: spec.metadata, + schema: jsonSchema as Record, + }; +} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/connectors.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/connectors.ts index 011a987fff942..03049b92e208f 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/connectors.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/connectors.ts @@ -21,6 +21,8 @@ export const mockActionTypes = [ subFeature: undefined, isDeprecated: false, allowMultipleSystemActions: undefined, + description: undefined, + isExperimental: undefined, } as ActionType, { id: '.bedrock', @@ -34,6 +36,8 @@ export const mockActionTypes = [ subFeature: undefined, isDeprecated: false, allowMultipleSystemActions: undefined, + description: undefined, + isExperimental: undefined, } as ActionType, { id: '.gemini', @@ -47,6 +51,8 @@ export const mockActionTypes = [ subFeature: undefined, isDeprecated: false, allowMultipleSystemActions: undefined, + description: undefined, + isExperimental: undefined, } as ActionType, ]; diff --git a/x-pack/platform/packages/shared/response-ops/form-generator/src/schema_extract_core.test.ts b/x-pack/platform/packages/shared/response-ops/form-generator/src/schema_extract_core.test.ts index 4803a4394c289..991558b199730 100644 --- a/x-pack/platform/packages/shared/response-ops/form-generator/src/schema_extract_core.test.ts +++ b/x-pack/platform/packages/shared/response-ops/form-generator/src/schema_extract_core.test.ts @@ -600,8 +600,9 @@ describe('extractSchemaCore', () => { const { schema: unwrapped } = extractSchemaCore(defaultSchema, meta); - expect(getMeta(unwrapped).label).toBe('Test URL'); - expect(getMeta(unwrapped).placeholder).toBe('https://'); + const schemaMeta = getMeta(unwrapped); + expect(schemaMeta.label).toBe('Test URL'); + expect(schemaMeta.placeholder).toBe('https://'); }); it('should preserve meta when meta exists on both wrapper and inner schema', () => { diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/index.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/index.ts new file mode 100644 index 0000000000000..b430bbc5ab9d5 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getConnectorSpecParamsSchema } from './schemas/latest'; +export type { GetConnectorSpecParams } from './types/latest'; + +export { getConnectorSpecParamsSchema as getConnectorSpecParamsSchemaV1 } from './schemas/v1'; +export type { GetConnectorSpecParams as GetConnectorSpecParamsV1 } from './types/v1'; + +export type { GetConnectorSpecResponseV1 } from '../../response'; +export { getConnectorSpecResponseBodySchema as getConnectorSpecResponseBodySchemaV1 } from '../../response'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/schemas/latest.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/schemas/latest.ts new file mode 100644 index 0000000000000..efec01a46a66e --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/schemas/latest.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 { getConnectorSpecParamsSchema } from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/schemas/v1.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/schemas/v1.ts new file mode 100644 index 0000000000000..96b531454f500 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/schemas/v1.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const getConnectorSpecParamsSchema = schema.object({ + id: schema.string({ + minLength: 1, + meta: { + description: 'The connector type identifier.', + }, + }), +}); diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/types/latest.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/types/latest.ts new file mode 100644 index 0000000000000..ab3f7581b2043 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/types/latest.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 type * from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/types/v1.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/types/v1.ts new file mode 100644 index 0000000000000..9d27f1aaed0e3 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/get_spec/types/v1.ts @@ -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. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import type { getConnectorSpecParamsSchemaV1 } from '..'; + +export type GetConnectorSpecParams = TypeOf; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/response/index.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/response/index.ts index 6f8a0ea3385a0..31ea8e892e6ee 100644 --- a/x-pack/platform/plugins/shared/actions/common/routes/connector/response/index.ts +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/response/index.ts @@ -11,6 +11,7 @@ export type { GetAllConnectorsResponse, ConnectorExecuteResponse, ConnectorAuthStatusResponse, + GetConnectorSpecResponse, } from './types/latest'; export { @@ -19,6 +20,7 @@ export { connectorTypeResponseSchema, connectorExecuteResponseSchema, connectorAuthStatusResponseSchema, + getConnectorSpecResponseBodySchema, } from './schemas/latest'; export type { @@ -28,6 +30,7 @@ export type { GetAllConnectorTypesResponse as GetAllConnectorTypesResponseV1, ConnectorExecuteResponse as ConnectorExecuteResponseV1, ConnectorAuthStatusResponse as ConnectorAuthStatusResponseV1, + GetConnectorSpecResponse as GetConnectorSpecResponseV1, } from './types/v1'; export { @@ -37,4 +40,5 @@ export { getAllConnectorTypesResponseSchema as getAllConnectorTypesResponseSchemaV1, connectorExecuteResponseSchema as connectorExecuteResponseSchemaV1, connectorAuthStatusResponseSchema as connectorAuthStatusResponseSchemaV1, + getConnectorSpecResponseBodySchema as getConnectorSpecResponseBodySchemaV1, } from './schemas/v1'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/response/schemas/v1.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/response/schemas/v1.ts index 9d8b6e924feac..6db6b77d1bb47 100644 --- a/x-pack/platform/plugins/shared/actions/common/routes/connector/response/schemas/v1.ts +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/response/schemas/v1.ts @@ -134,6 +134,20 @@ export const connectorTypeResponseSchema = schema.object({ description: 'The source of the connector type definition.', }, }), + description: schema.maybe( + schema.string({ + meta: { + description: 'Description of the connector type.', + }, + }) + ), + is_experimental: schema.maybe( + schema.boolean({ + meta: { + description: 'When true, the connector type is a technical preview in the UI.', + }, + }) + ), }); export const getAllConnectorTypesResponseSchema = schema.arrayOf(connectorTypeResponseSchema); @@ -228,3 +242,69 @@ export const connectorAuthStatusResponseSchema = schema.recordOf( }, } ); + +export const getConnectorSpecResponseBodySchema = schema.object({ + metadata: schema.object( + { + id: schema.string({ + meta: { + description: 'The connector type identifier (same shape as an action type id).', + }, + }), + display_name: schema.string({ + meta: { + description: 'Human-readable label for this connector type.', + }, + }), + description: schema.string({ + meta: { + description: 'Short summary of what this connector type is used for.', + }, + }), + minimum_license: schema.string({ + meta: { + description: 'Minimum Elastic license tier required to use this connector type.', + }, + }), + supported_feature_ids: schema.arrayOf(schema.string(), { + maxSize: 100, + meta: { + description: + 'Kibana feature identifiers this connector type supports (for example alerting or workflows).', + }, + }), + icon: schema.maybe( + schema.string({ + meta: { + description: 'Optional icon key or URL for this connector type in the UI.', + }, + }) + ), + docs_url: schema.maybe( + schema.string({ + meta: { + description: 'Optional link to documentation for this connector type.', + }, + }) + ), + is_technical_preview: schema.maybe( + schema.boolean({ + meta: { + description: 'When true, this connector type is offered as a technical preview.', + }, + }) + ), + }, + { + meta: { + description: 'Connector spec metadata (snake_case HTTP shape).', + }, + } + ), + schema: schema.recordOf(schema.string(), schema.any(), { + meta: { + description: + 'JSON Schema envelope for the connector form (top-level `config` and `secrets` shapes)', + }, + }), +}); diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/response/types/v1.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/response/types/v1.ts index a67c8c0d64cca..f627e9687d5cb 100644 --- a/x-pack/platform/plugins/shared/actions/common/routes/connector/response/types/v1.ts +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/response/types/v1.ts @@ -14,6 +14,7 @@ import type { getAllConnectorTypesResponseSchemaV1, connectorAuthStatusResponseSchemaV1, } from '..'; +import type { getConnectorSpecResponseBodySchema } from '../schemas/v1'; export type ConnectorResponse = TypeOf; export type GetAllConnectorsResponse = TypeOf; @@ -21,3 +22,4 @@ export type ConnectorTypeResponse = TypeOf export type GetAllConnectorTypesResponse = TypeOf; export type ConnectorExecuteResponse = TypeOf; export type ConnectorAuthStatusResponse = TypeOf; +export type GetConnectorSpecResponse = TypeOf; diff --git a/x-pack/platform/plugins/shared/actions/common/types.ts b/x-pack/platform/plugins/shared/actions/common/types.ts index 52d990363926c..6731ec23b48f0 100644 --- a/x-pack/platform/plugins/shared/actions/common/types.ts +++ b/x-pack/platform/plugins/shared/actions/common/types.ts @@ -33,6 +33,8 @@ export interface ActionType { validate?: { params: PublicValidatorType; }; + description?: string; + isExperimental?: boolean; } export enum InvalidEmailReason { diff --git a/x-pack/platform/plugins/shared/actions/server/action_type_registry.ts b/x-pack/platform/plugins/shared/actions/server/action_type_registry.ts index c2653025c5258..e9e1c36b2ec5a 100644 --- a/x-pack/platform/plugins/shared/actions/server/action_type_registry.ts +++ b/x-pack/platform/plugins/shared/actions/server/action_type_registry.ts @@ -281,6 +281,8 @@ export class ActionTypeRegistry { : {}), isDeprecated: !!actionType.isDeprecated, allowMultipleSystemActions: actionType.allowMultipleSystemActions, + description: actionType.description, + isExperimental: actionType.isExperimental, })); } diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.mock.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.mock.ts index d7a115211c0a0..81fe540ad7770 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.mock.ts @@ -20,6 +20,7 @@ const createActionsClientMock = () => { getAll: jest.fn(), getAllSystemConnectors: jest.fn(), getAuthStatus: jest.fn(), + getConnectorSpec: jest.fn(), getBulk: jest.fn(), getOAuthAccessToken: jest.fn(), execute: jest.fn(), diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts index 72fdea35a8816..060e1b8c2882b 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts @@ -29,6 +29,7 @@ import type { ConnectorType } from '../application/connector/types'; import { get } from '../application/connector/methods/get'; import { getAll, getAllSystemConnectors } from '../application/connector/methods/get_all'; import { getAuthStatus } from '../application/connector/methods/get_auth_status'; +import { getConnectorSpec as getConnectorSpecWithContext } from '../application/connector/methods/get_connector_spec'; import type { GetAuthStatusResult } from '../application/connector/methods/get_auth_status/types'; import { update } from '../application/connector/methods/update'; import { listTypes } from '../application/connector/methods/list_types'; @@ -267,6 +268,20 @@ export class ActionsClient { return getAuthStatus({ context: this.context }); } + public async getConnectorSpec({ + id, + configurationUtilities, + }: { + id: string; + configurationUtilities: ActionsConfigurationUtilities; + }) { + return getConnectorSpecWithContext({ + context: this.context, + id, + configurationUtilities, + }); + } + /** * Get bulk actions with in-memory list */ diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.test.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.test.ts new file mode 100644 index 0000000000000..cc5969aaa381d --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import type { ActionsClientContext } from '../../../../actions_client'; +import type { ActionsConfigurationUtilities } from '../../../../actions_config'; +import { actionsAuthorizationMock } from '../../../../authorization/actions_authorization.mock'; +import { getConnectorSpec } from './get_connector_spec'; + +const authorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const configurationUtilities = { + getWebhookSettings: () => ({ ssl: { pfx: { enabled: true } } }), + isEarsEnabled: () => false, +} as unknown as ActionsConfigurationUtilities; + +function createContext(): ActionsClientContext { + return { authorization, auditLogger } as unknown as ActionsClientContext; +} + +describe('getConnectorSpec', () => { + beforeEach(() => { + jest.clearAllMocks(); + authorization.ensureAuthorized.mockResolvedValue(undefined); + }); + + describe('authorization', () => { + test('ensures user is authorised to get actions before returning a connector type spec', async () => { + await getConnectorSpec({ + context: createContext(), + id: '.alienvault-otx', + configurationUtilities, + }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + + test('throws when user is not authorised to get actions', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized to get actions')); + + await expect( + getConnectorSpec({ + context: createContext(), + id: '.alienvault-otx', + configurationUtilities, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to get actions]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + }); + + describe('auditLogger', () => { + test('logs audit event when not authorised to get a connector type spec', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + getConnectorSpec({ + context: createContext(), + id: '.alienvault-otx', + configurationUtilities, + }) + ).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'failure', + }), + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + + it('returns serialized spec when the connector type exists', async () => { + const result = await getConnectorSpec({ + context: createContext(), + id: '.alienvault-otx', + configurationUtilities, + }); + expect(result).toHaveProperty('metadata'); + expect(result.metadata).toHaveProperty('id', '.alienvault-otx'); + expect(result.metadata).toHaveProperty('displayName'); + expect(result.metadata).toHaveProperty('supportedFeatureIds'); + expect(result).toHaveProperty('schema'); + }); + + it('rejects with 404 when the connector type has no spec', async () => { + await expect( + getConnectorSpec({ + context: createContext(), + id: '__no_such_spec_connector__', + configurationUtilities, + }) + ).rejects.toMatchObject({ output: { statusCode: 404 } }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts new file mode 100644 index 0000000000000..2213dd91e13d2 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/get_connector_spec.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { connectorsSpecs, serializeConnectorSpec } from '@kbn/connector-specs'; +import { ConnectorAuditAction, connectorAuditEvent } from '../../../../lib/audit_events'; +import type { GetConnectorSpecParams } from './types'; + +const specsByIdMap = new Map( + Object.values(connectorsSpecs).map((spec) => [spec.metadata.id, spec]) +); + +export async function getConnectorSpec({ + context, + id, + configurationUtilities, +}: GetConnectorSpecParams) { + try { + await context.authorization.ensureAuthorized({ operation: 'get' }); + } catch (error) { + context.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + error, + }) + ); + throw error; + } + + const spec = specsByIdMap.get(id); + + if (!spec) { + throw Boom.notFound(`Spec for connector type "${id}" not found.`); + } + + try { + const webhookSettings = configurationUtilities.getWebhookSettings(); + const isPfxEnabled = webhookSettings.ssl.pfx.enabled; + const isEarsEnabled = configurationUtilities.isEarsEnabled(); + const serialized = serializeConnectorSpec(spec, { isPfxEnabled, isEarsEnabled }); + return { + metadata: serialized.metadata, + schema: serialized.schema, + }; + } catch (error) { + throw new Error( + `Failed to serialize connector spec: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/index.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/index.ts new file mode 100644 index 0000000000000..c26ec6a91bf01 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { getConnectorSpec } from './get_connector_spec'; +export type { GetConnectorSpecParams } from './types'; diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/types/index.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/types/index.ts new file mode 100644 index 0000000000000..f5c4980af097c --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/types/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 type { GetConnectorSpecParams } from './params'; diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/types/params.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/types/params.ts new file mode 100644 index 0000000000000..541ccedad3ada --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/methods/get_connector_spec/types/params.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionsClientContext } from '../../../../../actions_client'; +import type { ActionsConfigurationUtilities } from '../../../../../actions_config'; + +export interface GetConnectorSpecParams { + context: ActionsClientContext; + id: string; + configurationUtilities: ActionsConfigurationUtilities; +} diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/schemas/connector_type_schema.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/schemas/connector_type_schema.ts index 8828ed4c3f8d3..48ac0d93b45f4 100644 --- a/x-pack/platform/plugins/shared/actions/server/application/connector/schemas/connector_type_schema.ts +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/schemas/connector_type_schema.ts @@ -27,4 +27,6 @@ export const connectorTypeSchema = schema.object({ isDeprecated: schema.boolean({ defaultValue: false }), subFeature: schema.maybe(schema.oneOf([schema.literal('endpointSecurity')])), allowMultipleSystemActions: schema.maybe(schema.boolean()), + description: schema.maybe(schema.string()), + isExperimental: schema.maybe(schema.boolean()), }); diff --git a/x-pack/platform/plugins/shared/actions/server/application/connector/types/connector_type.ts b/x-pack/platform/plugins/shared/actions/server/application/connector/types/connector_type.ts index 0d14804b5d59b..7b445ecd4732a 100644 --- a/x-pack/platform/plugins/shared/actions/server/application/connector/types/connector_type.ts +++ b/x-pack/platform/plugins/shared/actions/server/application/connector/types/connector_type.ts @@ -23,4 +23,6 @@ export interface ConnectorType { subFeature?: ConnectorTypeSchemaType['subFeature']; isDeprecated: ConnectorTypeSchemaType['isDeprecated']; allowMultipleSystemActions?: ConnectorTypeSchemaType['allowMultipleSystemActions']; + description?: ConnectorTypeSchemaType['description']; + isExperimental?: ConnectorTypeSchemaType['isExperimental']; } diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts index 954996989b4ab..19c9ce9f20fbb 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts @@ -31,9 +31,9 @@ import { import { getFips } from 'crypto'; import { getAxiosInstanceWithAuth } from '../lib/get_axios_instance'; import { AuthTypeRegistry, registerAuthTypes } from '../auth_types'; -import type { NormalizedAuthType } from '@kbn/connector-specs'; import { PFX } from '@kbn/connector-specs/src/auth_types/pfx'; import { CRT } from '@kbn/connector-specs/src/auth_types/crt'; +import type { NormalizedAuthType } from '@kbn/connector-specs'; const logger = loggingSystemMock.create().get() as jest.Mocked; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.test.ts index f15fa7c64530e..597aab0471881 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.test.ts @@ -64,6 +64,36 @@ describe('createConnectorTypeFromSpec', () => { expect(connectorType.executor).toBeDefined(); expect(connectorType.validate.params).toBeDefined(); expect(connectorType.source).toBe(ACTION_TYPE_SOURCES.spec); + expect(connectorType.isExperimental).toBeUndefined(); + }); + + it('sets isExperimental from metadata.isTechnicalPreview', () => { + const spec = createMockSpec({ + metadata: { + id: 'preview-connector', + displayName: 'Preview', + description: 'd', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + isTechnicalPreview: true, + }, + }); + const connectorType = createConnectorTypeFromSpec(spec, mockActionsPlugin); + expect(connectorType.isExperimental).toBe(true); + }); + + it('sets description from metadata.description', () => { + const spec = createMockSpec({ + metadata: { + id: 'connector-with-description', + displayName: 'Connector with description', + description: 'Connector description', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + }, + }); + const connectorType = createConnectorTypeFromSpec(spec, mockActionsPlugin); + expect(connectorType.description).toBe('Connector description'); }); it('creates connector type with executor and params for workflows connectors with multiple feature IDs', () => { diff --git a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.ts b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.ts index 727c00a663b5f..8306a33a4d902 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/create_connector_from_spec.ts @@ -27,17 +27,16 @@ export const createConnectorTypeFromSpec = ( ): ActionType => { const configUtils = actions.getActionsConfigurationUtilities(); - const shouldGenerateExecutor = Boolean(spec.actions); - const shouldGenerateParams = Boolean(spec.actions); + const hasActions = Boolean(spec.actions); - const executor = shouldGenerateExecutor + const executor = hasActions ? generateExecutorFunction({ actions: spec.actions, getAxiosInstanceWithAuth: actions.getAxiosInstanceWithAuth, }) : undefined; - const paramsValidator = shouldGenerateParams ? generateParamsSchema(spec.actions) : undefined; + const paramsValidator = hasActions ? generateParamsSchema(spec.actions) : undefined; return { id: spec.metadata.id, @@ -52,5 +51,7 @@ export const createConnectorTypeFromSpec = ( ...(executor ? { executor } : {}), globalAuthHeaders: spec.auth?.headers, source: ACTION_TYPE_SOURCES.spec, + description: spec.metadata.description, + isExperimental: spec.metadata.isTechnicalPreview, }; }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts index 729e9b05d5bb2..752a86735f23c 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_secrets_schema.ts @@ -6,8 +6,7 @@ */ import type { ConnectorSpec } from '@kbn/connector-specs'; - -import { generateSecretsSchemaFromSpec, getSchemaForAuthType } from '@kbn/connector-specs/src/lib'; +import { generateSecretsSchemaFromSpec, getSchemaForAuthType } from '@kbn/connector-specs'; import type { ActionTypeSecrets, ValidatorType, ValidatorServices } from '../../types'; import type { ActionsConfigurationUtilities } from '../../actions_config'; import { getAllowedHostsKeysFromShape, validateAllowedHostsKeys } from './allowed_hosts_validation'; diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/get_spec.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/get_spec.test.ts new file mode 100644 index 0000000000000..8e74edf4db3a8 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/get_spec.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../lib/license_state.mock'; +import { mockHandlerArguments } from '../../_mock_handler_arguments'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; +import { getConnectorSpecRoute } from './get_spec'; +import type { ActionsConfigurationUtilities } from '../../../actions_config'; +import { DEFAULT_ACTION_ROUTE_SECURITY } from '../../constants'; +import { actionsClientMock } from '../../../mocks'; + +jest.mock('../../verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + (verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler); +}); + +const createActionsConfigUtilsMock = ( + overrides: Partial<{ + pfxEnabled: boolean; + earsEnabled: boolean; + }> = {} +): ActionsConfigurationUtilities => + ({ + getWebhookSettings: jest.fn(() => ({ + ssl: { pfx: { enabled: overrides.pfxEnabled ?? true } }, + })), + isEarsEnabled: jest.fn(() => overrides.earsEnabled ?? false), + } as unknown as ActionsConfigurationUtilities); + +describe('getConnectorSpecRoute', () => { + it('registers the route with correct path', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + expect(router.get).toHaveBeenCalledTimes(1); + const [config] = router.get.mock.calls[0]; + expect(config.path).toBe('/internal/actions/connector_types/{id}/spec'); + }); + + it('registers the route with access internal', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [config] = router.get.mock.calls[0]; + expect(config.options?.access).toBe('internal'); + }); + + it('registers the route with default actions route security', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [config] = router.get.mock.calls[0]; + expect(config.security).toEqual(DEFAULT_ACTION_ROUTE_SECURITY); + }); + + it('returns 200 with body from actionsClient.getConnectorSpec', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsConfigUtils = createActionsConfigUtilsMock(); + const actionsClient = actionsClientMock.create(); + const clientResult = { + metadata: { + id: 'test-connector', + displayName: 'Test Connector', + description: 'A test connector', + minimumLicense: 'basic' as const, + supportedFeatureIds: ['alerting'] as const, + isTechnicalPreview: true, + }, + schema: { + type: 'object', + properties: { + config: { type: 'object', properties: {} }, + secrets: { type: 'object', properties: {} }, + }, + }, + }; + const responseBody = { + metadata: { + id: 'test-connector', + display_name: 'Test Connector', + description: 'A test connector', + minimum_license: 'basic', + supported_feature_ids: ['alerting'], + is_technical_preview: true, + }, + schema: clientResult.schema, + }; + actionsClient.getConnectorSpec.mockResolvedValue(clientResult as never); + + getConnectorSpecRoute(router, licenseState, actionsConfigUtils); + + const [, handler] = router.get.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { params: { id: 'test-connector' } }, + ['ok', 'notFound'] + ); + + const result = await handler(context, req, res); + + expect(result).toEqual({ body: responseBody }); + expect(res.ok).toHaveBeenCalled(); + expect(actionsClient.getConnectorSpec).toHaveBeenCalledWith({ + id: 'test-connector', + configurationUtilities: actionsConfigUtils, + }); + }); + + it('passes configuration utilities from route registration to getConnectorSpec', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsConfigUtils = createActionsConfigUtilsMock({ + pfxEnabled: false, + earsEnabled: true, + }); + const actionsClient = actionsClientMock.create(); + actionsClient.getConnectorSpec.mockResolvedValue({ + metadata: { + id: 'test-connector', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + }, + schema: {}, + } as never); + + getConnectorSpecRoute(router, licenseState, actionsConfigUtils); + + const [, handler] = router.get.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { params: { id: 'test-connector' } }, + ['ok', 'notFound'] + ); + + await handler(context, req, res); + + expect(actionsClient.getConnectorSpec).toHaveBeenCalledWith({ + id: 'test-connector', + configurationUtilities: actionsConfigUtils, + }); + }); + + it('propagates Boom notFound from getConnectorSpec (wrapped by handleLegacyErrors in production)', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.getConnectorSpec.mockRejectedValue( + Boom.notFound('Spec for connector type "unknown-connector" not found.') + ); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [, handler] = router.get.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { params: { id: 'unknown-connector' } }, + ['ok', 'customError'] + ); + + await expect(handler(context, req, res)).rejects.toMatchObject({ + output: { + statusCode: 404, + payload: expect.objectContaining({ + message: 'Spec for connector type "unknown-connector" not found.', + }), + }, + }); + }); + + it('ensures the license allows getting connector spec', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.getConnectorSpec.mockResolvedValue({ + metadata: { + id: 'test-connector', + displayName: 'Test', + description: 'Test', + minimumLicense: 'basic', + supportedFeatureIds: ['alerting'], + }, + schema: {}, + } as never); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [, handler] = router.get.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { params: { id: 'test-connector' } }, + ['ok'] + ); + + await handler(context, req, res); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); + + it('ensures the license check prevents getting connector spec', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => { + throw new Error('License check failed'); + }); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [, handler] = router.get.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments({}, { params: { id: 'test-connector' } }, [ + 'ok', + ]); + + await expect(handler(context, req, res)).rejects.toThrow('License check failed'); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); + + it('propagates serialization failure from getConnectorSpec', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.getConnectorSpec.mockRejectedValue( + new Error('Failed to serialize connector spec: Serialization error') + ); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [, handler] = router.get.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { params: { id: 'test-connector' } }, + ['ok'] + ); + + await expect(handler(context, req, res)).rejects.toThrow( + 'Failed to serialize connector spec: Serialization error' + ); + }); + + it('validates request params schema', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [config] = router.get.mock.calls[0]; + + expect(config.validate).toBeDefined(); + const validateConfig = config.validate as { request?: { params?: unknown } }; + expect(validateConfig.request?.params).toBeDefined(); + }); + + it('has proper response schema validation', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getConnectorSpecRoute(router, licenseState, createActionsConfigUtilsMock()); + + const [config] = router.get.mock.calls[0]; + + const validateConfig = config.validate as { response?: Record }; + expect(validateConfig.response?.[200]).toBeDefined(); + expect(validateConfig.response?.[404]).toBeDefined(); + expect(validateConfig.response?.[500]).toBeDefined(); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/get_spec.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/get_spec.ts new file mode 100644 index 0000000000000..58798e2b9cdfd --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/get_spec.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IRouter } from '@kbn/core/server'; +import { + type GetConnectorSpecParamsV1, + getConnectorSpecParamsSchemaV1, +} from '../../../../common/routes/connector/apis/get_spec'; +import { + getConnectorSpecResponseBodySchemaV1, + type GetConnectorSpecResponseV1, +} from '../../../../common/routes/connector/response'; +import type { ActionsRequestHandlerContext } from '../../../types'; +import { INTERNAL_BASE_ACTION_API_PATH } from '../../../../common'; +import type { ILicenseState } from '../../../lib'; +import type { ActionsConfigurationUtilities } from '../../../actions_config'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; +import { DEFAULT_ACTION_ROUTE_SECURITY } from '../../constants'; +import { transformGetConnectorSpecResponseV1 } from './transforms'; + +/** + * GET /internal/actions/connector_types/{id}/spec + * + * Returns the serialized connector spec as JSON Schema for client-side + * form generation and validation. + * + * Only available for connector types with source === 'spec'. + */ +export const getConnectorSpecRoute = ( + router: IRouter, + licenseState: ILicenseState, + configurationUtilities: ActionsConfigurationUtilities +) => { + router.get( + { + path: `${INTERNAL_BASE_ACTION_API_PATH}/connector_types/{id}/spec`, + security: DEFAULT_ACTION_ROUTE_SECURITY, + options: { + access: 'internal', + summary: 'Get connector type specification', + description: + 'Returns metadata and JSON Schema for a connector type form (config + secrets). Only available for spec-based connectors.', + tags: ['oas-tag:connectors'], + }, + validate: { + request: { + params: getConnectorSpecParamsSchemaV1, + }, + response: { + 200: { + description: 'Connector specification returned successfully.', + body: () => getConnectorSpecResponseBodySchemaV1, + }, + 404: { + description: 'Connector type not found or not spec-based.', + }, + 500: { + description: 'Internal server error.', + }, + }, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const actionsClient = (await context.actions).getActionsClient(); + const { id }: GetConnectorSpecParamsV1 = req.params; + const specResult = await actionsClient.getConnectorSpec({ + id, + configurationUtilities, + }); + const responseBody: GetConnectorSpecResponseV1 = + transformGetConnectorSpecResponseV1(specResult); + return res.ok({ body: responseBody }); + }) + ) + ); +}; diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/index.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/index.ts new file mode 100644 index 0000000000000..0e62ebed5c1cc --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/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 { getConnectorSpecRoute } from './get_spec'; diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/index.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/index.ts new file mode 100644 index 0000000000000..8a80737c89244 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { transformGetConnectorSpecResponse } from './transform_get_connector_spec_response/latest'; + +export { transformGetConnectorSpecResponse as transformGetConnectorSpecResponseV1 } from './transform_get_connector_spec_response/v1'; diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/transform_get_connector_spec_response/latest.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/transform_get_connector_spec_response/latest.ts new file mode 100644 index 0000000000000..40947fa0213f1 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/transform_get_connector_spec_response/latest.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 { transformGetConnectorSpecResponse } from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/transform_get_connector_spec_response/v1.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/transform_get_connector_spec_response/v1.ts new file mode 100644 index 0000000000000..faf4d74d081b5 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/get_spec/transforms/transform_get_connector_spec_response/v1.ts @@ -0,0 +1,32 @@ +/* + * 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 { ConnectorMetadata } from '@kbn/connector-specs'; +import type { GetConnectorSpecResponseV1 } from '../../../../../../common/routes/connector/response'; + +export interface GetConnectorSpecServiceResult { + metadata: ConnectorMetadata; + schema: Record; +} + +export const transformGetConnectorSpecResponse = ( + spec: GetConnectorSpecServiceResult +): GetConnectorSpecResponseV1 => ({ + metadata: { + id: spec.metadata.id, + display_name: spec.metadata.displayName, + description: spec.metadata.description, + minimum_license: spec.metadata.minimumLicense as string, + supported_feature_ids: [...(spec.metadata.supportedFeatureIds ?? [])], + ...(spec.metadata.icon !== undefined ? { icon: spec.metadata.icon } : {}), + ...(spec.metadata.docsUrl !== undefined ? { docs_url: spec.metadata.docsUrl } : {}), + ...(spec.metadata.isTechnicalPreview !== undefined + ? { is_technical_preview: spec.metadata.isTechnicalPreview } + : {}), + }, + schema: spec.schema, +}); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/list_types.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/list_types.test.ts index 1779ca86464b2..2c6708ded9a56 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/list_types.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/list_types.test.ts @@ -52,11 +52,13 @@ describe('listTypesRoute', () => { "body": Array [ Object { "allow_multiple_system_actions": undefined, + "description": undefined, "enabled": true, "enabled_in_config": true, "enabled_in_license": true, "id": "1", "is_deprecated": false, + "is_experimental": undefined, "is_system_action_type": false, "minimum_license_required": "gold", "name": "name", @@ -125,11 +127,13 @@ describe('listTypesRoute', () => { "body": Array [ Object { "allow_multiple_system_actions": undefined, + "description": undefined, "enabled": true, "enabled_in_config": true, "enabled_in_license": true, "id": "1", "is_deprecated": false, + "is_experimental": undefined, "is_system_action_type": false, "minimum_license_required": "gold", "name": "name", diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/transforms/transform_list_types_response/v1.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/transforms/transform_list_types_response/v1.ts index eb4fbf3181d3d..f891853ae586b 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/transforms/transform_list_types_response/v1.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types/transforms/transform_list_types_response/v1.ts @@ -25,6 +25,8 @@ export const transformListTypesResponse = ( subFeature, isDeprecated, allowMultipleSystemActions, + description, + isExperimental, }) => ({ id, name, @@ -38,6 +40,8 @@ export const transformListTypesResponse = ( sub_feature: subFeature, is_deprecated: isDeprecated, allow_multiple_system_actions: allowMultipleSystemActions, + description, + is_experimental: isExperimental, }) ); }; diff --git a/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types_system/list_types_system.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types_system/list_types_system.test.ts index cfa8ddffac43f..7a1be4f7144da 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types_system/list_types_system.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/connector/list_types_system/list_types_system.test.ts @@ -56,11 +56,13 @@ describe('listTypesWithSystemRoute', () => { "body": Array [ Object { "allow_multiple_system_actions": undefined, + "description": undefined, "enabled": true, "enabled_in_config": true, "enabled_in_license": true, "id": "1", "is_deprecated": false, + "is_experimental": undefined, "is_system_action_type": true, "minimum_license_required": "gold", "name": "name", @@ -129,11 +131,13 @@ describe('listTypesWithSystemRoute', () => { "body": Array [ Object { "allow_multiple_system_actions": undefined, + "description": undefined, "enabled": true, "enabled_in_config": true, "enabled_in_license": true, "id": "1", "is_deprecated": false, + "is_experimental": undefined, "is_system_action_type": false, "minimum_license_required": "gold", "name": "name", diff --git a/x-pack/platform/plugins/shared/actions/server/routes/index.ts b/x-pack/platform/plugins/shared/actions/server/routes/index.ts index 8ea68f43a49a0..233dec579d8de 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/index.ts @@ -13,6 +13,7 @@ import { getAllConnectorsIncludingSystemRoute } from './connector/get_all_system import { connectorAuthStatusRoute } from './connector/auth_status'; import { listTypesRoute } from './connector/list_types'; import { listTypesWithSystemRoute } from './connector/list_types_system'; +import { getConnectorSpecRoute } from './connector/get_spec'; import type { ILicenseState } from '../lib'; import type { ActionsRequestHandlerContext } from '../types'; import { createConnectorRoute } from './connector/create'; @@ -61,4 +62,6 @@ export function defineRoutes(opts: RouteOptions) { getAllConnectorsIncludingSystemRoute(router, licenseState); connectorAuthStatusRoute(router, licenseState); listTypesWithSystemRoute(router, licenseState); + + getConnectorSpecRoute(router, licenseState, actionsConfigUtils); } diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index 405dfbbad4a20..b4d5e6c66f622 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -242,6 +242,14 @@ export interface ActionTypeCoreFields< * Only applies to system actions (isSystemActionType: true). */ allowMultipleSystemActions?: boolean; + /** + * Description of this connector type. + */ + description?: string; + /** + * When true, the connector type is shown as technical preview in the UI. + */ + isExperimental?: boolean; /** * Additional Kibana privileges to be checked by the actions framework. * Use it if you want to perform extra authorization checks based on a Kibana feature. diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml b/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml index 5c1e021c5858d..cf4d59b74937f 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/moon.yml @@ -105,6 +105,7 @@ dependsOn: - '@kbn/security-plugin' - '@kbn/std' - '@kbn/lens-common' + - '@kbn/connector-specs' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/index.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/index.ts index 3595a80c0779b..949e5a7264adc 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/index.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/index.ts @@ -6,3 +6,4 @@ */ export { useSubAction } from './use_sub_action'; +export { useActionTypeModel, type UseActionTypeModelResult } from '@kbn/alerts-ui-shared'; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/use_action_type_model.test.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/use_action_type_model.test.tsx new file mode 100644 index 0000000000000..6b639a2931b43 --- /dev/null +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/use_action_type_model.test.tsx @@ -0,0 +1,591 @@ +/* + * 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 type { FC, PropsWithChildren } from 'react'; +import { waitFor, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { ACTION_TYPE_SOURCES } from '@kbn/actions-types'; +import { connectorsSpecs, serializeConnectorSpec } from '@kbn/connector-specs'; +import { useActionTypeModel } from '@kbn/alerts-ui-shared'; +import { actionTypeRegistryMock } from '../action_type_registry.mock'; +import type { ActionType, ActionTypeModel } from '../../types'; + +const WORKFLOWS_CONNECTOR_FEATURE_ID = 'workflows'; + +describe('useActionTypeModel', () => { + let actionTypeRegistry: ReturnType; + let queryClient: QueryClient; + let mockHttp: { get: jest.Mock }; + let mockUiSettings: { get: jest.Mock }; + + const mockActionTypeModel: ActionTypeModel = actionTypeRegistryMock.createMockActionTypeModel({ + id: 'test-connector', + }); + + const mockSpecResponse = { + metadata: { + id: 'spec-connector', + display_name: 'Spec Connector', + description: 'A spec-based connector', + minimum_license: 'basic', + supported_feature_ids: ['alerting'], + }, + schema: serializeConnectorSpec(connectorsSpecs.AlienVaultOTXConnector).schema as Record< + string, + unknown + >, + }; + + const createWrapper = (): FC> => { + return ({ children }) => ( + {children} + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockHttp = { get: jest.fn() }; + mockUiSettings = { get: jest.fn().mockReturnValue(true) }; + actionTypeRegistry = actionTypeRegistryMock.create(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + logger: { + log: () => {}, + warn: () => {}, + error: () => {}, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('returns null when actionType is null', async () => { + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: null, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + expect(result.current).toEqual({ + actionTypeModel: null, + isLoading: false, + error: null, + isFromSpec: false, + refetch: expect.any(Function), + }); + }); + + it('returns registered model synchronously for stack connectors', async () => { + const stackActionType: ActionType = { + id: 'test-connector', + name: 'Test Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + }; + + actionTypeRegistry.has.mockReturnValue(true); + actionTypeRegistry.get.mockReturnValue(mockActionTypeModel); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: stackActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + // Should return immediately without loading + expect(result.current.actionTypeModel).toBe(mockActionTypeModel); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.isFromSpec).toBe(false); + + // Should not call HTTP get for registered connectors + expect(mockHttp.get).not.toHaveBeenCalled(); + }); + + it('fetches spec from API for spec-based connectors not in registry', async () => { + const specActionType: ActionType = { + id: 'spec-connector', + name: 'Spec Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + actionTypeRegistry.has.mockReturnValue(false); + mockHttp.get.mockResolvedValue(mockSpecResponse); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + // Initially loading + expect(result.current.isLoading).toBe(true); + expect(result.current.actionTypeModel).toBeNull(); + + // Wait for fetch to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Verify API was called + expect(mockHttp.get).toHaveBeenCalledWith( + '/internal/actions/connector_types/spec-connector/spec', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + + // Verify model is returned + expect(result.current.actionTypeModel).not.toBeNull(); + expect(result.current.actionTypeModel?.id).toBe('spec-connector'); + expect(result.current.isFromSpec).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('returns loading state while fetching spec', async () => { + const specActionType: ActionType = { + id: 'spec-connector', + name: 'Spec Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + actionTypeRegistry.has.mockReturnValue(false); + + // Create a promise that won't resolve immediately + let resolvePromise: (value: typeof mockSpecResponse) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockHttp.get.mockReturnValue(promise); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + // Should be loading + expect(result.current.isLoading).toBe(true); + expect(result.current.actionTypeModel).toBeNull(); + expect(result.current.isFromSpec).toBe(false); + + // Resolve the promise + resolvePromise!(mockSpecResponse); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.actionTypeModel).not.toBeNull(); + expect(result.current.isFromSpec).toBe(true); + }); + }); + + it('surfaces error when connector spec schema cannot be parsed', async () => { + const specActionType: ActionType = { + id: 'spec-connector', + name: 'Spec Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + actionTypeRegistry.has.mockReturnValue(false); + mockHttp.get.mockResolvedValue({ + ...mockSpecResponse, + schema: { + type: 'object', + properties: { + config: { type: 'object', properties: {} }, + secrets: { type: 'string' }, + }, + }, + }); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe( + 'Failed to parse connector spec schema for "spec-connector"' + ); + expect(result.current.actionTypeModel).toBeNull(); + expect(result.current.isFromSpec).toBe(false); + }); + + it('handles fetch errors correctly', async () => { + const specActionType: ActionType = { + id: 'spec-connector', + name: 'Spec Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + actionTypeRegistry.has.mockReturnValue(false); + const fetchError = new Error('Failed to fetch spec'); + mockHttp.get.mockRejectedValue(fetchError); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toEqual(fetchError); + expect(result.current.actionTypeModel).toBeNull(); + expect(result.current.isFromSpec).toBe(false); + }); + + it('caches spec data and does not refetch on subsequent calls', async () => { + const specActionType: ActionType = { + id: 'spec-connector', + name: 'Spec Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + actionTypeRegistry.has.mockReturnValue(false); + mockHttp.get.mockResolvedValue(mockSpecResponse); + + // First render + const { result, rerender } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + // Rerender (simulate component re-render) + rerender(); + + // Should still have the cached data, no new fetch + expect(result.current.actionTypeModel).not.toBeNull(); + expect(mockHttp.get).toHaveBeenCalledTimes(1); + }); + + it('does not fetch for non-spec connectors not in registry', async () => { + const unknownActionType: ActionType = { + id: 'unknown-connector', + name: 'Unknown Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + // No source field - not a spec connector + }; + + actionTypeRegistry.has.mockReturnValue(false); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: unknownActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + // Should not be loading (no fetch triggered) + expect(result.current.isLoading).toBe(false); + expect(result.current.actionTypeModel).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.isFromSpec).toBe(false); + + // Should not call HTTP get for non-spec connectors + expect(mockHttp.get).not.toHaveBeenCalled(); + }); + + it('transforms spec response into ActionTypeModel with correct properties', async () => { + const specActionType: ActionType = { + id: 'spec-connector', + name: 'Spec Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + actionTypeRegistry.has.mockReturnValue(false); + mockHttp.get.mockResolvedValue(mockSpecResponse); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const model = result.current.actionTypeModel; + expect(model).not.toBeNull(); + expect(model?.id).toBe('spec-connector'); + expect(model?.actionTypeTitle).toBe('Spec Connector'); + expect(model?.selectMessage).toBe('A spec-based connector'); + expect(model?.source).toBe(ACTION_TYPE_SOURCES.spec); + expect(model?.isExperimental).toBe(false); + expect(model?.actionConnectorFields).toBeDefined(); + expect(model?.actionParamsFields).toBeDefined(); + expect(model?.validateParams).toBeDefined(); + }); + + it('exposes getHideInUi on fetched spec model using uiSettings for workflows-only connectors', async () => { + const workflowsSpecResponse = { + ...mockSpecResponse, + metadata: { + ...mockSpecResponse.metadata, + id: 'workflows-spec-connector', + supported_feature_ids: [WORKFLOWS_CONNECTOR_FEATURE_ID], + }, + }; + + const uiSettingsGet = jest.fn().mockReturnValue(false); + mockHttp.get.mockResolvedValue(workflowsSpecResponse); + + const specActionType: ActionType = { + id: 'workflows-spec-connector', + name: 'Workflows Spec', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: [WORKFLOWS_CONNECTOR_FEATURE_ID], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + actionTypeRegistry.has.mockReturnValue(false); + + const { result } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: { get: uiSettingsGet } as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.actionTypeModel?.getHideInUi?.([])).toBe(true); + expect(uiSettingsGet).toHaveBeenCalledWith('workflows:ui:enabled', true); + }); + + describe('stale time window', () => { + const specActionType: ActionType = { + id: 'spec-connector', + name: 'Spec Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: false, + isDeprecated: false, + source: ACTION_TYPE_SOURCES.spec, + }; + + const mockNow = { value: 1_000_000 }; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => mockNow.value); + actionTypeRegistry.has.mockReturnValue(false); + mockHttp.get.mockResolvedValue(mockSpecResponse); + mockNow.value = 1_000_000; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('re-fetches spec after stale time window expires', async () => { + const { result, unmount } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + unmount(); + mockNow.value += 5 * 60 * 1000 + 1; + + renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(mockHttp.get).toHaveBeenCalledTimes(2); + }); + }); + + it('does not re-fetch spec within stale time window', async () => { + const { result, unmount } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + unmount(); + mockNow.value += 5 * 60 * 1000 - 1; + + const { result: resultAfterRemount } = renderHook( + () => + useActionTypeModel({ + actionTypeRegistry, + actionType: specActionType, + http: mockHttp as any, + uiSettings: mockUiSettings as any, + }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(resultAfterRemount.current.actionTypeModel).not.toBeNull(); + }); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 3f1d02a9ab2be..f6f173a14deb4 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -123,7 +123,10 @@ export const ActionTypeMenu = ({ }, []); const registeredActionTypes = Object.entries(actionTypesIndex ?? []) .filter(([id, details]) => { - const actionTypeModel = actionTypeRegistry.has(id) ? actionTypeRegistry.get(id) : undefined; + if (!actionTypeRegistry.has(id)) { + return false; + } + const actionTypeModel = actionTypeRegistry.get(id); const shouldHideInUi = actionTypeModel?.getHideInUi?.( actionTypesIndex ? Object.values(actionTypesIndex) : [] ); diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx index 90ce9001ee73f..740cb50c154b2 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.test.tsx @@ -880,4 +880,204 @@ describe('CreateConnectorFlyout', () => { expect(screen.queryByTestId('create-connector-flyout-save-test-btn')).not.toBeInTheDocument(); }); }); + + describe('spec connector with API fetch', () => { + // Use 'workflows' feature ID since spec connectors are only shown when workflows UI is enabled + const specConnectorType = { + id: 'spec-connector-test', + name: 'Spec Connector Test', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic' as const, + supportedFeatureIds: ['workflows'], // Workflows feature to enable display + source: 'spec', + description: 'Test spec connector description', + }; + + const mockSpecResponse = { + metadata: { + id: 'spec-connector-test', + display_name: 'Spec Connector Test', + description: 'Connect to Test API', + minimum_license: 'basic', + supported_feature_ids: ['workflows'], + }, + schema: { + type: 'object', + properties: { + config: { + type: 'object', + properties: {}, + }, + secrets: { + anyOf: [ + { + type: 'object', + properties: { + authType: { const: 'api_key_header', type: 'string' }, + apiKey: { + type: 'string', + minLength: 1, + label: 'API key', + sensitive: true, + }, + }, + required: ['authType', 'apiKey'], + label: 'API key header authentication', + }, + ], + label: 'Authentication', + }, + }, + required: ['config', 'secrets'], + }, + }; + + const specActionTypeModel = actionTypeRegistryMock.createMockActionTypeModel({ + id: 'spec-connector-test', + source: 'spec', + actionConnectorFields: lazy(() => import('../connector_mock')), + }); + + beforeEach(() => { + loadActionTypes.mockResolvedValue([specConnectorType]); + // Registered client-side (via registerConnectorTypesFromSpecs); API fetch is triggered by source==='spec' + actionTypeRegistry.has.mockReturnValue(true); + actionTypeRegistry.get.mockReturnValue(specActionTypeModel); + appMockRenderer.coreStart.http.get = jest.fn().mockResolvedValue(mockSpecResponse); + // Enable workflows UI setting so spec connectors are displayed + appMockRenderer.coreStart.uiSettings.get = jest.fn().mockImplementation((key: string) => { + if (key === 'workflows:ui:enabled') { + return true; + } + return undefined; + }); + }); + + it('fetches spec from API when selecting a spec-based connector', async () => { + appMockRenderer.render( + + ); + + // Wait for the connector card to appear and click it + await userEvent.click(await screen.findByTestId('spec-connector-test-card')); + + // Verify API was called with correct endpoint + await waitFor(() => { + expect(appMockRenderer.coreStart.http.get).toHaveBeenCalledWith( + '/internal/actions/connector_types/spec-connector-test/spec', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + }); + + it('shows loading state while fetching spec', async () => { + // Create a deferred promise to control when the API response resolves + let resolveSpec: (value: typeof mockSpecResponse) => void; + const specPromise = new Promise((resolve) => { + resolveSpec = resolve; + }); + appMockRenderer.coreStart.http.get = jest.fn().mockReturnValue(specPromise); + + appMockRenderer.render( + + ); + + await userEvent.click(await screen.findByTestId('spec-connector-test-card')); + + // While loading, form fields should not yet be visible (or there should be a loading indicator) + // The exact behavior depends on the implementation + await waitFor(() => { + expect(appMockRenderer.coreStart.http.get).toHaveBeenCalled(); + }); + + // Now resolve the spec + resolveSpec!(mockSpecResponse); + + // After spec loads, the form should render + await waitFor(() => { + expect(screen.getByTestId('nameInput')).toBeInTheDocument(); + }); + }); + + it('shows error state when spec fetch fails', async () => { + const errorMessage = 'Failed to fetch spec'; + appMockRenderer.coreStart.http.get = jest.fn().mockRejectedValue(new Error(errorMessage)); + + appMockRenderer.render( + + ); + + await userEvent.click(await screen.findByTestId('spec-connector-test-card')); + + // Verify the API was called + await waitFor(() => { + expect(appMockRenderer.coreStart.http.get).toHaveBeenCalledWith( + '/internal/actions/connector_types/spec-connector-test/spec', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + // Verify error message is displayed + await waitFor(() => { + expect(screen.getByTestId('connector-spec-load-error')).toBeInTheDocument(); + }); + }); + + it('does not show save and test button for spec connectors', async () => { + appMockRenderer.render( + + ); + + await userEvent.click(await screen.findByTestId('spec-connector-test-card')); + + await waitFor(() => { + expect(screen.getByTestId('nameInput')).toBeInTheDocument(); + }); + + // Spec connectors should not show save and test button + expect(screen.queryByTestId('create-connector-flyout-save-test-btn')).not.toBeInTheDocument(); + // But should show the save button + expect(screen.getByTestId('create-connector-flyout-save-btn')).toBeInTheDocument(); + }); + + it('navigates back to connector list when pressing back button', async () => { + appMockRenderer.render( + + ); + + await userEvent.click(await screen.findByTestId('spec-connector-test-card')); + + await waitFor(() => { + expect(screen.getByTestId('create-connector-flyout-back-btn')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByTestId('create-connector-flyout-back-btn')); + + // Should show connector selection again + expect(await screen.findByTestId('spec-connector-test-card')).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx index 825afc46b69d3..80f34f1ac2653 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx @@ -7,13 +7,25 @@ import type { ReactNode } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { + EuiButton, + EuiButtonGroup, + EuiCallOut, + EuiFlyout, + EuiFlyoutBody, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; import type { IconType } from '@elastic/eui'; -import { EuiButtonGroup, EuiCallOut, EuiFlyout, EuiFlyoutBody, EuiSpacer } from '@elastic/eui'; import { ACTION_TYPE_SOURCES } from '@kbn/actions-types'; import { i18n } from '@kbn/i18n'; import { getConnectorCompatibility } from '@kbn/actions-plugin/common'; import type { ConnectorFormSchema } from '@kbn/alerts-ui-shared'; +import { useActionTypeModel } from '@kbn/alerts-ui-shared'; import { isLLMConnectorTypeId } from '@kbn/response-ops-rule-form/src/constants'; import { DEPRECATED_LLM_CONNECTOR_CALLOUT_TITLE, @@ -59,6 +71,8 @@ const CreateConnectorFlyoutComponent: React.FC = ({ }) => { const { application: { capabilities }, + http, + uiSettings, } = useKibana().services; const { isLoading: isSavingConnector, createConnector } = useCreateConnector(); @@ -104,26 +118,44 @@ const CreateConnectorFlyoutComponent: React.FC = ({ const { preSubmitValidator, submit, isValid: isFormValid, isSubmitting } = formState; + const { + actionTypeModel, + isLoading: isLoadingActionTypeModel, + error: actionTypeModelError, + refetch: refetchConnectorSpec, + } = useActionTypeModel({ actionTypeRegistry, actionType, http, uiSettings }); + + // Delay the spinner so quick spec loads don't flash a loading state. + const [showLoadingSpinner, setShowLoadingSpinner] = useState(false); + useDebounce(() => setShowLoadingSpinner(isLoadingActionTypeModel), 300, [ + isLoadingActionTypeModel, + ]); + const hasErrors = isFormValid === false; const isSaving = isSavingConnector || isSubmitting; const isUsingInitialConnector = Boolean(initialConnector); const hasConnectorTypeSelected = actionType != null; - const disabled = hasErrors || !canSave; - - const actionTypeModel: ActionTypeModel | null = - actionType != null ? actionTypeRegistry.get(actionType.id) : null; - + const disabled = + hasErrors || !canSave || isLoadingActionTypeModel || !!actionTypeModelError || !actionTypeModel; + // Only stack connectors (not spec-based) support the test tab const isTestable = !actionTypeModel?.source || actionTypeModel?.source === ACTION_TYPE_SOURCES.stack; const groupActionTypeModel: Array = actionTypeModel && actionTypeModel.subtype ? (actionTypeModel?.subtype ?? []) - .filter((item) => allActionTypes && allActionTypes[item.id].enabledInConfig) - .map((subtypeAction) => ({ - ...actionTypeRegistry.get(subtypeAction.id), - name: subtypeAction.name, - })) + .filter((item) => allActionTypes && allActionTypes[item.id]?.enabledInConfig) + .flatMap((subtypeAction) => { + if (!actionTypeRegistry.has(subtypeAction.id)) { + return []; + } + return [ + { + ...actionTypeRegistry.get(subtypeAction.id), + name: subtypeAction.name, + }, + ]; + }) : []; const groupActionButtons = @@ -287,13 +319,16 @@ const CreateConnectorFlyoutComponent: React.FC = ({ {hasConnectorTypeSelected ? ( <> - {groupActionTypeModel && ( + {groupActionButtons.length > 0 && ( <> = ({ )} + {showLoadingSpinner && ( + + + + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorAdd.loadingConnectorConfiguration', + { + defaultMessage: 'Loading connector configuration...', + } + )} + + + )} + + {actionTypeModelError && ( + <> + +

+ {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorAdd.specLoadErrorDescription', + { + defaultMessage: + 'The connector form could not be loaded. Try again, or contact your administrator if the problem persists.', + } + )} +

+ + refetchConnectorSpec()} + > + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorAdd.specLoadErrorRetry', + { defaultMessage: 'Retry' } + )} + +
+ + + )} + {isLLMConnectorTypeId(actionType.id) && ( <> = ({ )} - - {!!preSubmitValidationErrorMessage &&

{preSubmitValidationErrorMessage}

} + + {!isLoadingActionTypeModel && + !showLoadingSpinner && + !actionTypeModelError && + actionTypeModel && ( + <> + + {!!preSubmitValidationErrorMessage &&

{preSubmitValidationErrorMessage}

} + + )} ) : ( { + const actual = jest.requireActual( + '../../../lib/action_connector_api' + ); + return { + ...actual, + loadActionTypes: jest.fn((opts: Parameters[0]) => + actual.loadActionTypes(opts) + ), + }; +}); + import React, { lazy } from 'react'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; @@ -16,8 +28,30 @@ import { EditConnectorTabs } from '../../../../types'; import type { AppMockRenderer } from '../../test_utils'; import { createAppMockRenderer } from '../../test_utils'; import { TECH_PREVIEW_LABEL } from '../../translations'; +import type { ActionType } from '@kbn/actions-plugin/common'; import { createMockActionConnector } from '@kbn/alerts-ui-shared/src/common/test_utils/connector.mock'; +const { loadActionTypes } = jest.requireMock('../../../lib/action_connector_api') as { + loadActionTypes: jest.Mock; +}; +const actualActionConnectorApi = jest.requireActual< + typeof import('../../../lib/action_connector_api') +>('../../../lib/action_connector_api'); + +const defaultEditFlyoutLoadActionTypes: ActionType[] = [ + { + id: '.test', + name: 'Test', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting', 'siem'], + isSystemActionType: false, + isDeprecated: false, + }, +]; + const updateConnectorResponse = { connector_type_id: 'test', is_preconfigured: false, @@ -57,8 +91,9 @@ describe('EditConnectorFlyout', () => { const actionTypeRegistry = actionTypeRegistryMock.create(); - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); + loadActionTypes.mockResolvedValue(defaultEditFlyoutLoadActionTypes); actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(actionTypeModel); appMockRenderer = createAppMockRenderer(); @@ -68,6 +103,9 @@ describe('EditConnectorFlyout', () => { }; appMockRenderer.coreStart.http.put = jest.fn().mockResolvedValue(updateConnectorResponse); appMockRenderer.coreStart.http.post = jest.fn().mockResolvedValue(executeConnectorResponse); + await act(async () => { + await Promise.resolve(); + }); }); it('renders', async () => { @@ -94,7 +132,9 @@ describe('EditConnectorFlyout', () => { onConnectorUpdated={onConnectorUpdated} /> ); - expect(getByTestId('edit-connector-flyout-save-btn')).toBeDisabled(); + await waitFor(() => { + expect(getByTestId('edit-connector-flyout-save-btn')).toBeDisabled(); + }); await act(async () => { await userEvent.clear(getByTestId('nameInput')); @@ -115,7 +155,9 @@ describe('EditConnectorFlyout', () => { onConnectorUpdated={onConnectorUpdated} /> ); - expect(getByTestId('edit-connector-flyout-save-btn')).toBeDisabled(); + await waitFor(() => { + expect(getByTestId('edit-connector-flyout-save-btn')).toBeDisabled(); + }); await userEvent.clear(getByTestId('nameInput')); await userEvent.type(getByTestId('nameInput'), 'My new name', { @@ -189,7 +231,9 @@ describe('EditConnectorFlyout', () => { /> ); - expect(getByTestId('edit-connector-flyout-save-btn')).toBeInTheDocument(); + await waitFor(() => { + expect(getByTestId('edit-connector-flyout-save-btn')).toBeInTheDocument(); + }); expect(getByTestId('edit-connector-flyout-close-btn')).toBeInTheDocument(); }); @@ -250,6 +294,25 @@ describe('EditConnectorFlyout', () => { }); }); + it('shows an error callout and no form when loadActionTypes fails', async () => { + loadActionTypes.mockRejectedValue(new Error('network error')); + + const { getByTestId, queryByTestId } = appMockRenderer.render( + + ); + + await waitFor(() => { + expect(getByTestId('connector-action-types-load-error')).toBeInTheDocument(); + }); + + expect(queryByTestId('nameInput')).not.toBeInTheDocument(); + }); + describe('Header', () => { it('shows the icon', async () => { const { getByTestId } = appMockRenderer.render( @@ -261,7 +324,9 @@ describe('EditConnectorFlyout', () => { /> ); - expect(getByTestId('edit-connector-flyout-header-icon')).toBeInTheDocument(); + await waitFor(() => { + expect(getByTestId('edit-connector-flyout-header-icon')).toBeInTheDocument(); + }); }); it('does not shows the icon when is not defined', async () => { @@ -372,8 +437,10 @@ describe('EditConnectorFlyout', () => { /> ); - expect(getByTestId('configureConnectorTab')).toBeInTheDocument(); - expect(getByTestId('testConnectorTab')).toBeInTheDocument(); + await waitFor(() => { + expect(getByTestId('configureConnectorTab')).toBeInTheDocument(); + expect(getByTestId('testConnectorTab')).toBeInTheDocument(); + }); }); it('navigates to the test form', async () => { @@ -386,6 +453,10 @@ describe('EditConnectorFlyout', () => { /> ); + await waitFor(() => { + expect(getByTestId('testConnectorTab')).toBeInTheDocument(); + }); + expect(getByTestId('configureConnectorTab')).toBeInTheDocument(); expect(getByTestId('testConnectorTab')).toBeInTheDocument(); @@ -474,7 +545,9 @@ describe('EditConnectorFlyout', () => { /> ); - expect(getByTestId('test-connector-text-field')).toBeInTheDocument(); + await waitFor(() => { + expect(getByTestId('test-connector-text-field')).toBeInTheDocument(); + }); await user.clear(getByTestId('test-connector-text-field')); await user.type(getByTestId('test-connector-text-field'), 'My updated text field'); @@ -582,6 +655,8 @@ describe('EditConnectorFlyout', () => { /> ); + expect(await screen.findByTestId('nameInput')).toBeInTheDocument(); + await userEvent.clear(screen.getByTestId('nameInput')); await userEvent.type(screen.getByTestId('nameInput'), 'My new name'); await userEvent.type(screen.getByTestId('test-connector-secret-text-field'), 'password'); @@ -769,8 +844,10 @@ describe('EditConnectorFlyout', () => { /> ); - expect(getByTestId('configureConnectorTab')).toBeInTheDocument(); - expect(screen.queryByTestId('testConnectorTab')).toBeEnabled(); + await waitFor(() => { + expect(getByTestId('configureConnectorTab')).toBeInTheDocument(); + expect(screen.queryByTestId('testConnectorTab')).toBeEnabled(); + }); }); }); }); @@ -791,8 +868,9 @@ describe('is spec connector', () => { const actionTypeRegistry = actionTypeRegistryMock.create(); - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); + loadActionTypes.mockResolvedValue(defaultEditFlyoutLoadActionTypes); actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(actionTypeModel); appMockRenderer = createAppMockRenderer(); @@ -802,6 +880,9 @@ describe('is spec connector', () => { }; appMockRenderer.coreStart.http.put = jest.fn().mockResolvedValue(updateConnectorResponse); appMockRenderer.coreStart.http.post = jest.fn().mockResolvedValue(executeConnectorResponse); + await act(async () => { + await Promise.resolve(); + }); }); it('should not render the test tab', async () => { @@ -818,3 +899,213 @@ describe('is spec connector', () => { expect(screen.queryByTestId('testConnectorTab')).not.toBeInTheDocument(); }); }); + +describe('EditConnectorFlyout spec loading', () => { + let appMockRenderer: AppMockRenderer; + const onClose = jest.fn(); + const onConnectorUpdated = jest.fn(); + + const specConnectorType = { + id: 'spec-edit-connector', + name: 'Spec Edit Connector', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic' as const, + supportedFeatureIds: ['workflows'], + source: 'spec', + description: 'Test spec connector description', + }; + + const mockSpecResponse = { + metadata: { + id: 'spec-edit-connector', + display_name: 'Spec Edit Connector', + description: 'Connect to Test API', + minimum_license: 'basic', + supported_feature_ids: ['workflows'], + }, + schema: { + type: 'object', + properties: { + config: { + type: 'object', + properties: {}, + }, + secrets: { + anyOf: [ + { + type: 'object', + properties: { + authType: { const: 'api_key_header', type: 'string' }, + apiKey: { + type: 'string', + minLength: 1, + label: 'API key', + sensitive: true, + }, + }, + required: ['authType', 'apiKey'], + label: 'API key header authentication', + }, + ], + label: 'Authentication', + }, + }, + required: ['config', 'secrets'], + }, + }; + + const specConnector: ActionConnector = createMockActionConnector({ + id: 'spec-conn-1', + name: 'Spec connector instance', + actionTypeId: 'spec-edit-connector', + config: {}, + secrets: {}, + authMode: 'shared', + }); + + const actionTypeRegistry = actionTypeRegistryMock.create(); + + beforeEach(() => { + jest.clearAllMocks(); + loadActionTypes.mockResolvedValue([specConnectorType as ActionType]); + actionTypeRegistry.has.mockReturnValue(false); + actionTypeRegistry.get.mockImplementation(() => { + throw new Error('Unexpected get for unregistered type'); + }); + appMockRenderer = createAppMockRenderer(); + appMockRenderer.coreStart.application.capabilities = { + ...appMockRenderer.coreStart.application.capabilities, + actions: { save: true, show: true, execute: true }, + }; + appMockRenderer.coreStart.http.put = jest.fn().mockResolvedValue({ + connector_type_id: 'spec-edit-connector', + is_preconfigured: false, + is_deprecated: false, + name: 'Spec connector instance', + config: {}, + secrets: {}, + id: 'spec-conn-1', + }); + appMockRenderer.coreStart.http.post = jest.fn().mockResolvedValue(executeConnectorResponse); + appMockRenderer.coreStart.http.get = jest.fn().mockResolvedValue(mockSpecResponse); + appMockRenderer.coreStart.uiSettings.get = jest.fn().mockImplementation((key: string) => { + if (key === 'workflows:ui:enabled') { + return true; + } + return undefined; + }); + }); + + afterEach(() => { + loadActionTypes.mockImplementation((opts) => actualActionConnectorApi.loadActionTypes(opts)); + }); + + it('fetches spec from API when editing a spec-based connector', async () => { + appMockRenderer.render( + + ); + + await waitFor(() => { + expect(appMockRenderer.coreStart.http.get).toHaveBeenCalledWith( + '/internal/actions/connector_types/spec-edit-connector/spec', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('nameInput')).toBeInTheDocument(); + }); + }); + + it('shows loading state while spec is being fetched', async () => { + let resolveSpec: (value: typeof mockSpecResponse) => void; + const specPromise = new Promise((resolve) => { + resolveSpec = resolve; + }); + appMockRenderer.coreStart.http.get = jest.fn().mockReturnValue(specPromise); + + appMockRenderer.render( + + ); + + await waitFor(() => { + expect(appMockRenderer.coreStart.http.get).toHaveBeenCalled(); + }); + + expect(screen.queryByTestId('nameInput')).not.toBeInTheDocument(); + + resolveSpec!(mockSpecResponse); + + await waitFor(() => { + expect(screen.getByTestId('nameInput')).toBeInTheDocument(); + }); + }); + + it('shows error state when spec fetch fails', async () => { + const errorMessage = 'Failed to fetch spec'; + appMockRenderer.coreStart.http.get = jest.fn().mockRejectedValue(new Error(errorMessage)); + + appMockRenderer.render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('connector-spec-load-error')).toBeInTheDocument(); + }); + }); + + it('does not fetch spec when loadActionTypes has no match (unregistered connector type)', async () => { + loadActionTypes.mockResolvedValue([ + { + id: 'other-type', + name: 'Other', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic' as const, + supportedFeatureIds: ['alerting'], + }, + ] as ActionType[]); + + const orphanConnector: ActionConnector = createMockActionConnector({ + id: 'orphan-1', + name: 'Orphan', + actionTypeId: '.orphan-plugin-type', + config: {}, + secrets: {}, + authMode: 'shared', + }); + + appMockRenderer.render( + + ); + + await waitFor(() => { + expect(loadActionTypes).toHaveBeenCalled(); + }); + + expect(appMockRenderer.coreStart.http.get).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx index 5c2bc37195d37..b9fe591173475 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx @@ -7,27 +7,32 @@ import type { ReactNode } from 'react'; import React, { memo, useCallback, useEffect, useRef, useMemo, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; import type { IconType } from '@elastic/eui'; import { + EuiButton, EuiFlyout, EuiFlyoutBody, EuiConfirmModal, EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, EuiSpacer, useGeneratedHtmlId, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import type { ActionType, ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; import { isActionTypeExecutorResult } from '@kbn/actions-plugin/common'; import type { Option } from 'fp-ts/Option'; import { none, some } from 'fp-ts/Option'; import type { ConnectorFormSchema } from '@kbn/alerts-ui-shared'; import { ACTION_TYPE_SOURCES } from '@kbn/actions-types/action_types'; +import { useActionTypeModel } from '@kbn/alerts-ui-shared'; import { ReadOnlyConnectorMessage } from './read_only'; import type { ActionConnector, - ActionTypeModel, ActionTypeRegistryContract, UserConfiguredActionConnector, } from '../../../../types'; @@ -35,6 +40,7 @@ import { EditConnectorTabs } from '../../../../types'; import type { ConnectorFormState } from '../connector_form'; import { ConnectorForm } from '../connector_form'; import { useUpdateConnector } from '../../../hooks/use_edit_connector'; +import { loadActionTypes } from '../../../lib/action_connector_api'; import { useKibana } from '../../../../common/lib/kibana'; import { hasSaveActionsCapability } from '../../../lib/capabilities'; import { TestConnectorForm } from '../test_connector_form'; @@ -78,6 +84,8 @@ const EditConnectorFlyoutComponent: React.FC = ({ const { docLinks, + http, + uiSettings, application: { capabilities }, } = useKibana().services; @@ -134,9 +142,78 @@ const EditConnectorFlyoutComponent: React.FC = ({ const { preSubmitValidator, submit, isValid: isFormValid, isSubmitting } = formState; const hasErrors = isFormValid === false; const isSaving = isUpdatingConnector || isSubmitting || isExecutingConnector; - const actionTypeModel: ActionTypeModel | null = actionTypeRegistry.get(connector.actionTypeId); - const showButtons = canSave && actionTypeModel && !connector.isPreconfigured; - const disabled = !isFormModified || hasErrors || isSaving; + const [resolvedActionType, setResolvedActionType] = useState(null); + const [actionTypesLoadError, setActionTypesLoadError] = useState(null); + + useEffect(() => { + let cancelled = false; + const fallbackName = connector.name ?? ''; + setActionTypesLoadError(null); + (async () => { + try { + const types = await loadActionTypes({ http }); + if (cancelled) { + return; + } + const match = types.find((t) => t.id === connector.actionTypeId); + setResolvedActionType( + match ?? + ({ + id: connector.actionTypeId, + name: fallbackName, + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: [], + isSystemActionType: false, + isDeprecated: false, + source: connector.source ?? ACTION_TYPE_SOURCES.stack, + } as ActionType) + ); + } catch (err) { + if (!cancelled) { + setResolvedActionType(null); + setActionTypesLoadError(err instanceof Error ? err : new Error(String(err))); + } + } + })(); + return () => { + cancelled = true; + }; + // connector.name is only used as a display fallback in the synthetic ActionType object; + // it does not affect which type is loaded, so it must not be a dep. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector.actionTypeId]); + + const { + actionTypeModel, + isLoading: isLoadingActionTypeModel, + error: actionTypeModelError, + refetch: refetchConnectorSpec, + } = useActionTypeModel({ actionTypeRegistry, actionType: resolvedActionType, http, uiSettings }); + + // Delay the spinner so quick spec loads don't flash a loading state. + const [showLoadingSpinner, setShowLoadingSpinner] = useState(false); + useDebounce(() => setShowLoadingSpinner(isLoadingActionTypeModel), 300, [ + isLoadingActionTypeModel, + ]); + + const isResolvingConnectorType = resolvedActionType === null && !actionTypesLoadError; + + const showButtons = + canSave && + actionTypeModel && + !connector.isPreconfigured && + !isLoadingActionTypeModel && + !actionTypeModelError; + const disabled = + !isFormModified || + hasErrors || + isSaving || + isLoadingActionTypeModel || + !!actionTypeModelError || + !actionTypeModel; const connectorWithoutSecrets = useMemo( () => @@ -279,14 +356,110 @@ const EditConnectorFlyoutComponent: React.FC = ({ )} - - {!!preSubmitValidationErrorMessage &&

{preSubmitValidationErrorMessage}

} + {actionTypesLoadError && ( + <> + +

+ {i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.actionTypesLoadErrorDescription', + { + defaultMessage: + 'The connector form could not be loaded. Try again, or contact your administrator if the problem persists.', + } + )} +

+
+ + + )} + + {(showLoadingSpinner || isResolvingConnectorType) && ( + + + + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.loadingConnectorConfiguration', + { + defaultMessage: 'Loading connector configuration...', + } + )} + + + )} + + {actionTypeModelError && ( + <> + +

+ {i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.specLoadErrorDescription', + { + defaultMessage: + 'The connector form could not be loaded. Try again, or contact your administrator if the problem persists.', + } + )} +

+ + refetchConnectorSpec()} + > + {i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.specLoadErrorRetry', + { defaultMessage: 'Retry' } + )} + +
+ + + )} + + {actionTypeModel && + !isLoadingActionTypeModel && + !actionTypeModelError && + !actionTypesLoadError && ( + <> + + {!!preSubmitValidationErrorMessage &&

{preSubmitValidationErrorMessage}

} + + )} )} @@ -306,10 +479,16 @@ const EditConnectorFlyoutComponent: React.FC = ({ connectorWithoutSecrets, docLinks.links.alerting.preconfiguredConnectors, actionTypeModel, + actionTypeModelError, + actionTypesLoadError, + isResolvingConnectorType, isEdit, + isLoadingActionTypeModel, + showLoadingSpinner, showFormErrors, onFormModifiedChange, preSubmitValidationErrorMessage, + refetchConnectorSpec, ]); const renderTestTab = useCallback(() => { @@ -354,7 +533,18 @@ const EditConnectorFlyoutComponent: React.FC = ({ const isTestable = isTestableProp ?? - (!actionTypeModel?.source || actionTypeModel?.source === ACTION_TYPE_SOURCES.stack); + (() => { + if (!resolvedActionType && !actionTypeModel) { + return false; + } + const source = + actionTypeModel?.source ?? + resolvedActionType?.source ?? + // Older API payloads / saved objects may omit `source`; treat as stack so the test tab + // stays available for legacy in-process connector types. + ACTION_TYPE_SOURCES.stack; + return source === ACTION_TYPE_SOURCES.stack; + })(); return ( <> @@ -368,7 +558,7 @@ const EditConnectorFlyoutComponent: React.FC = ({ isPreconfigured={connector.isPreconfigured} connectorName={connector.name} connectorTypeDesc={ - actionTypeModel?.selectMessagePreconfigured || actionTypeModel?.selectMessage + actionTypeModel?.selectMessagePreconfigured || actionTypeModel?.selectMessage || '' } setTab={handleSetTab} selectedTab={selectedTab} diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json index edf74eeec24c0..f210f24a5f2f1 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json @@ -99,7 +99,8 @@ "@kbn/cps", "@kbn/security-plugin", "@kbn/std", - "@kbn/lens-common" + "@kbn/lens-common", + "@kbn/connector-specs" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_connector_spec.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_connector_spec.ts new file mode 100644 index 0000000000000..79739ef0fc3ec --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_connector_spec.ts @@ -0,0 +1,146 @@ +/* + * 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 expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../../scenarios'; +import { getUrlPrefix } from '../../../../common/lib'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +export default function getConnectorSpecTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('get connector spec', () => { + describe('basic functionality', () => { + it('returns spec for a valid spec-based connector (alienvault-otx)', async () => { + const response = await supertest + .get(`${getUrlPrefix('space1')}/internal/actions/connector_types/.alienvault-otx/spec`) + .set('kbn-xsrf', 'foo') + .expect(200); + + // Verify metadata structure + expect(response.body).to.have.property('metadata'); + expect(response.body.metadata).to.have.property('id', '.alienvault-otx'); + expect(response.body.metadata).to.have.property('display_name'); + expect(response.body.metadata).to.have.property('description'); + expect(response.body.metadata).to.have.property('minimum_license'); + expect(response.body.metadata).to.have.property('supported_feature_ids'); + expect(response.body.metadata.supported_feature_ids).to.be.an('array'); + + // Verify schema structure + expect(response.body).to.have.property('schema'); + expect(response.body.schema).to.be.an('object'); + }); + + it('returns 404 for non-spec connector (.server-log)', async () => { + await supertest + .get(`${getUrlPrefix('space1')}/internal/actions/connector_types/.server-log/spec`) + .set('kbn-xsrf', 'foo') + .expect(404); + }); + + it('returns 404 for unknown connector type', async () => { + const response = await supertest + .get( + `${getUrlPrefix('space1')}/internal/actions/connector_types/nonexistent-connector/spec` + ) + .set('kbn-xsrf', 'foo') + .expect(404); + + expect(response.body).to.have.property('message'); + expect(response.body.message).to.contain('not found'); + }); + + it('schema contains valid JSON Schema structure', async () => { + const response = await supertest + .get(`${getUrlPrefix('space1')}/internal/actions/connector_types/.alienvault-otx/spec`) + .set('kbn-xsrf', 'foo') + .expect(200); + + const { schema } = response.body; + + // JSON Schema should have type, typically 'object' + expect(schema).to.have.property('type'); + // Should have properties for config and secrets + expect(schema).to.have.property('properties'); + }); + }); + + describe('authorization', () => { + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle authorization correctly', async () => { + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix(space.id)}/internal/actions/connector_types/.alienvault-otx/spec` + ) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.have.property('metadata'); + expect(response.body).to.have.property('schema'); + expect(response.body.metadata.id).to.eql('.alienvault-otx'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); + + describe('response validation', () => { + it('metadata contains all required fields', async () => { + const response = await supertest + .get(`${getUrlPrefix('space1')}/internal/actions/connector_types/.alienvault-otx/spec`) + .set('kbn-xsrf', 'foo') + .expect(200); + + const { metadata } = response.body; + + // Required fields per the route schema + expect(metadata).to.have.property('id'); + expect(metadata).to.have.property('display_name'); + expect(metadata).to.have.property('description'); + expect(metadata).to.have.property('minimum_license'); + expect(metadata).to.have.property('supported_feature_ids'); + + // Type validations + expect(typeof metadata.id).to.eql('string'); + expect(typeof metadata.display_name).to.eql('string'); + expect(typeof metadata.description).to.eql('string'); + expect(typeof metadata.minimum_license).to.eql('string'); + expect(Array.isArray(metadata.supported_feature_ids)).to.eql(true); + }); + + it('works without space prefix (default space)', async () => { + const response = await supertest + .get('/internal/actions/connector_types/.alienvault-otx/spec') + .set('kbn-xsrf', 'foo') + .expect(200); + + expect(response.body.metadata.id).to.eql('.alienvault-otx'); + }); + }); + }); +} 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 95e70e72b49c7..31138b3544989 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 @@ -18,6 +18,8 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide await tearDown(getService); }); + loadTestFile(require.resolve('./get_connector_spec')); + // Connector types A-P (first ~15 alphabetically) loadTestFile(require.resolve('./connector_types/bedrock')); loadTestFile(require.resolve('./connector_types/cases_webhook'));