diff --git a/docs/reference/connectors-kibana/openai-action-type.md b/docs/reference/connectors-kibana/openai-action-type.md index bdc59b4c1aad..c1586f7b8e0c 100644 --- a/docs/reference/connectors-kibana/openai-action-type.md +++ b/docs/reference/connectors-kibana/openai-action-type.md @@ -9,7 +9,7 @@ applies_to: # OpenAI connector and action [openai-action-type] -The OpenAI connector uses [axios](https://github.com/axios/axios) to send a POST request to an OpenAI provider, either OpenAI or Azure OpenAI. +The OpenAI connector uses [axios](https://github.com/axios/axios) to send a POST request to an OpenAI provider, either OpenAI, Azure OpenAI, or Other (OpenAI-compatible service). ## Create connectors in {{kib}} [define-gen-ai-ui] @@ -30,20 +30,36 @@ To validate that your connectivity problems are caused by using a proxy, you can OpenAI connectors have the following configuration properties: -Name -: The name of the connector. +| Field | Required for | Description | +|------------------|---------------------|---------------------------------------------------------------------------------------------| +| Name | All | The name of the connector. | +| OpenAI provider | All | The API provider: `OpenAI`, `Azure OpenAI`, or `Other` (OpenAI-compatible service). | +| URL | All | The API endpoint URL for the selected provider. | +| Default model | OpenAI/Other | The default model for requests. **Required** for `Other`, optional for `OpenAI`. | +| Headers | Optional | Custom HTTP headers to include in requests. | +| Verification mode| Other (PKI only) | SSL verification mode for PKI authentication. Default: `full`. | +| API key | OpenAI/Azure/Other | The API key for authentication. **Required** for `OpenAI` and `Azure OpenAI`. For `Other`, required unless PKI authentication is used. | +| PKI fields | Other (PKI only) | See below. Only available for `Other` provider. | -OpenAI provider -: The OpenAI API provider, either OpenAI or Azure OpenAI. +#### PKI Authentication (Other provider only) -URL -: The OpenAI request URL. +When using the `Other` provider, you can use PKI (certificate-based) authentication. With PKI, you can also optionally include an API key if your OpenAI-compatible service supports or requires one. The following fields are supported for PKI: -Default model -: (optional) The default model to use for requests. This option is available only when the provider is `OpenAI`. +- **Certificate data** (`certificateData`): PEM-encoded certificate content, base64-encoded. (**Required for PKI**) +- **Private key data** (`privateKeyData`): PEM-encoded private key content, base64-encoded. (**Required for PKI**) +- **CA data** (`caData`, optional): PEM-encoded CA certificate content, base64-encoded. +- **API key** (`apiKey`, optional): The API key for authentication, if required by your service. +- **Verification mode** (`verificationMode`): SSL verification mode for PKI authentication. Options: + - `full` (default): Verify server's certificate and hostname + - `certificate`: Verify only the server's certificate + - `none`: Skip verification (not recommended for production) -API key -: The OpenAI or Azure OpenAI API key for authentication. +**Note:** +- All PKI fields must be PEM-encoded and base64-encoded when sent via API. +- If any PKI field is provided, both `certificateData` and `privateKeyData` are required and must be valid PEM. +- With PKI, you may also include an API key if your provider supports or requires it. +- If PKI is not used, `apiKey` is required for the `Other` provider. +- For `OpenAI` and `Azure OpenAI`, only `apiKey` is supported for authentication. ## Test connectors [gen-ai-action-configuration] diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index bf84c7cfeab5..b09a87853663 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -383,6 +383,7 @@ paths: - $ref: '#/components/schemas/jira_config' - $ref: '#/components/schemas/genai_azure_config' - $ref: '#/components/schemas/genai_openai_config' + - $ref: '#/components/schemas/genai_openai_other_config' - $ref: '#/components/schemas/opsgenie_config' - $ref: '#/components/schemas/pagerduty_config' - $ref: '#/components/schemas/sentinelone_config' @@ -74170,12 +74171,30 @@ components: The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts. genai_secrets: title: Connector secrets properties for an OpenAI connector - description: Defines secrets for connectors when type is `.gen-ai`. + description: | + Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content. type: object properties: apiKey: type: string - description: The OpenAI API key. + description: | + The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one. + certificateData: + type: string + description: | + Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI. + minLength: 1 + privateKeyData: + type: string + description: | + Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI. + minLength: 1 + caData: + type: string + description: | + Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional. + minLength: 1 + required: [] opsgenie_secrets: title: Connector secrets properties for an Opsgenie connector required: @@ -74345,6 +74364,52 @@ components: description: | A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`. type: string + genai_openai_other_config: + title: Connector request properties for an OpenAI connector with Other provider + description: | + Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication. + type: object + required: + - apiProvider + - apiUrl + - defaultModel + properties: + apiProvider: + type: string + description: The OpenAI API provider. + enum: + - Other + apiUrl: + type: string + description: The OpenAI-compatible API endpoint. + defaultModel: + type: string + description: The default model to use for requests. + certificateData: + type: string + description: PEM-encoded certificate content. + minLength: 1 + privateKeyData: + type: string + description: PEM-encoded private key content. + minLength: 1 + caData: + type: string + description: PEM-encoded CA certificate content. + minLength: 1 + verificationMode: + type: string + description: SSL verification mode for PKI authentication. + enum: + - full + - certificate + - none + default: full + headers: + type: object + description: Custom headers to include in requests. + additionalProperties: + type: string defender_secrets: title: Connector secrets properties for a Microsoft Defender for Endpoint connector required: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index af14379d6276..d107e7e8c1fe 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -435,6 +435,7 @@ paths: - $ref: '#/components/schemas/jira_config' - $ref: '#/components/schemas/genai_azure_config' - $ref: '#/components/schemas/genai_openai_config' + - $ref: '#/components/schemas/genai_openai_other_config' - $ref: '#/components/schemas/opsgenie_config' - $ref: '#/components/schemas/pagerduty_config' - $ref: '#/components/schemas/sentinelone_config' @@ -84083,12 +84084,30 @@ components: The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts. genai_secrets: title: Connector secrets properties for an OpenAI connector - description: Defines secrets for connectors when type is `.gen-ai`. + description: | + Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content. type: object properties: apiKey: type: string - description: The OpenAI API key. + description: | + The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one. + certificateData: + type: string + description: | + Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI. + minLength: 1 + privateKeyData: + type: string + description: | + Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI. + minLength: 1 + caData: + type: string + description: | + Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional. + minLength: 1 + required: [] opsgenie_secrets: title: Connector secrets properties for an Opsgenie connector required: @@ -84258,6 +84277,52 @@ components: description: | A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`. type: string + genai_openai_other_config: + title: Connector request properties for an OpenAI connector with Other provider + description: | + Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication. + type: object + required: + - apiProvider + - apiUrl + - defaultModel + properties: + apiProvider: + type: string + description: The OpenAI API provider. + enum: + - Other + apiUrl: + type: string + description: The OpenAI-compatible API endpoint. + defaultModel: + type: string + description: The default model to use for requests. + certificateData: + type: string + description: PEM-encoded certificate content. + minLength: 1 + privateKeyData: + type: string + description: PEM-encoded private key content. + minLength: 1 + caData: + type: string + description: PEM-encoded CA certificate content. + minLength: 1 + verificationMode: + type: string + description: SSL verification mode for PKI authentication. + enum: + - full + - certificate + - none + default: full + headers: + type: object + description: Custom headers to include in requests. + additionalProperties: + type: string defender_secrets: title: Connector secrets properties for a Microsoft Defender for Endpoint connector required: diff --git a/oas_docs/overlays/connectors.overlays.yaml b/oas_docs/overlays/connectors.overlays.yaml index 8d1b5ee9a064..bd533b1b1a84 100644 --- a/oas_docs/overlays/connectors.overlays.yaml +++ b/oas_docs/overlays/connectors.overlays.yaml @@ -164,6 +164,8 @@ actions: - $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_azure_config.yaml' # OpenAI (.gen-ai) - $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_config.yaml' + # Other OpenAI (.gen-ai) + - $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_other_config.yaml' # Opsgenie (.opsgenie) - $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/opsgenie_config.yaml' # PagerDuty (.pagerduty) diff --git a/x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_other_config.yaml b/x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_other_config.yaml new file mode 100644 index 000000000000..bb22ef4346fb --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_other_config.yaml @@ -0,0 +1,41 @@ +title: Connector request properties for an OpenAI connector with Other provider +description: > + Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication. +type: object +required: + - apiProvider + - apiUrl + - defaultModel +properties: + apiProvider: + type: string + description: The OpenAI API provider. + enum: ['Other'] + apiUrl: + type: string + description: The OpenAI-compatible API endpoint. + defaultModel: + type: string + description: The default model to use for requests. + certificateData: + type: string + description: PEM-encoded certificate content. + minLength: 1 + privateKeyData: + type: string + description: PEM-encoded private key content. + minLength: 1 + caData: + type: string + description: PEM-encoded CA certificate content. + minLength: 1 + verificationMode: + type: string + description: SSL verification mode for PKI authentication. + enum: ['full', 'certificate', 'none'] + default: 'full' + headers: + type: object + description: Custom headers to include in requests. + additionalProperties: + type: string diff --git a/x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_secrets.yaml b/x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_secrets.yaml index 586c50ddbbd3..e811abeefcc8 100644 --- a/x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_secrets.yaml +++ b/x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_secrets.yaml @@ -1,7 +1,26 @@ title: Connector secrets properties for an OpenAI connector -description: Defines secrets for connectors when type is `.gen-ai`. +# Defines secrets for connectors when type is `.gen-ai`, including support for PKI authentication for 'Other' providers. +description: | + Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content. type: object properties: - apiKey: - type: string - description: The OpenAI API key. + apiKey: + type: string + description: | + The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one. + certificateData: + type: string + description: | + Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI. + minLength: 1 + privateKeyData: + type: string + description: | + Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI. + minLength: 1 + caData: + type: string + description: | + Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional. + minLength: 1 +required: [] diff --git a/x-pack/platform/plugins/shared/actions/server/index.ts b/x-pack/platform/plugins/shared/actions/server/index.ts index f93a140405de..83bde129df70 100644 --- a/x-pack/platform/plugins/shared/actions/server/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/index.ts @@ -23,6 +23,7 @@ export type { ActionType, InMemoryConnector, ActionsApiRequestHandlerContext, + SSLSettings, } from './types'; export type { ConnectorWithExtraFindData as FindActionResult } from './application/connector/types'; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts index db40f1eb3e46..7193a8ee5557 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_custom_agents.ts @@ -68,7 +68,8 @@ export function getCustomAgents( // This is where the global rejectUnauthorized is overridden by a custom host const customHostNodeSSLOptions = getNodeSSLOptions( logger, - sslSettingsFromConfig.verificationMode + sslSettingsFromConfig.verificationMode, + sslOverrides ); if (customHostNodeSSLOptions.rejectUnauthorized !== undefined) { agentOptions.rejectUnauthorized = customHostNodeSSLOptions.rejectUnauthorized; @@ -116,7 +117,8 @@ export function getCustomAgents( const proxyNodeSSLOptions = getNodeSSLOptions( logger, - proxySettings.proxySSLSettings.verificationMode + proxySettings.proxySSLSettings.verificationMode, + sslOverrides ); // At this point, we are going to use a proxy, so we need new agents. // We will though, copy over the calculated ssl options from above, into diff --git a/x-pack/platform/plugins/shared/actions/server/sub_action_framework/types.ts b/x-pack/platform/plugins/shared/actions/server/sub_action_framework/types.ts index af238cdfdecb..e07f21e9ec10 100644 --- a/x-pack/platform/plugins/shared/actions/server/sub_action_framework/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/sub_action_framework/types.ts @@ -16,6 +16,7 @@ import type { ActionTypeParams, RenderParameterTemplates, Services, + SSLSettings, ValidatorType as ValidationSchema, } from '../types'; import type { SubFeature } from '../../common'; @@ -41,6 +42,7 @@ export type SubActionRequestParams = { url: string; responseSchema: Type; method?: Method; + sslOverrides?: SSLSettings; } & AxiosRequestConfig; export type IService = new ( diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.test.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.test.tsx index 66e29c79c9fa..96a59782839e 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.test.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.test.tsx @@ -397,4 +397,101 @@ describe('ConnectorFields renders', () => { expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false }); }); }); + + describe('PKI Configuration', () => { + it('toggles pki as expected', async () => { + const testFormData = { + ...otherOpenAiConnector, + __internal__: { + hasHeaders: false, + hasPKI: false, + }, + }; + render( + + {}} /> + + ); + + const pkiToggle = await screen.findByTestId('openAIViewPKISwitch'); + + expect(screen.queryByTestId('openAISSLCRTInput')).not.toBeInTheDocument(); + expect(screen.queryByTestId('openAISSLKEYInput')).not.toBeInTheDocument(); + expect(screen.queryByTestId('verificationModeSelect')).not.toBeInTheDocument(); + expect(pkiToggle).toBeInTheDocument(); + + await userEvent.click(pkiToggle); + expect(screen.getByTestId('openAISSLCRTInput')).toBeInTheDocument(); + expect(screen.getByTestId('openAISSLKEYInput')).toBeInTheDocument(); + expect(screen.getByTestId('verificationModeSelect')).toBeInTheDocument(); + }); + it('succeeds without pki', async () => { + const testFormData = { + ...otherOpenAiConnector, + __internal__: { + hasHeaders: false, + hasPKI: false, + }, + }; + const onSubmit = jest.fn(); + render( + + {}} /> + + ); + + await userEvent.click(await screen.findByTestId('form-test-provide-submit')); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + data: { + ...testFormData, + }, + isValid: true, + }); + }); + }); + it('succeeds with pki', async () => { + const testFormData = { + ...otherOpenAiConnector, + __internal__: { + hasHeaders: false, + hasPKI: false, + }, + }; + const onSubmit = jest.fn(); + render( + + {}} /> + + ); + const pkiToggle = await screen.findByTestId('openAIViewPKISwitch'); + await userEvent.click(pkiToggle); + const certFile = new File(['hello'], 'cert.crt', { type: 'text/plain' }); + const keyFile = new File(['world'], 'key.key', { type: 'text/plain' }); + await userEvent.upload(screen.getByTestId('openAISSLCRTInput'), certFile); + await userEvent.upload(screen.getByTestId('openAISSLKEYInput'), keyFile); + await userEvent.click(await screen.findByTestId('form-test-provide-submit')); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + data: { + ...testFormData, + config: { + ...testFormData.config, + verificationMode: 'full', + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + certificateData: Buffer.from('hello').toString('base64'), + privateKeyData: Buffer.from('world').toString('base64'), + }, + __internal__: { + hasHeaders: false, + hasPKI: true, + }, + }, + isValid: true, + }); + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.tsx index 4e2ebebda6d1..2ab76e4329ca 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/connector.tsx @@ -11,6 +11,7 @@ import { SimpleConnectorForm, } from '@kbn/triggers-actions-ui-plugin/public'; import { + FilePickerField, SelectField, TextField, ToggleField, @@ -39,21 +40,27 @@ import { azureAiConfig, azureAiSecrets, otherOpenAiConfig, - otherOpenAiSecrets, + getOtherOpenAiSecrets, openAiSecrets, providerOptions, openAiConfig, } from './constants'; +import { CRT_REQUIRED, KEY_REQUIRED } from '../../common/auth/translations'; const { emptyField } = fieldValidators; + const ConnectorFields: React.FC = ({ readOnly, isEdit }) => { const { euiTheme } = useEuiTheme(); const { getFieldDefaultValue } = useFormContext(); const [{ config, __internal__, id, name }] = useFormData({ - watch: ['config.apiProvider', '__internal__.hasHeaders'], + watch: ['config.apiProvider', '__internal__.hasHeaders', '__internal__.hasPKI'], }); const hasHeaders = __internal__ != null ? __internal__.hasHeaders : false; const hasHeadersDefaultValue = !!getFieldDefaultValue('config.headers'); + const hasPKI = __internal__ != null ? __internal__.hasPKI : false; + const hasPKIDefaultValue = useMemo(() => { + return !!getFieldDefaultValue('config.verificationMode'); + }, [getFieldDefaultValue]); const selectedProviderDefaultValue = useMemo( () => @@ -61,6 +68,14 @@ const ConnectorFields: React.FC = ({ readOnly, isEdi [getFieldDefaultValue] ); + const verificationModeOptions = [ + { value: 'full', text: i18n.VERIFICATION_MODE_FULL }, + { value: 'certificate', text: i18n.VERIFICATION_MODE_CERTIFICATE }, + { value: 'none', text: i18n.VERIFICATION_MODE_NONE }, + ]; + + const otherOpenAiSecrets = useMemo(() => getOtherOpenAiSecrets(!hasPKI), [hasPKI]); + return ( <> = ({ readOnly, isEdi /> )} {config != null && config.apiProvider === OpenAiProviderType.Other && ( - + <> + + + + {hasPKI && ( + <> + + + + + + + + )} + )} = ({ readOnly, isEdi onClick={() => removeItem(item.id)} iconType="minusInCircle" aria-label={i18nAuth.DELETE_BUTTON} - style={{ marginTop: '28px' }} + css={{ marginTop: '28px' }} /> diff --git a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/constants.tsx b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/constants.tsx index 70d79e98ad10..e51084d2ec93 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/constants.tsx +++ b/x-pack/platform/plugins/shared/stack_connectors/public/connector_types/openai/constants.tsx @@ -243,11 +243,12 @@ export const azureAiSecrets: SecretsFieldSchema[] = [ }, ]; -export const otherOpenAiSecrets: SecretsFieldSchema[] = [ +export const getOtherOpenAiSecrets = (isRequired = true): SecretsFieldSchema[] => [ { id: 'apiKey', label: i18n.API_KEY_LABEL, isPasswordField: true, + isRequired, helpText: ( ; + // PKI properties are only used when apiProvider is OpenAiProviderType.Other + certificateData?: string; + privateKeyData?: string; + verificationMode?: 'full' | 'certificate' | 'none'; + caData?: string; +} + +export interface InternalConfig { + hasPKI: boolean; } export interface Secrets { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.test.ts index 1cdcd40b11a3..fd5bb1546ecf 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.test.ts @@ -5,7 +5,16 @@ * 2.0. */ -import { sanitizeRequest, getRequestWithStreamOption } from './other_openai_utils'; +import type { Logger } from '@kbn/logging'; +import { + sanitizeRequest, + getRequestWithStreamOption, + pkiSecretsValidator, + pkiErrorHandler, + getPKISSLOverrides, +} from './other_openai_utils'; +import type { AxiosError } from 'axios'; +import type { Secrets } from '../../../../common/openai/types'; describe('Other (OpenAI Compatible Service) Utils', () => { describe('sanitizeRequest', () => { @@ -150,4 +159,203 @@ describe('Other (OpenAI Compatible Service) Utils', () => { }); }); }); + describe('PKI utils', () => { + it('pkiErrorHandler returns undefined for unrecognized PKI-related error messages', () => { + const error = { message: 'Some unknown error occurred' } as AxiosError; + const result = pkiErrorHandler(error); + expect(result).toBeUndefined(); + }); + + it('returns a friendly message for UNABLE_TO_VERIFY_LEAF_SIGNATURE', () => { + const error = { + message: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE: certificate problem', + } as AxiosError; + const result = pkiErrorHandler(error); + expect(result).toMatch( + /Certificate error: UNABLE_TO_VERIFY_LEAF_SIGNATURE: certificate problem. Please check if your PKI certificates are valid or adjust SSL verification mode./ + ); + }); + + it('returns a TLS handshake message for ERR_TLS_CERT_ALTNAME_INVALID', () => { + const error = { + message: 'ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate', + } as AxiosError; + const result = pkiErrorHandler(error); + expect(result).toMatch( + /TLS handshake failed: ERR_TLS_CERT_ALTNAME_INVALID: Hostname\/IP does not match certificate. Verify server certificate hostname and CA configuration./ + ); + }); + + it('returns a TLS handshake message for ERR_TLS_HANDSHAKE', () => { + const error = { message: 'ERR_TLS_HANDSHAKE: handshake failed' } as AxiosError; + const result = pkiErrorHandler(error); + expect(result).toMatch( + /TLS handshake failed: ERR_TLS_HANDSHAKE: handshake failed. Verify server certificate hostname and CA configuration./ + ); + }); + + it('throws an error if certificateData is missing in pkiSecretsValidator', () => { + const secretsObject = { privateKeyData: 'privateKey' } as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).toThrow( + 'Certificate data must be provided for PKI' + ); + }); + + it('throws an error if privateKeyData is missing in pkiSecretsValidator', () => { + const secretsObject = { certificateData: 'certificate' } as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).toThrow( + 'Private key data must be provided for PKI' + ); + }); + + it('validates PEM format for privateKeyData in pkiSecretsValidator', () => { + const secretsObject = { + certificateData: Buffer.from( + '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----' + ).toString('base64'), + privateKeyData: 'bad', + } as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).toThrow( + 'Invalid Private key data file format: The file must be a PEM-encoded private key beginning with "-----BEGIN PRIVATE KEY-----" or "-----BEGIN RSA PRIVATE KEY-----".' + ); + }); + + it('validates PEM format for certificateData in pkiSecretsValidator', () => { + const secretsObject = { + certificateData: 'invalidCertificate', + privateKeyData: '-----BEGIN PRIVATE KEY-----', + } as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).toThrow( + 'Invalid Certificate data file format: The file must be a PEM-encoded certificate beginning with "-----BEGIN CERTIFICATE-----".' + ); + }); + + it('validates successfully with valid certificateData and privateKeyData PEM', () => { + const secretsObject = { + certificateData: Buffer.from( + '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----' + ).toString('base64'), + privateKeyData: Buffer.from( + '-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----' + ).toString('base64'), + } as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).not.toThrow(); + }); + + it('validates successfully with valid certificateData, privateKeyData, and caData PEM', () => { + const secretsObject = { + certificateData: Buffer.from( + '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----' + ).toString('base64'), + privateKeyData: Buffer.from( + '-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----' + ).toString('base64'), + caData: Buffer.from('-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----').toString( + 'base64' + ), + } as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).not.toThrow(); + }); + + it('throws an error if caData is not valid PEM in pkiSecretsValidator', () => { + const secretsObject = { + certificateData: Buffer.from( + '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----' + ).toString('base64'), + privateKeyData: Buffer.from( + '-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----' + ).toString('base64'), + caData: Buffer.from('not a valid cert').toString('base64'), + } as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).toThrow( + 'Invalid CA certificate data file format: The file must be a PEM-encoded certificate beginning with "-----BEGIN CERTIFICATE-----".' + ); + }); + + it('throws an error if none of the PKI fields are present', () => { + const secretsObject = {} as Secrets; + expect(() => pkiSecretsValidator(secretsObject)).toThrow( + 'PKI configuration requires certificate and private key' + ); + }); + + describe('getPKISSLOverrides', () => { + const logger = { error: jest.fn() } as unknown as Logger; + const validCert = Buffer.from( + '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----' + ).toString('base64'); + const validKey = Buffer.from( + '-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----' + ).toString('base64'); + const validCA = Buffer.from( + '-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----' + ).toString('base64'); + + it('returns expected SSLSettings with valid cert, key, and ca', () => { + const result = getPKISSLOverrides({ + logger, + certificateData: validCert, + privateKeyData: validKey, + caData: validCA, + verificationMode: 'full', + }); + expect(result).toMatchObject({ + verificationMode: 'full', + }); + expect(result.cert).toBeInstanceOf(Buffer); + expect(result.key).toBeInstanceOf(Buffer); + expect(result.ca).toBeInstanceOf(Buffer); + }); + + it('returns expected SSLSettings with valid cert and key, ca undefined', () => { + const result = getPKISSLOverrides({ + logger, + certificateData: validCert, + privateKeyData: validKey, + caData: undefined, + verificationMode: 'none', + }); + expect(result.ca).toBeUndefined(); + expect(result.cert).toBeInstanceOf(Buffer); + expect(result.key).toBeInstanceOf(Buffer); + expect(result.verificationMode).toBe('none'); + }); + + it('throws if cert is invalid', () => { + expect(() => + getPKISSLOverrides({ + logger, + certificateData: Buffer.from('not a cert').toString('base64'), + privateKeyData: validKey, + caData: validCA, + verificationMode: 'full', + }) + ).toThrow(); + }); + + it('throws if key is invalid', () => { + expect(() => + getPKISSLOverrides({ + logger, + certificateData: validCert, + privateKeyData: Buffer.from('not a key').toString('base64'), + caData: validCA, + verificationMode: 'full', + }) + ).toThrow(); + }); + + it('throws if ca is invalid', () => { + expect(() => + getPKISSLOverrides({ + logger, + certificateData: validCert, + privateKeyData: validKey, + caData: Buffer.from('not a ca').toString('base64'), + verificationMode: 'full', + }) + ).toThrow(); + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.ts index 25b6966a3b24..706478135f13 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/lib/other_openai_utils.ts @@ -5,6 +5,9 @@ * 2.0. */ +import type { Logger } from '@kbn/logging'; +import type { SSLSettings } from '@kbn/actions-plugin/server'; +import type { AxiosError } from 'axios'; import type { Secrets } from '../../../../common/openai/types'; /** @@ -46,8 +49,204 @@ export const getRequestWithStreamOption = ( return body; }; +// PKI utils +/** + * Input interface for SSL overrides. + */ +interface SSLOverridesInput { + logger: Logger; // Logger instance for logging errors or information. + certificateData: string; // PEM-encoded certificate data as a string. + privateKeyData: string; // PEM-encoded private key data as a string. + caData?: string; // PEM-encoded CA certificate data as a string. + verificationMode: 'full' | 'certificate' | 'none'; // SSL verification mode. +} + +/** + * Validates that the provided PEM data is in the correct format. + * + * @param data - The PEM data to validate. + * @param type - The type of PEM data ('CERTIFICATE' or 'PRIVATE KEY'). + * @param description - A description of the data being validated. + * @throws Will throw an error if the PEM data is not in the correct format. + */ +const PEM_HEADERS: Record<'PRIVATE KEY' | 'CERTIFICATE', string[]> = { + 'PRIVATE KEY': ['-----BEGIN PRIVATE KEY-----', '-----BEGIN RSA PRIVATE KEY-----'], + CERTIFICATE: ['-----BEGIN CERTIFICATE-----'], +}; +const PEM_FOOTERS: Record<'PRIVATE KEY' | 'CERTIFICATE', string[]> = { + 'PRIVATE KEY': ['-----END PRIVATE KEY-----', '-----END RSA PRIVATE KEY-----'], + CERTIFICATE: ['-----END CERTIFICATE-----'], +}; + +function getPEMHeaderFooter( + type: 'CERTIFICATE' | 'PRIVATE KEY', + content: string +): { header: string; footer: string } | undefined { + const headers = PEM_HEADERS[type]; + const footers = PEM_FOOTERS[type]; + for (let i = 0; i < headers.length; ++i) { + if (content.includes(headers[i])) { + return { header: headers[i], footer: footers[i] }; + } + } + return undefined; +} + +const validatePEMData = ( + data: string | undefined, + type: 'CERTIFICATE' | 'PRIVATE KEY', + description: string +): void => { + const decodedData = data ? Buffer.from(data, 'base64').toString() : undefined; + if (!decodedData) return; + const headers = PEM_HEADERS[type]; + const found = headers.some((h) => decodedData.includes(h)); + if (!found) { + const headerMsg = + headers.length === 1 ? `"${headers[0]}"` : headers.map((h) => `"${h}"`).join(' or '); + throw new Error( + `Invalid ${description} file format: The file must be a PEM-encoded ${type.toLowerCase()} beginning with ${headerMsg}.` + ); + } +}; + +/** + * Validates the provided PKI certificates for correct PEM formatting. + * + * @param input - An object containing certificate, private key, and CA data, along with a logger. + * @throws Will log and throw an error if any of the provided certificates are invalid. + */ +const validatePKICertificates = ({ + certificateData, + privateKeyData, + caData, + logger, +}: Omit): void => { + try { + validatePEMData(certificateData, 'CERTIFICATE', 'Certificate data'); + validatePEMData(privateKeyData, 'PRIVATE KEY', 'Private key data'); + if (caData) validatePEMData(caData, 'CERTIFICATE', 'CA certificate data'); + } catch (error) { + logger.error(`Error validating PKI certificates: ${error.message}`); + throw new Error(`Invalid or inaccessible PKI certificates: ${error.message}`); + } +}; + +/** + * Converts PEM data into a Buffer. + * + * @param data - The PEM data to convert. + * @param type - The type of PEM data ('CERTIFICATE' or 'PRIVATE KEY'). + * @returns A Buffer containing the PEM data. + * @throws Will throw an error if no data is provided. + */ +const loadBuffer = (data: string, type: 'CERTIFICATE' | 'PRIVATE KEY'): Buffer => { + if (data) { + return Buffer.from(formatPEMContent(Buffer.from(data, 'base64').toString(), type)); + } + throw new Error(`No ${type?.toLowerCase()} data provided`); +}; + +/** + * Generates SSL settings for PKI authentication. + * + * @param input - An object containing logger, certificate, private key, and CA data, along with verification mode. + * @returns An SSLSettings object containing the certificate, private key, CA, and verification mode. + * @throws Will throw an error if the provided certificates are invalid or missing. + */ +export const getPKISSLOverrides = ({ + logger, + certificateData, + privateKeyData, + caData, + verificationMode, +}: SSLOverridesInput): SSLSettings => { + validatePKICertificates({ + logger, + certificateData, + privateKeyData, + caData, + }); + + const cert = loadBuffer(certificateData, 'CERTIFICATE'); + const key = loadBuffer(privateKeyData, 'PRIVATE KEY'); + // CA can be undefined for verification mode "none". + const ca = caData ? loadBuffer(caData, 'CERTIFICATE') : undefined; + + return { cert, key, ca, verificationMode }; +}; + +/** + * Formats PEM content to ensure proper structure and line breaks. + * + * @param pemContent - The PEM content to format. + * @param type - The type of PEM data ('CERTIFICATE' or 'PRIVATE KEY'). + * @returns A properly formatted PEM string. + */ +function formatPEMContent(pemContent: string, type: 'CERTIFICATE' | 'PRIVATE KEY'): string { + if (!pemContent) return pemContent; + const normalizedContent = pemContent.replace(/\s+/g, ' ').trim(); + const headerFooter = getPEMHeaderFooter(type, normalizedContent); + if (!headerFooter) { + return pemContent; + } + const { header, footer } = headerFooter; + if (!normalizedContent.startsWith(header) || !normalizedContent.endsWith(footer)) { + return pemContent; + } + const base64Content = normalizedContent + .slice(header.length, normalizedContent.length - footer.length) + .replace(/\s+/g, ''); + const formattedBase64 = base64Content.match(/.{1,64}/g)?.join('\n') || base64Content; + return `${header}\n${formattedBase64}\n${footer}`; +} + +/** + * Handles PKI-related errors and provides user-friendly error messages. + * + * @param error - The AxiosError object containing details about the error. + * @returns A string with a user-friendly error message, or undefined if the error is not recognized. + */ +export const pkiErrorHandler = (error: AxiosError): string | undefined => { + if (error.message.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) { + return `Certificate error: ${error.message}. Please check if your PKI certificates are valid or adjust SSL verification mode.`; + } + if ( + error.message.includes('ERR_TLS_CERT_ALTNAME_INVALID') || + error.message.includes('ERR_TLS_HANDSHAKE') + ) { + return `TLS handshake failed: ${error.message}. Verify server certificate hostname and CA configuration.`; + } +}; + +/** + * Validates the PKI configuration object to ensure required fields are present and properly formatted. + * + * @param secretsObject - The configuration object containing PKI-related data. + * @throws Will throw an error if required fields are missing or if the provided data is not in valid PEM format. + */ export const pkiSecretsValidator = (secretsObject: Secrets): void => { - // TODO - implemented in 219984 + if ( + 'caData' in secretsObject || + 'certificateData' in secretsObject || + 'privateKeyData' in secretsObject + ) { + if (!secretsObject.certificateData) { + throw new Error('Certificate data must be provided for PKI'); + } + if (!secretsObject.privateKeyData) { + throw new Error('Private key data must be provided for PKI'); + } + // Validate PEM format for raw data + validatePEMData(secretsObject.certificateData, 'CERTIFICATE', 'Certificate data'); + validatePEMData(secretsObject.privateKeyData, 'PRIVATE KEY', 'Private key data'); + // CA is optional, but if provided, validate its format + if (secretsObject.caData) { + validatePEMData(secretsObject.caData, 'CERTIFICATE', 'CA certificate data'); + } + } else { + throw new Error('PKI configuration requires certificate and private key'); + } }; /** * Validates the apiKey in the secrets object for non-PKI authentication. diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.test.ts index 6e383e94430c..aa3c4caa2e87 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.test.ts @@ -241,6 +241,19 @@ describe('OpenAIConnector', () => { connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }, connectorUsageCollector) ).rejects.toThrow('API Error'); }); + + it('passes timeout and signal to runApi', async () => { + const signal = jest.fn(); + const timeout = 12345; + await connector.runApi( + { body: JSON.stringify({ messages: [] }), signal, timeout }, + new ConnectorUsageCollector({ logger, connectorId: 'test' }) + ); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ signal, timeout }), + expect.anything() + ); + }); }); describe('streamApi', () => { @@ -1506,6 +1519,155 @@ describe('OpenAIConnector', () => { expect(response).toEqual({ available: false }); }); }); + + describe('PKI/SSL overrides', () => { + const config = { + apiUrl: 'https://other-openai.local/v1/chat/completions', + apiProvider: OpenAiProviderType.Other, + defaultModel: DEFAULT_OTHER_OPENAI_MODEL, + verificationMode: 'full' as const, + headers: {}, + }; + const secrets = { + certificateData: Buffer.from( + '-----BEGIN CERTIFICATE-----cert-----END CERTIFICATE-----' + ).toString('base64'), + privateKeyData: Buffer.from( + '-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----' + ).toString('base64'), + caData: Buffer.from('-----BEGIN CERTIFICATE-----ca-----END CERTIFICATE-----').toString( + 'base64' + ), + }; + it('should initialize PKI SSL overrides when PKI secrets are present', () => { + const connector = new OpenAIConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: OPENAI_CONNECTOR_ID }, + config, + secrets, + logger, + services: actionsMock.createServices(), + }); + expect(connector).toBeDefined(); + // @ts-ignore + expect(connector.sslOverrides).toBeDefined(); + }); + it('should throw and log error when PKI secrets are invalid', () => { + const badSecrets = { ...secrets, certificateData: undefined }; + expect(() => { + new OpenAIConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: OPENAI_CONNECTOR_ID }, + config, + secrets: badSecrets, + logger, + services: actionsMock.createServices(), + }); + }).toThrow(); + }); + it('should call runApi with sslOverrides when they exist', async () => { + const connector = new OpenAIConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: OPENAI_CONNECTOR_ID }, + config, + secrets, + logger, + services: actionsMock.createServices(), + }); + // @ts-ignore + connector.request = jest.fn().mockResolvedValue({ data: {} }); + // @ts-ignore + connector.request = mockRequest; + await connector.runApi( + { body: JSON.stringify({ messages: [] }) }, + new ConnectorUsageCollector({ logger, connectorId: 'test' }) + ); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + sslOverrides: expect.objectContaining({ + verificationMode: 'full', + }), + }), + expect.anything() + ); + }); + }); + + describe('Organization/Project headers', () => { + it('should include OpenAI-Organization and OpenAI-Project headers if present', async () => { + const connector = new OpenAIConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: OPENAI_CONNECTOR_ID }, + config: { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: OpenAiProviderType.OpenAi, + defaultModel: DEFAULT_OPENAI_MODEL, + organizationId: 'org-id', + projectId: 'proj-id', + headers: { 'X-My-Custom-Header': 'foo' }, + }, + secrets: { apiKey: '123' }, + logger, + services: actionsMock.createServices(), + }); + // @ts-ignore + connector.request = jest.fn().mockResolvedValue({ data: {} }); + // @ts-ignore + connector.request = mockRequest; + await connector.runApi( + { body: JSON.stringify({ messages: [] }) }, + new ConnectorUsageCollector({ logger, connectorId: 'test' }) + ); + const callArgs = mockRequest.mock.calls[0][0]; + expect(callArgs.headers['OpenAI-Organization']).toBe('org-id'); + expect(callArgs.headers['OpenAI-Project']).toBe('proj-id'); + }); + }); + + describe('Enhanced error handling', () => { + const connector = new OpenAIConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: OPENAI_CONNECTOR_ID }, + config: { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: OpenAiProviderType.OpenAi, + defaultModel: DEFAULT_OPENAI_MODEL, + headers: {}, + }, + secrets: { apiKey: '123' }, + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + }); + it('returns Azure function error message', () => { + const err = { message: '404 Unrecognized request argument supplied: functions' }; + // @ts-ignore + expect(connector.getResponseErrorMessage(err)).toContain( + 'Function support with Azure OpenAI API was added' + ); + }); + it('returns LM Studio error message', () => { + const err = { + response: { + status: 400, + statusText: 'Bad Request', + data: { error: 'LM Studio error' }, + }, + }; + // @ts-ignore + expect(connector.getResponseErrorMessage(err)).toContain('LM Studio error'); + }); + it('returns Unauthorized error message', () => { + const err = { + response: { + status: 401, + statusText: 'Unauthorized', + data: { error: { message: 'Invalid key' } }, + }, + }; + // @ts-ignore + expect(connector.getResponseErrorMessage(err)).toContain('Unauthorized API Error'); + }); + }); }); function createStreamMock() { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.ts index ad3cdc56ca41..3ac468d24644 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/openai/openai.ts @@ -5,12 +5,13 @@ * 2.0. */ -import type { ServiceParams } from '@kbn/actions-plugin/server'; +import type { ServiceParams, SSLSettings } from '@kbn/actions-plugin/server'; import { SubActionConnector } from '@kbn/actions-plugin/server'; import type { AxiosError } from 'axios'; import OpenAI from 'openai'; import { PassThrough } from 'stream'; -import type { IncomingMessage } from 'http'; +import type { Agent as HttpsAgent } from 'https'; +import type { Agent as HttpAgent, IncomingMessage } from 'http'; import type { ChatCompletionChunk, ChatCompletionCreateParamsStreaming, @@ -55,13 +56,15 @@ import { pipeStreamingResponse, sanitizeRequest, } from './lib/utils'; +import { getPKISSLOverrides, pkiErrorHandler } from './lib/other_openai_utils'; export class OpenAIConnector extends SubActionConnector { - private url; - private provider; - private key; - private openAI; - private headers; + private url: string; + private provider: OpenAiProviderType; + private key: string; + private openAI: OpenAI; + private headers: Record; + private sslOverrides?: SSLSettings; constructor(params: ServiceParams) { super(params); @@ -77,36 +80,88 @@ export class OpenAIConnector extends SubActionConnector { ...('projectId' in this.config ? { 'OpenAI-Project': this.config.projectId } : {}), }; - const { httpAgent, httpsAgent } = getCustomAgents( - this.configurationUtilities, - this.logger, - this.url - ); + try { + let httpAgent; + let baseURL = this.config.apiUrl; + const defaultHeaders = { ...this.headers }; + let defaultQuery: Record | undefined; - this.openAI = - this.config.apiProvider === OpenAiProviderType.AzureAi - ? new OpenAI({ - apiKey: this.key, - baseURL: this.config.apiUrl, - defaultQuery: { 'api-version': getAzureApiVersionParameter(this.config.apiUrl) }, - defaultHeaders: { - ...this.headers, - 'api-key': this.key, - }, - httpAgent: httpsAgent ?? httpAgent, - }) - : new OpenAI({ - baseURL: removeEndpointFromUrl(this.config.apiUrl), - apiKey: this.key, - defaultHeaders: { - ...this.headers, - }, - httpAgent: httpsAgent ?? httpAgent, - }); + if ( + this.provider === OpenAiProviderType.Other && + (('certificateData' in this.secrets && this.secrets.certificateData) || + ('caData' in this.secrets && this.secrets.caData) || + ('privateKeyData' in this.secrets && this.secrets.privateKeyData)) && + 'verificationMode' in this.config && + this.config.verificationMode + ) { + this.sslOverrides = getPKISSLOverrides({ + logger: this.logger, + // ! These two values must be defined for PKI use. If undefined, will throw error + certificateData: this.secrets.certificateData!, + privateKeyData: this.secrets.privateKeyData!, + caData: this.secrets.caData, + verificationMode: this.config.verificationMode, + }); + const agents = getCustomAgents( + this.configurationUtilities, + this.logger, + this.url, + this.sslOverrides + ); + httpAgent = agents.httpsAgent; + baseURL = removeEndpointFromUrl(this.url); + } else { + const agents = getCustomAgents(this.configurationUtilities, this.logger, this.url); + httpAgent = agents.httpsAgent ?? agents.httpAgent; + if (this.config.apiProvider === OpenAiProviderType.AzureAi) { + baseURL = this.config.apiUrl; + defaultHeaders['api-key'] = this.key; + const apiVersion = getAzureApiVersionParameter(this.config.apiUrl); + if (apiVersion) { + defaultQuery = { 'api-version': apiVersion }; + } + } else { + baseURL = removeEndpointFromUrl(this.config.apiUrl); + } + } + + this.openAI = this.createOpenAIClient({ + apiKey: this.key, + baseURL, + defaultHeaders, + httpAgent, + defaultQuery, + }); + } catch (error) { + this.logger.error(`Error initializing OpenAI client: ${error.message}`); + throw error; + } this.registerSubActions(); } + private createOpenAIClient({ + apiKey, + baseURL, + defaultHeaders, + httpAgent, + defaultQuery, + }: { + apiKey: string; + baseURL: string; + defaultHeaders: Record; + httpAgent?: HttpsAgent | HttpAgent; + defaultQuery?: Record; + }): OpenAI { + return new OpenAI({ + apiKey, + baseURL, + defaultHeaders, + httpAgent, + defaultQuery, + }); + } + private registerSubActions() { this.registerSubAction({ name: SUB_ACTION.RUN, @@ -169,11 +224,11 @@ export class OpenAIConnector extends SubActionConnector { } return `API Error: ${error.response?.statusText}${errorMessage ? ` - ${errorMessage}` : ''}`; } + /** * responsible for making a POST request to the external API endpoint and returning the response data * @param body The stringified request body to be sent in the POST request. */ - public async runApi( { body, signal, timeout }: RunActionParams, connectorUsageCollector: ConnectorUsageCollector @@ -185,24 +240,36 @@ export class OpenAIConnector extends SubActionConnector { ...('defaultModel' in this.config ? [this.config.defaultModel] : []) ); const axiosOptions = getAxiosOptions(this.provider, this.key, false); - const response = await this.request( - { - url: this.url, - method: 'post', - responseSchema: RunActionResponseSchema, - data: sanitizedBody, - signal, - // give up to 2 minutes for response - timeout: timeout ?? DEFAULT_TIMEOUT_MS, - ...axiosOptions, - headers: { - ...this.headers, - ...axiosOptions.headers, + + try { + const response = await this.request( + { + url: this.url, + method: 'post', + responseSchema: RunActionResponseSchema, + data: sanitizedBody, + signal, + // give up to 2 minutes for response + timeout: timeout ?? DEFAULT_TIMEOUT_MS, + ...axiosOptions, + headers: { + ...this.headers, + ...axiosOptions.headers, + }, + sslOverrides: this.sslOverrides, }, - }, - connectorUsageCollector - ); - return response.data; + connectorUsageCollector + ); + + return response.data; + } catch (error) { + // special error handling for PKI errors + const errorMessage = pkiErrorHandler(error); + if (errorMessage?.length) { + throw new Error(errorMessage); + } + throw error; + } } /** @@ -226,24 +293,34 @@ export class OpenAIConnector extends SubActionConnector { ); const axiosOptions = getAxiosOptions(this.provider, this.key, stream); - - const response = await this.request( - { - url: this.url, - method: 'post', - responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema, - data: executeBody, - signal, - ...axiosOptions, - headers: { - ...this.headers, - ...axiosOptions.headers, + try { + const response = await this.request( + { + url: this.url, + method: 'post', + responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema, + data: executeBody, + signal, + ...axiosOptions, + headers: { + ...this.headers, + ...axiosOptions.headers, + }, + timeout, + sslOverrides: this.sslOverrides, }, - timeout, - }, - connectorUsageCollector - ); - return stream ? pipeStreamingResponse(response) : response.data; + connectorUsageCollector + ); + + return stream ? pipeStreamingResponse(response) : response.data; + } catch (error) { + // special error handling for PKI errors + const errorMessage = pkiErrorHandler(error); + if (errorMessage?.length) { + throw new Error(errorMessage); + } + throw error; + } } /** diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts index 656d102f2717..47fd95b8efca 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/openai.ts @@ -216,6 +216,174 @@ export default function genAiTest({ getService }: FtrProviderContext) { }); }); }); + + it('should return error when creating the connector with PKI auth that has missing crt', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + secrets: { + privateKeyData: Buffer.from( + '-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----' + ).toString('base64'), + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: Certificate data must be provided for PKI', + }); + }); + }); + + it('should return error when creating the connector with PKI auth that has missing key', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + secrets: { + certificateData: Buffer.from( + '-----BEGIN CERTIFICATE-----cert-----END CERTIFICATE-----' + ).toString('base64'), + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: Private key data must be provided for PKI', + }); + }); + }); + it('should return error when creating the connector with PKI auth that has bad crt', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + secrets: { + certificateData: Buffer.from('invalid crt format').toString('base64'), + privateKeyData: Buffer.from( + '-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----' + ).toString('base64'), + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: Invalid Certificate data file format: The file must be a PEM-encoded certificate beginning with "-----BEGIN CERTIFICATE-----".', + }); + }); + }); + it('should return error when creating the connector with PKI auth that has bad key', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + secrets: { + certificateData: Buffer.from( + '-----BEGIN CERTIFICATE-----cert-----END CERTIFICATE-----' + ).toString('base64'), + privateKeyData: Buffer.from('invalid key format').toString('base64'), + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: Invalid Private key data file format: The file must be a PEM-encoded private key beginning with "-----BEGIN PRIVATE KEY-----" or "-----BEGIN RSA PRIVATE KEY-----".', + }); + }); + }); + + it('should create the connector with PKI auth when both certificate and key are valid', async () => { + const validCert = Buffer.from( + '-----BEGIN CERTIFICATE-----\nMIIC+zCCAeOgAwIBAgIJAKKpPKItestcert\n-----END CERTIFICATE-----' + ).toString('base64'); + const validKey = Buffer.from( + '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----' + ).toString('base64'); + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `${name} PKI`, + connector_type_id: connectorTypeId, + config: { + ...config, + apiProvider: 'Other', + defaultModel: 'gpt-3.5-turbo', + }, + secrets: { + certificateData: validCert, + privateKeyData: validKey, + }, + }) + .expect(200); + expect(createdAction).to.have.property('id'); + expect(createdAction.config.apiProvider).to.equal('Other'); + }); + + it('should execute successfully with valid PKI certificate and key', async () => { + const validCert = Buffer.from( + '-----BEGIN CERTIFICATE-----\nMIIC+zCCAeOgAwIBAgIJAKKpPKItestcert\n-----END CERTIFICATE-----' + ).toString('base64'); + const validKey = Buffer.from( + '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----' + ).toString('base64'); + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `${name} PKI exec`, + connector_type_id: connectorTypeId, + config: { + ...config, + apiProvider: 'Other', + defaultModel: 'gpt-3.5-turbo', + }, + secrets: { + certificateData: validCert, + privateKeyData: validKey, + }, + }) + .expect(200); + const { body } = await supertest + .post(`/api/actions/connector/${createdAction.id}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'test', + subActionParams: { + body: '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello world"}]}', + }, + }, + }) + .expect(200); + expect(body.status).to.equal('ok'); + expect(body.connector_id).to.equal(createdAction.id); + }); }); describe('executor', () => {