diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.mock.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.mock.ts index 71d0a2f4466a4..4b38f6fcb53e6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.mock.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.mock.ts @@ -14,6 +14,7 @@ const createConnectorTokenClientMock = () => { get: jest.fn(), update: jest.fn(), deleteConnectorTokens: jest.fn(), + updateOrReplace: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts index e3e9f3b362ae9..54765b9e01b8f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts @@ -357,3 +357,144 @@ describe('delete()', () => { `); }); }); + +describe('updateOrReplace()', () => { + test('creates new SO if current token is null', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt: new Date().toISOString(), + }, + references: [], + }); + await connectorTokenClient.updateOrReplace({ + connectorId: '1', + token: null, + newToken: 'newToken', + expiresInSec: 1000, + deleteExisting: false, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( + 'newToken' + ); + + expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.delete).not.toHaveBeenCalled(); + }); + + test('creates new SO and deletes all existing tokens for connector if current token is null and deleteExisting is true', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + { + id: '2', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }); + await connectorTokenClient.updateOrReplace({ + connectorId: '1', + token: null, + newToken: 'newToken', + expiresInSec: 1000, + deleteExisting: true, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( + 'newToken' + ); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); + }); + + test('updates existing SO if current token exists', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ + errors: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt: new Date().toISOString(), + }, + references: [], + }); + await connectorTokenClient.updateOrReplace({ + connectorId: '1', + token: { + id: '3', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + newToken: 'newToken', + expiresInSec: 1000, + deleteExisting: true, + }); + + expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.delete).not.toHaveBeenCalled(); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.checkConflicts).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( + 'newToken' + ); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts index 949ec855ee3f0..6ce91fad94546 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts @@ -33,6 +33,13 @@ export interface UpdateOptions { tokenType?: string; } +interface UpdateOrReplaceOptions { + connectorId: string; + token: ConnectorToken | null; + newToken: string; + expiresInSec: number; + deleteExisting: boolean; +} export class ConnectorTokenClient { private readonly logger: Logger; private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; @@ -245,4 +252,36 @@ export class ConnectorTokenClient { throw err; } } + + public async updateOrReplace({ + connectorId, + token, + newToken, + expiresInSec, + deleteExisting, + }: UpdateOrReplaceOptions) { + expiresInSec = expiresInSec ?? 3600; + if (token === null) { + if (deleteExisting) { + await this.deleteConnectorTokens({ + connectorId, + tokenType: 'access_token', + }); + } + + await this.create({ + connectorId, + token: newToken, + expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(), + tokenType: 'access_token', + }); + } else { + await this.update({ + id: token.id!.toString(), + token: newToken, + expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(), + tokenType: 'access_token', + }); + } + } } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts index d68cc9a5e4398..d679564c82472 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts @@ -17,6 +17,8 @@ import { createJWTAssertion } from './create_jwt_assertion'; const jwtSign = jwt.sign as jest.Mock; const mockLogger = loggingSystemMock.create().get() as jest.Mocked; +Date.now = jest.fn(() => 0); + describe('createJWTAssertion', () => { test('creating a JWT token from provided claims with default values', () => { jwtSign.mockReturnValueOnce('123456qwertyjwttoken'); @@ -27,6 +29,28 @@ describe('createJWTAssertion', () => { subject: 'test@gmail.com', }); + expect(jwtSign).toHaveBeenCalledWith( + { aud: '1', exp: 3600, iat: 0, iss: 'someappid', sub: 'test@gmail.com' }, + { key: 'test', passphrase: '123456' }, + { algorithm: 'RS256' } + ); + expect(assertion).toMatchInlineSnapshot('"123456qwertyjwttoken"'); + }); + + test('creating a JWT token when private key password is null', () => { + jwtSign.mockReturnValueOnce('123456qwertyjwttoken'); + + const assertion = createJWTAssertion(mockLogger, 'test', null, { + audience: '1', + issuer: 'someappid', + subject: 'test@gmail.com', + }); + + expect(jwtSign).toHaveBeenCalledWith( + { aud: '1', exp: 3600, iat: 0, iss: 'someappid', sub: 'test@gmail.com' }, + 'test', + { algorithm: 'RS256' } + ); expect(assertion).toMatchInlineSnapshot('"123456qwertyjwttoken"'); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts index f4723e59b418c..b33a2d17ed9d8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts @@ -12,18 +12,18 @@ export interface JWTClaims { audience: string; subject: string; issuer: string; - expireInMilisecons?: number; + expireInMilliseconds?: number; keyId?: string; } export function createJWTAssertion( logger: Logger, privateKey: string, - privateKeyPassword: string, + privateKeyPassword: string | null, reservedClaims: JWTClaims, customClaims?: Record ): string { - const { subject, audience, issuer, expireInMilisecons, keyId } = reservedClaims; + const { subject, audience, issuer, expireInMilliseconds, keyId } = reservedClaims; const iat = Math.floor(Date.now() / 1000); const headerObj = { algorithm: 'RS256' as Algorithm, ...(keyId ? { keyid: keyId } : {}) }; @@ -33,17 +33,19 @@ export function createJWTAssertion( aud: audience, // audience claim identifies the recipients that the JWT is intended for iss: issuer, // issuer claim identifies the principal that issued the JWT iat, // issued at claim identifies the time at which the JWT was issued - exp: iat + (expireInMilisecons ?? 3600), // expiration time claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing + exp: iat + (expireInMilliseconds ?? 3600), // expiration time claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing ...(customClaims ?? {}), }; try { const jwtToken = jwt.sign( - JSON.stringify(payloadObj), - { - key: privateKey, - passphrase: privateKeyPassword, - }, + payloadObj, + privateKeyPassword + ? { + key: privateKey, + passphrase: privateKeyPassword, + } + : privateKey, headerObj ); return jwtToken; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 8c36113032461..1d1c2c46cb0e4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -601,7 +601,7 @@ describe('send_email module', () => { await sendEmail(mockLogger, sendEmailOptions, connectorTokenClientM); expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); - expect(connectorTokenClientM.deleteConnectorTokens.mock.calls.length).toBe(1); + expect(connectorTokenClientM.updateOrReplace.mock.calls.length).toBe(1); delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; sendEmailGraphApiMock.mock.calls[0].pop(); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index a664b22f9df34..983846adc71e0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -105,30 +105,13 @@ async function sendEmailWithExchange( // try to update connector_token SO try { - if (connectorToken === null) { - if (hasErrors) { - // delete existing access tokens - await connectorTokenClient.deleteConnectorTokens({ - connectorId, - tokenType: 'access_token', - }); - } - await connectorTokenClient.create({ - connectorId, - token: accessToken, - // convert MS Exchange expiresIn from seconds to milliseconds - expiresAtMillis: new Date(Date.now() + tokenResult.expiresIn * 1000).toISOString(), - tokenType: 'access_token', - }); - } else { - await connectorTokenClient.update({ - id: connectorToken.id!.toString(), - token: accessToken, - // convert MS Exchange expiresIn from seconds to milliseconds - expiresAtMillis: new Date(Date.now() + tokenResult.expiresIn * 1000).toISOString(), - tokenType: 'access_token', - }); - } + await connectorTokenClient.updateOrReplace({ + connectorId, + token: connectorToken, + newToken: accessToken, + expiresInSec: tokenResult.expiresIn, + deleteExisting: hasErrors, + }); } catch (err) { logger.warn( `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/create_service_wrapper.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/create_service_wrapper.test.ts new file mode 100644 index 0000000000000..37ada260c75a9 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/create_service_wrapper.test.ts @@ -0,0 +1,89 @@ +/* + * 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 axios from 'axios'; +import { createServiceWrapper } from './create_service_wrapper'; +import { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; +import { snExternalServiceConfig } from './config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const connectorTokenClient = connectorTokenClientMock.create(); +const configurationUtilities = actionsConfigMock.create(); + +jest.mock('axios'); +axios.create = jest.fn(() => axios); + +describe('createServiceWrapper', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('creates axios instance with apiUrl', () => { + const createServiceFn = jest.fn(); + const credentials = { + config: { + apiUrl: 'https://test-sn.service-now.com', + }, + secrets: { + username: 'username', + password: 'password', + }, + }; + const serviceConfig = snExternalServiceConfig['.servicenow']; + createServiceWrapper({ + connectorId: '123', + credentials, + logger, + configurationUtilities, + serviceConfig, + connectorTokenClient, + createServiceFn, + }); + + expect(createServiceFn).toHaveBeenCalledWith({ + credentials, + logger, + configurationUtilities, + serviceConfig, + axiosInstance: axios, + }); + }); + + test('handles apiUrl with trailing slash', () => { + const createServiceFn = jest.fn(); + const credentials = { + config: { + apiUrl: 'https://test-sn.service-now.com/', + }, + secrets: { + username: 'username', + password: 'password', + }, + }; + const serviceConfig = snExternalServiceConfig['.servicenow']; + createServiceWrapper({ + connectorId: '123', + credentials, + logger, + configurationUtilities, + serviceConfig, + connectorTokenClient, + createServiceFn, + }); + + expect(createServiceFn).toHaveBeenCalledWith({ + credentials, + logger, + configurationUtilities, + serviceConfig, + axiosInstance: axios, + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/create_service_wrapper.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/create_service_wrapper.ts new file mode 100644 index 0000000000000..cd431027a720f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/create_service_wrapper.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/core/server'; +import { ExternalService, ExternalServiceCredentials, SNProductsConfigValue } from './types'; + +import { ServiceNowPublicConfigurationType, ServiceFactory } from './types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { getAxiosInstance } from './utils'; +import { ConnectorTokenClientContract } from '../../types'; + +interface CreateServiceWrapperOpts { + connectorId: string; + credentials: ExternalServiceCredentials; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + serviceConfig: SNProductsConfigValue; + connectorTokenClient: ConnectorTokenClientContract; + createServiceFn: ServiceFactory; +} + +export function createServiceWrapper({ + connectorId, + credentials, + logger, + configurationUtilities, + serviceConfig, + connectorTokenClient, + createServiceFn, +}: CreateServiceWrapperOpts): T { + const { config } = credentials; + const { apiUrl: url } = config as ServiceNowPublicConfigurationType; + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const axiosInstance = getAxiosInstance({ + connectorId, + logger, + configurationUtilities, + credentials, + snServiceUrl: urlWithoutTrailingSlash, + connectorTokenClient, + }); + + return createServiceFn({ + credentials, + logger, + configurationUtilities, + serviceConfig, + axiosInstance, + }); +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index e22c65a3694bb..17a3b1982f487 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -39,6 +39,7 @@ import { ExternalServiceApiITOM, ExternalServiceITOM, ServiceNowPublicConfigurationBaseType, + ExternalService, } from './types'; import { ServiceNowITOMActionTypeId, @@ -53,6 +54,7 @@ import { apiSIR } from './api_sir'; import { throwIfSubActionIsNotSupported } from './utils'; import { createExternalServiceITOM } from './service_itom'; import { apiITOM } from './api_itom'; +import { createServiceWrapper } from './create_service_wrapper'; export { ServiceNowITSMActionTypeId, @@ -97,6 +99,7 @@ export function getServiceNowITSMActionType( secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { validate: curry(validate.secrets)(configurationUtilities), }), + connector: validate.connector, params: ExecutorParamsSchemaITSM, }, executor: curry(executor)({ @@ -124,6 +127,7 @@ export function getServiceNowSIRActionType( secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { validate: curry(validate.secrets)(configurationUtilities), }), + connector: validate.connector, params: ExecutorParamsSchemaSIR, }, executor: curry(executor)({ @@ -151,6 +155,7 @@ export function getServiceNowITOMActionType( secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { validate: curry(validate.secrets)(configurationUtilities), }), + connector: validate.connector, params: ExecutorParamsSchemaITOM, }, executor: curry(executorITOM)({ @@ -184,20 +189,24 @@ async function executor( ExecutorParams > ): Promise> { - const { actionId, config, params, secrets } = execOptions; + const { actionId, config, params, secrets, services } = execOptions; const { subAction, subActionParams } = params; + const connectorTokenClient = services.connectorTokenClient; const externalServiceConfig = snExternalServiceConfig[actionTypeId]; let data: ServiceNowExecutorResultData | null = null; - const externalService = createService( - { + const externalService = createServiceWrapper({ + connectorId: actionId, + credentials: { config, secrets, }, logger, configurationUtilities, - externalServiceConfig - ); + serviceConfig: externalServiceConfig, + connectorTokenClient, + createServiceFn: createService, + }); const apiAsRecord = api as unknown as Record; throwIfSubActionIsNotSupported({ api: apiAsRecord, subAction, supportedSubActions, logger }); @@ -260,18 +269,22 @@ async function executorITOM( ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; + const connectorTokenClient = execOptions.services.connectorTokenClient; const externalServiceConfig = snExternalServiceConfig[actionTypeId]; let data: ServiceNowExecutorResultData | null = null; - const externalService = createService( - { + const externalService = createServiceWrapper({ + connectorId: actionId, + credentials: { config, secrets, }, logger, configurationUtilities, - externalServiceConfig - ) as ExternalServiceITOM; + serviceConfig: externalServiceConfig, + connectorTokenClient, + createServiceFn: createService, + }); const apiAsRecord = api as unknown as Record; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index e41eea24834c7..5f5ea6ab0ff93 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -10,6 +10,10 @@ import { DEFAULT_ALERTS_GROUPING_KEY } from './config'; export const ExternalIncidentServiceConfigurationBase = { apiUrl: schema.string(), + isOAuth: schema.boolean({ defaultValue: false }), + userIdentifierValue: schema.nullable(schema.string()), // required if isOAuth = true + clientId: schema.nullable(schema.string()), // required if isOAuth = true + jwtKeyId: schema.nullable(schema.string()), // required if isOAuth = true }; export const ExternalIncidentServiceConfiguration = { @@ -26,8 +30,11 @@ export const ExternalIncidentServiceConfigurationSchema = schema.object( ); export const ExternalIncidentServiceSecretConfiguration = { - password: schema.string(), - username: schema.string(), + password: schema.nullable(schema.string()), // required if isOAuth = false + username: schema.nullable(schema.string()), // required if isOAuth = false + clientSecret: schema.nullable(schema.string()), // required if isOAuth = true + privateKey: schema.nullable(schema.string()), // required if isOAuth = true + privateKeyPassword: schema.nullable(schema.string()), }; export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index c179277956aa9..68fa57e93f31b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -147,64 +147,240 @@ describe('ServiceNow service', () => { let service: ExternalService; beforeEach(() => { - service = createExternalService( - { + jest.clearAllMocks(); + service = createExternalService({ + credentials: { // The trailing slash at the end of the url is intended. // All API calls need to have the trailing slash removed. - config: { apiUrl: 'https://example.com/' }, + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - snExternalServiceConfig['.servicenow'] - ); - }); - - beforeEach(() => { - jest.clearAllMocks(); + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }); }); describe('createExternalService', () => { test('throws without url', () => { expect(() => - createExternalService( - { - config: { apiUrl: null }, + createExternalService({ + credentials: { + config: { apiUrl: null, isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - snExternalServiceConfig['.servicenow'] - ) + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }) ).toThrow(); }); - test('throws without username', () => { - expect(() => - createExternalService( - { - config: { apiUrl: 'test.com' }, - secrets: { username: '', password: 'admin' }, - }, - logger, - configurationUtilities, - snExternalServiceConfig['.servicenow'] - ) - ).toThrow(); + test('throws when isOAuth is false and basic auth required values are falsy', () => { + const badBasicCredentials = [ + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: '', password: 'admin' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: null, password: 'admin' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { password: 'admin' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: 'admin', password: '' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: 'admin', password: null }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: 'admin' }, + }, + ]; + + badBasicCredentials.forEach((badCredentials) => { + expect(() => + createExternalService({ + credentials: badCredentials, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }) + ).toThrow(); + }); }); - test('throws without password', () => { - expect(() => - createExternalService( - { - config: { apiUrl: 'test.com' }, - secrets: { username: '', password: undefined }, + test('throws when isOAuth is true and OAuth required values are falsy', () => { + const badOAuthCredentials = [ + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: '', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', }, - logger, - configurationUtilities, - snExternalServiceConfig['.servicenow'] - ) - ).toThrow(); + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: null, + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: '', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: null, + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: '', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: null, + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: '', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: null, privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: '' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: null }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret' }, + }, + ]; + + badOAuthCredentials.forEach((badCredentials) => { + expect(() => + createExternalService({ + credentials: badCredentials, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }) + ).toThrow(); + }); }); }); @@ -233,15 +409,16 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, + axiosInstance: axios, + }); requestMock.mockImplementation(() => ({ data: { result: { sys_id: '1', number: 'INC01' } }, @@ -298,15 +475,16 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - snExternalServiceConfig['.servicenow-sir'] - ); + serviceConfig: snExternalServiceConfig['.servicenow-sir'], + axiosInstance: axios, + }); const res = await createIncident(service); @@ -382,15 +560,16 @@ describe('ServiceNow service', () => { // old connectors describe('table API', () => { beforeEach(() => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, + axiosInstance: axios, + }); }); test('it creates the incident correctly', async () => { @@ -418,15 +597,16 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, + axiosInstance: axios, + }); mockIncidentResponse(false); @@ -468,15 +648,16 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - snExternalServiceConfig['.servicenow-sir'] - ); + serviceConfig: snExternalServiceConfig['.servicenow-sir'], + axiosInstance: axios, + }); const res = await updateIncident(service); expect(requestMock).toHaveBeenNthCalledWith(1, { @@ -554,15 +735,16 @@ describe('ServiceNow service', () => { // old connectors describe('table API', () => { beforeEach(() => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, + axiosInstance: axios, + }); }); test('it updates the incident correctly', async () => { @@ -591,15 +773,16 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, + axiosInstance: axios, + }); mockIncidentResponse(false); @@ -646,15 +829,16 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, + axiosInstance: axios, + }); requestMock.mockImplementation(() => ({ data: { result: serviceNowCommonFields }, @@ -714,15 +898,16 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, + axiosInstance: axios, + }); requestMock.mockImplementation(() => ({ data: { result: serviceNowChoices }, @@ -818,15 +1003,16 @@ describe('ServiceNow service', () => { }); test('it does not log if useOldApi = true', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } - ); + serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, + axiosInstance: axios, + }); await service.checkIfApplicationIsInstalled(); expect(requestMock).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 8e606ca5f8ef7..5a1b1f604cb81 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -5,11 +5,9 @@ * 2.0. */ -import axios, { AxiosResponse } from 'axios'; +import { AxiosResponse } from 'axios'; -import { Logger } from '@kbn/core/server'; import { - ExternalServiceCredentials, ExternalService, ExternalServiceParamsCreate, ExternalServiceParamsUpdate, @@ -17,29 +15,41 @@ import { ImportSetApiResponseError, ServiceNowIncident, GetApplicationInfoResponse, - SNProductsConfigValue, ServiceFactory, } from './types'; import * as i18n from './translations'; import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; import { request } from '../lib/axios_utils'; -import { ActionsConfigurationUtilities } from '../../actions_config'; import { createServiceError, getPushedDate, prepareIncident } from './utils'; export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; -export const createExternalService: ServiceFactory = ( - { config, secrets }: ExternalServiceCredentials, - logger: Logger, - configurationUtilities: ActionsConfigurationUtilities, - { table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue -): ExternalService => { - const { apiUrl: url, usesTableApi: usesTableApiConfigValue } = - config as ServiceNowPublicConfigurationType; - const { username, password } = secrets as ServiceNowSecretConfigurationType; - - if (!url || !username || !password) { +export const createExternalService: ServiceFactory = ({ + credentials, + logger, + configurationUtilities, + serviceConfig, + axiosInstance, +}): ExternalService => { + const { config, secrets } = credentials; + const { table, importSetTable, useImportAPI, appScope } = serviceConfig; + const { + apiUrl: url, + usesTableApi: usesTableApiConfigValue, + isOAuth, + clientId, + jwtKeyId, + userIdentifierValue, + } = config as ServiceNowPublicConfigurationType; + const { username, password, clientSecret, privateKey } = + secrets as ServiceNowSecretConfigurationType; + + if ( + !url || + (!isOAuth && (!username || !password)) || + (isOAuth && (!clientSecret || !privateKey || !clientId || !jwtKeyId || !userIdentifierValue)) + ) { throw Error(`[Action]${i18n.SERVICENOW}: Wrong configuration.`); } @@ -54,10 +64,6 @@ export const createExternalService: ServiceFactory = ( */ const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`; - const axiosInstance = axios.create({ - auth: { username, password }, - }); - const useTableApi = !useImportAPI || usesTableApiConfigValue; const getCreateIncidentUrl = () => (useTableApi ? tableApiIncidentUrl : importSetTableUrl); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts index 57e7a7506171a..855cff79d608e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts @@ -35,15 +35,16 @@ describe('ServiceNow SIR service', () => { let service: ExternalServiceITOM; beforeEach(() => { - service = createExternalServiceITOM( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalServiceITOM({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - snExternalServiceConfig['.servicenow-itom'] - ) as ExternalServiceITOM; + serviceConfig: snExternalServiceConfig['.servicenow-itom'], + axiosInstance: axios, + }) as ExternalServiceITOM; }); beforeEach(() => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts index 65c9c70545acc..3e33564fe7364 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts @@ -4,41 +4,28 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import axios from 'axios'; -import { Logger } from '@kbn/core/server'; -import { - ExternalServiceCredentials, - SNProductsConfigValue, - ServiceFactory, - ExternalServiceITOM, - ExecutorSubActionAddEventParams, -} from './types'; +import { ServiceFactory, ExternalServiceITOM, ExecutorSubActionAddEventParams } from './types'; -import { ServiceNowSecretConfigurationType } from './types'; import { request } from '../lib/axios_utils'; -import { ActionsConfigurationUtilities } from '../../actions_config'; import { createExternalService } from './service'; import { createServiceError } from './utils'; const getAddEventURL = (url: string) => `${url}/api/global/em/jsonv2`; -export const createExternalServiceITOM: ServiceFactory = ( - credentials: ExternalServiceCredentials, - logger: Logger, - configurationUtilities: ActionsConfigurationUtilities, - serviceConfig: SNProductsConfigValue -): ExternalServiceITOM => { - const snService = createExternalService( +export const createExternalServiceITOM: ServiceFactory = ({ + credentials, + logger, + configurationUtilities, + serviceConfig, + axiosInstance, +}): ExternalServiceITOM => { + const snService = createExternalService({ credentials, logger, configurationUtilities, - serviceConfig - ); - - const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; - const axiosInstance = axios.create({ - auth: { username, password }, + serviceConfig, + axiosInstance, }); const addEvent = async (params: ExecutorSubActionAddEventParams) => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts index cbd47fb2552c7..59c71ac6887c8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts @@ -92,15 +92,16 @@ describe('ServiceNow SIR service', () => { let service: ExternalServiceSIR; beforeEach(() => { - service = createExternalServiceSIR( - { - config: { apiUrl: 'https://example.com/' }, + service = createExternalServiceSIR({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - snExternalServiceConfig['.servicenow-sir'] - ) as ExternalServiceSIR; + serviceConfig: snExternalServiceConfig['.servicenow-sir'], + axiosInstance: axios, + }) as ExternalServiceSIR; }); beforeEach(() => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts index 41c02a57643e3..0ecd838c93bd2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -5,21 +5,9 @@ * 2.0. */ -import axios from 'axios'; +import { Observable, ExternalServiceSIR, ObservableResponse, ServiceFactory } from './types'; -import { Logger } from '@kbn/core/server'; -import { - ExternalServiceCredentials, - SNProductsConfigValue, - Observable, - ExternalServiceSIR, - ObservableResponse, - ServiceFactory, -} from './types'; - -import { ServiceNowSecretConfigurationType } from './types'; import { request } from '../lib/axios_utils'; -import { ActionsConfigurationUtilities } from '../../actions_config'; import { createExternalService } from './service'; import { createServiceError } from './utils'; @@ -29,22 +17,19 @@ const getAddObservableToIncidentURL = (url: string, incidentID: string) => const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; -export const createExternalServiceSIR: ServiceFactory = ( - credentials: ExternalServiceCredentials, - logger: Logger, - configurationUtilities: ActionsConfigurationUtilities, - serviceConfig: SNProductsConfigValue -): ExternalServiceSIR => { - const snService = createExternalService( +export const createExternalServiceSIR: ServiceFactory = ({ + credentials, + logger, + configurationUtilities, + serviceConfig, + axiosInstance, +}): ExternalServiceSIR => { + const snService = createExternalService({ credentials, logger, configurationUtilities, - serviceConfig - ); - - const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; - const axiosInstance = axios.create({ - auth: { username, password }, + serviceConfig, + axiosInstance, }); const _addObservable = async (data: Observable | Observable[], url: string) => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 8b2bb9423d012..b007e57773989 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -30,3 +30,42 @@ export const ALLOWED_HOSTS_ERROR = (message: string) => message, }, }); + +export const CREDENTIALS_ERROR = i18n.translate( + 'xpack.actions.builtin.configuration.apiCredentialsError', + { + defaultMessage: 'Either basic auth or OAuth credentials must be specified', + } +); + +export const BASIC_AUTH_CREDENTIALS_ERROR = i18n.translate( + 'xpack.actions.builtin.configuration.apiBasicAuthCredentialsError', + { + defaultMessage: 'username and password must both be specified', + } +); + +export const OAUTH_CREDENTIALS_ERROR = i18n.translate( + 'xpack.actions.builtin.configuration.apiOAuthCredentialsError', + { + defaultMessage: 'clientSecret and privateKey must both be specified', + } +); + +export const VALIDATE_OAUTH_MISSING_FIELD_ERROR = (field: string, isOAuth: boolean) => + i18n.translate('xpack.actions.builtin.configuration.apiValidateMissingOAuthFieldError', { + defaultMessage: '{field} must be provided when isOAuth = {isOAuth}', + values: { + field, + isOAuth: isOAuth ? 'true' : 'false', + }, + }); + +export const VALIDATE_OAUTH_POPULATED_FIELD_ERROR = (field: string, isOAuth: boolean) => + i18n.translate('xpack.actions.builtin.configuration.apiValidateOAuthFieldError', { + defaultMessage: '{field} should not be provided with isOAuth = {isOAuth}', + values: { + field, + isOAuth: isOAuth ? 'true' : 'false', + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 4475832e1a7f7..ff3a92e935818 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { AxiosError, AxiosResponse } from 'axios'; +import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { TypeOf } from '@kbn/config-schema'; import { Logger } from '@kbn/core/server'; import { @@ -78,6 +78,7 @@ export interface ExternalServiceCredentials { export interface ExternalServiceValidation { config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void; secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void; + connector: (config: any, secrets: any) => string | null; } export interface ExternalServiceIncidentResponse { @@ -277,12 +278,21 @@ export interface ExternalServiceSIR extends ExternalService { ) => Promise; } -export type ServiceFactory = ( - credentials: ExternalServiceCredentials, - logger: Logger, - configurationUtilities: ActionsConfigurationUtilities, - serviceConfig: SNProductsConfigValue -) => T; +interface ServiceFactoryOpts { + credentials: ExternalServiceCredentials; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + serviceConfig: SNProductsConfigValue; + axiosInstance: AxiosInstance; +} + +export type ServiceFactory = ({ + credentials, + logger, + configurationUtilities, + serviceConfig, + axiosInstance, +}: ServiceFactoryOpts) => T; /** * ITOM diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts index 7d66949d4473f..dae4e59728a0c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AxiosError } from 'axios'; +import axios, { AxiosError } from 'axios'; import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -14,10 +14,35 @@ import { createServiceError, getPushedDate, throwIfSubActionIsNotSupported, + getAccessToken, + getAxiosInstance, } from './utils'; +import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { createJWTAssertion } from '../lib/create_jwt_assertion'; +import { requestOAuthJWTToken } from '../lib/request_oauth_jwt_token'; -const logger = loggingSystemMock.create().get() as jest.Mocked; +jest.mock('../lib/create_jwt_assertion', () => ({ + createJWTAssertion: jest.fn(), +})); +jest.mock('../lib/request_oauth_jwt_token', () => ({ + requestOAuthJWTToken: jest.fn(), +})); + +jest.mock('axios', () => ({ + create: jest.fn(), +})); +const createAxiosInstanceMock = axios.create as jest.Mock; +const axiosInstanceMock = { + interceptors: { + request: { eject: jest.fn(), use: jest.fn() }, + response: { eject: jest.fn(), use: jest.fn() }, + }, +}; +const connectorTokenClient = connectorTokenClientMock.create(); +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); /** * The purpose of this test is to * prevent developers from accidentally @@ -131,4 +156,285 @@ describe('utils', () => { ).not.toThrow(); }); }); + + describe('getAxiosInstance', () => { + beforeEach(() => { + jest.clearAllMocks(); + createAxiosInstanceMock.mockReturnValue(axiosInstanceMock); + }); + + test('creates axios instance with basic auth when isOAuth is false and username and password are defined', () => { + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, + }, + secrets: { + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + username: 'username', + password: 'password', + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, + }); + + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith({ + auth: { password: 'password', username: 'username' }, + }); + }); + + test('creates axios instance with interceptor when isOAuth is true and OAuth fields are defined', () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10000000000).toISOString(), + }, + }); + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + username: null, + password: null, + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, + }); + + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAccessToken', () => { + const getAccessTokenOpts = { + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: 'privateKeyPassword', + username: null, + password: null, + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, + }; + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + test('uses stored access token if it exists', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10000000000).toISOString(), + }, + }); + const accessToken = await getAccessToken(getAccessTokenOpts); + + expect(accessToken).toEqual('testtokenvalue'); + expect(createJWTAssertion as jest.Mock).not.toHaveBeenCalled(); + expect(requestOAuthJWTToken as jest.Mock).not.toHaveBeenCalled(); + }); + + test('creates new assertion if stored access token does not exist', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getAccessToken(getAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: null, + newToken: 'access_token brandnewaccesstoken', + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('creates new assertion if stored access token exists but is expired', async () => { + const createdAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() - 100).toISOString(); + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getAccessToken(getAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + newToken: 'access_token brandnewaccesstoken', + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('throws error if createJWTAssertion throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockImplementationOnce(() => { + throw new Error('createJWTAssertion error!!'); + }); + + await expect(getAccessToken(getAccessTokenOpts)).rejects.toThrowErrorMatchingInlineSnapshot( + `"createJWTAssertion error!!"` + ); + }); + + test('throws error if requestOAuthJWTToken throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockRejectedValueOnce( + new Error('requestOAuthJWTToken error!!') + ); + + await expect(getAccessToken(getAccessTokenOpts)).rejects.toThrowErrorMatchingInlineSnapshot( + `"requestOAuthJWTToken error!!"` + ); + }); + + test('logs warning if connectorTokenClient.updateOrReplace throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + connectorTokenClient.updateOrReplace.mockRejectedValueOnce( + new Error('updateOrReplace error') + ); + + const accessToken = await getAccessToken(getAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(logger.warn).toHaveBeenCalledWith( + `Not able to update ServiceNow connector token for connectorId: 123 due to error: updateOrReplace error` + ); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index f18d09cdaedb9..84d6741398bce 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -5,11 +5,24 @@ * 2.0. */ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { Logger } from '@kbn/core/server'; -import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types'; +import { + ExternalServiceCredentials, + Incident, + PartialIncident, + ResponseError, + ServiceNowError, + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, +} from './types'; import { FIELD_PREFIX } from './config'; import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; import * as i18n from './translations'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ConnectorTokenClientContract } from '../../types'; +import { createJWTAssertion } from '../lib/create_jwt_assertion'; +import { requestOAuthJWTToken } from '../lib/request_oauth_jwt_token'; export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => useOldApi @@ -69,3 +82,129 @@ export const throwIfSubActionIsNotSupported = ({ throw new Error(errorMessage); } }; + +export interface GetAccessTokenAndAxiosInstanceOpts { + connectorId: string; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + credentials: ExternalServiceCredentials; + snServiceUrl: string; + connectorTokenClient: ConnectorTokenClientContract; +} + +export const getAxiosInstance = ({ + connectorId, + logger, + configurationUtilities, + credentials, + snServiceUrl, + connectorTokenClient, +}: GetAccessTokenAndAxiosInstanceOpts): AxiosInstance => { + const { config, secrets } = credentials; + const { isOAuth } = config as ServiceNowPublicConfigurationType; + const { username, password } = secrets as ServiceNowSecretConfigurationType; + + let axiosInstance; + + if (!isOAuth && username && password) { + axiosInstance = axios.create({ + auth: { username, password }, + }); + } else { + axiosInstance = axios.create(); + axiosInstance.interceptors.request.use( + async (axiosConfig: AxiosRequestConfig) => { + const accessToken = await getAccessToken({ + connectorId, + logger, + configurationUtilities, + credentials: { + config: config as ServiceNowPublicConfigurationType, + secrets, + }, + snServiceUrl, + connectorTokenClient, + }); + axiosConfig.headers.Authorization = accessToken; + return axiosConfig; + }, + (error) => { + Promise.reject(error); + } + ); + } + + return axiosInstance; +}; + +export const getAccessToken = async ({ + connectorId, + logger, + configurationUtilities, + credentials, + snServiceUrl, + connectorTokenClient, +}: GetAccessTokenAndAxiosInstanceOpts) => { + const { isOAuth, clientId, jwtKeyId, userIdentifierValue } = + credentials.config as ServiceNowPublicConfigurationType; + const { clientSecret, privateKey, privateKeyPassword } = + credentials.secrets as ServiceNowSecretConfigurationType; + + let accessToken: string; + + // Check if there is a token stored for this connector + const { connectorToken, hasErrors } = await connectorTokenClient.get({ connectorId }); + + if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + // generate a new assertion + if ( + !isOAuth || + !clientId || + !clientSecret || + !jwtKeyId || + !privateKey || + !userIdentifierValue + ) { + return null; + } + + const assertion = createJWTAssertion(logger, privateKey, privateKeyPassword, { + audience: clientId, + issuer: clientId, + subject: userIdentifierValue, + keyId: jwtKeyId, + }); + + // request access token with jwt assertion + const tokenResult = await requestOAuthJWTToken( + `${snServiceUrl}/oauth_token.do`, + { + clientId, + clientSecret, + assertion, + }, + logger, + configurationUtilities + ); + accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + // try to update connector_token SO + try { + await connectorTokenClient.updateOrReplace({ + connectorId, + token: connectorToken, + newToken: accessToken, + expiresInSec: tokenResult.expiresIn, + deleteExisting: hasErrors, + }); + } catch (err) { + logger.warn( + `Not able to update ServiceNow connector token for connectorId: ${connectorId} due to error: ${err.message}` + ); + } + } else { + // use existing valid token + accessToken = connectorToken.token; + } + return accessToken; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.test.ts new file mode 100644 index 0000000000000..547c025fcdb61 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.test.ts @@ -0,0 +1,401 @@ +/* + * 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 { validateCommonConfig, validateCommonSecrets, validateCommonConnector } from './validators'; +import { actionsConfigMock } from '../../actions_config.mock'; + +const configurationUtilities = actionsConfigMock.create(); + +describe('validateCommonConfig', () => { + test('config validation fails when apiUrl is not allowed', () => { + expect( + validateCommonConfig( + { + ...configurationUtilities, + ensureUriAllowed: (_) => { + throw new Error(`target url is not present in allowedHosts`); + }, + }, + { + apiUrl: 'example.com/do-something', + usesTableApi: true, + isOAuth: false, + userIdentifierValue: null, + clientId: null, + jwtKeyId: null, + } + ) + ).toEqual(`error configuring connector action: target url is not present in allowedHosts`); + }); + describe('when isOAuth = true', () => { + test('config validation fails when userIdentifierValue is null', () => { + expect( + validateCommonConfig(configurationUtilities, { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: null, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + }) + ).toEqual(`userIdentifierValue must be provided when isOAuth = true`); + }); + test('config validation fails when clientId is null', () => { + expect( + validateCommonConfig(configurationUtilities, { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: 'userIdentifierValue', + clientId: null, + jwtKeyId: 'jwtKeyId', + }) + ).toEqual(`clientId must be provided when isOAuth = true`); + }); + test('config validation fails when jwtKeyId is null', () => { + expect( + validateCommonConfig(configurationUtilities, { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: 'userIdentifierValue', + clientId: 'clientId', + jwtKeyId: null, + }) + ).toEqual(`jwtKeyId must be provided when isOAuth = true`); + }); + }); + + describe('when isOAuth = false', () => { + test('connector validation fails when username is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: false, + userIdentifierValue: null, + clientId: null, + jwtKeyId: null, + }, + { + password: 'password', + username: null, + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + } + ) + ).toEqual(`username must be provided when isOAuth = false`); + }); + test('connector validation fails when password is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: false, + userIdentifierValue: null, + clientId: null, + jwtKeyId: null, + }, + { + password: null, + username: 'username', + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + } + ) + ).toEqual(`password must be provided when isOAuth = false`); + }); + test('connector validation fails when any oauth related field is defined', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: false, + userIdentifierValue: null, + clientId: null, + jwtKeyId: null, + }, + { + password: 'password', + username: 'username', + clientSecret: 'clientSecret', + privateKey: null, + privateKeyPassword: null, + } + ) + ).toEqual( + `clientId, clientSecret, userIdentifierValue, jwtKeyId and privateKey should not be provided with isOAuth = false` + ); + }); + }); +}); + +describe('validateCommonSecrets', () => { + test('secrets validation fails when no credentials are defined', () => { + expect( + validateCommonSecrets(configurationUtilities, { + password: null, + username: null, + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + }) + ).toEqual(`Either basic auth or OAuth credentials must be specified`); + }); + + test('secrets validation fails when username is defined and password is not', () => { + expect( + validateCommonSecrets(configurationUtilities, { + password: null, + username: 'admin', + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + }) + ).toEqual(`username and password must both be specified`); + }); + + test('secrets validation fails when password is defined and username is not', () => { + expect( + validateCommonSecrets(configurationUtilities, { + password: 'password', + username: null, + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + }) + ).toEqual(`username and password must both be specified`); + }); + + test('secrets validation fails when clientSecret is defined and privateKey is not', () => { + expect( + validateCommonSecrets(configurationUtilities, { + password: null, + username: null, + clientSecret: 'secret', + privateKey: null, + privateKeyPassword: null, + }) + ).toEqual(`clientSecret and privateKey must both be specified`); + }); + + test('secrets validation fails when privateKey is defined and clientSecret is not', () => { + expect( + validateCommonSecrets(configurationUtilities, { + password: null, + username: null, + clientSecret: null, + privateKey: 'private', + privateKeyPassword: null, + }) + ).toEqual(`clientSecret and privateKey must both be specified`); + }); +}); + +describe('validateCommonConnector', () => { + describe('when isOAuth = true', () => { + test('connector validation fails when userIdentifierValue is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: null, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + }, + { + password: null, + username: null, + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + } + ) + ).toEqual(`userIdentifierValue must be provided when isOAuth = true`); + }); + test('connector validation fails when clientId is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: 'userIdentifierValue', + clientId: null, + jwtKeyId: 'jwtKeyId', + }, + { + password: null, + username: null, + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + } + ) + ).toEqual(`clientId must be provided when isOAuth = true`); + }); + test('connector validation fails when jwtKeyId is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: 'userIdentifierValue', + clientId: 'clientId', + jwtKeyId: null, + }, + { + password: null, + username: null, + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + } + ) + ).toEqual(`jwtKeyId must be provided when isOAuth = true`); + }); + test('connector validation fails when clientSecret is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: 'userIdentifierValue', + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + }, + { + password: null, + username: null, + clientSecret: null, + privateKey: 'privateKey', + privateKeyPassword: null, + } + ) + ).toEqual(`clientSecret must be provided when isOAuth = true`); + }); + test('connector validation fails when privateKey is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: 'userIdentifierValue', + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + }, + { + password: null, + username: null, + clientSecret: 'clientSecret', + privateKey: null, + privateKeyPassword: null, + } + ) + ).toEqual(`privateKey must be provided when isOAuth = true`); + }); + test('connector validation fails when username and password are not null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: true, + userIdentifierValue: 'userIdentifierValue', + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + }, + { + password: 'password', + username: 'username', + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + } + ) + ).toEqual(`Username and password should not be provided with isOAuth = true`); + }); + }); + + describe('when isOAuth = false', () => { + test('connector validation fails when username is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: false, + userIdentifierValue: null, + clientId: null, + jwtKeyId: null, + }, + { + password: 'password', + username: null, + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + } + ) + ).toEqual(`username must be provided when isOAuth = false`); + }); + test('connector validation fails when password is null', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: false, + userIdentifierValue: null, + clientId: null, + jwtKeyId: null, + }, + { + password: null, + username: 'username', + clientSecret: null, + privateKey: null, + privateKeyPassword: null, + } + ) + ).toEqual(`password must be provided when isOAuth = false`); + }); + test('connector validation fails when any oauth related field is defined', () => { + expect( + validateCommonConnector( + { + apiUrl: 'https://url', + usesTableApi: true, + isOAuth: false, + userIdentifierValue: null, + clientId: null, + jwtKeyId: null, + }, + { + password: 'password', + username: 'username', + clientSecret: 'clientSecret', + privateKey: null, + privateKeyPassword: null, + } + ) + ).toEqual( + `clientId, clientSecret, userIdentifierValue, jwtKeyId and privateKey should not be provided with isOAuth = false` + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts index f074e28863642..87ea4922fa5cc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts @@ -16,21 +16,107 @@ import * as i18n from './translations'; export const validateCommonConfig = ( configurationUtilities: ActionsConfigurationUtilities, - configObject: ServiceNowPublicConfigurationType + config: ServiceNowPublicConfigurationType ) => { + const { isOAuth, apiUrl, userIdentifierValue, clientId, jwtKeyId } = config; + try { - configurationUtilities.ensureUriAllowed(configObject.apiUrl); + configurationUtilities.ensureUriAllowed(apiUrl); } catch (allowedListError) { return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message); } + + if (isOAuth) { + if (userIdentifierValue == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('userIdentifierValue', true); + } + + if (clientId == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('clientId', true); + } + + if (jwtKeyId == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('jwtKeyId', true); + } + } }; export const validateCommonSecrets = ( configurationUtilities: ActionsConfigurationUtilities, secrets: ServiceNowSecretConfigurationType -) => {}; +) => { + const { username, password, clientSecret, privateKey } = secrets; + + if (!username && !password && !clientSecret && !privateKey) { + return i18n.CREDENTIALS_ERROR; + } + + if (username || password) { + // Username and password must be set and set together + if (!username || !password) { + return i18n.BASIC_AUTH_CREDENTIALS_ERROR; + } + } else if (clientSecret || privateKey) { + // Client secret and private key must be set and set together + if (!clientSecret || !privateKey) { + return i18n.OAUTH_CREDENTIALS_ERROR; + } + } +}; + +export const validateCommonConnector = ( + config: ServiceNowPublicConfigurationType, + secrets: ServiceNowSecretConfigurationType +): string | null => { + const { isOAuth, userIdentifierValue, clientId, jwtKeyId } = config; + const { username, password, clientSecret, privateKey } = secrets; + + if (isOAuth) { + if (userIdentifierValue == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('userIdentifierValue', true); + } + + if (clientId == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('clientId', true); + } + + if (jwtKeyId == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('jwtKeyId', true); + } + + if (clientSecret == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('clientSecret', true); + } + + if (privateKey == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('privateKey', true); + } + + if (username || password) { + return i18n.VALIDATE_OAUTH_POPULATED_FIELD_ERROR('Username and password', true); + } + } else { + if (username == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('username', false); + } + + if (password == null) { + return i18n.VALIDATE_OAUTH_MISSING_FIELD_ERROR('password', false); + } + + if (clientSecret || clientId || userIdentifierValue || jwtKeyId || privateKey) { + return i18n.VALIDATE_OAUTH_POPULATED_FIELD_ERROR( + 'clientId, clientSecret, userIdentifierValue, jwtKeyId and privateKey', + false + ); + } + } + + return null; +}; export const validate: ExternalServiceValidation = { config: validateCommonConfig, secrets: validateCommonSecrets, + connector: validateCommonConnector, }; diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts index d350e40c1b362..12bf3984907b4 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts @@ -168,7 +168,7 @@ describe('successful migrations', () => { test('set usesTableApi config property for .servicenow', () => { const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; - const action = getMockDataForServiceNow(); + const action = getMockDataForServiceNow716({ usesTableApi: true }); const migratedAction = migration716(action, context); expect(migratedAction).toEqual({ @@ -185,7 +185,7 @@ describe('successful migrations', () => { test('set usesTableApi config property for .servicenow-sir', () => { const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; - const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' }); + const action = getMockDataForServiceNow716({ actionTypeId: '.servicenow-sir' }); const migratedAction = migration716(action, context); expect(migratedAction).toEqual({ @@ -215,6 +215,52 @@ describe('successful migrations', () => { expect(migration800(action, context)).toEqual(action); }); }); + + describe('8.3.0', () => { + test('set isOAuth config property for .servicenow', () => { + const migration830 = getActionsMigrations(encryptedSavedObjectsSetup)['8.3.0']; + const action = getMockDataForServiceNow83(); + const migratedAction = migration830(action, context); + + expect(migratedAction.attributes.config).toEqual({ + apiUrl: 'https://example.com', + usesTableApi: true, + isOAuth: false, + }); + }); + + test('set isOAuth config property for .servicenow-sir', () => { + const migration830 = getActionsMigrations(encryptedSavedObjectsSetup)['8.3.0']; + const action = getMockDataForServiceNow83({ actionTypeId: '.servicenow-sir' }); + const migratedAction = migration830(action, context); + + expect(migratedAction.attributes.config).toEqual({ + apiUrl: 'https://example.com', + usesTableApi: true, + isOAuth: false, + }); + }); + + test('set isOAuth config property for .servicenow-itom', () => { + const migration830 = getActionsMigrations(encryptedSavedObjectsSetup)['8.3.0']; + const action = getMockDataForServiceNow83({ actionTypeId: '.servicenow-itom' }); + const migratedAction = migration830(action, context); + + expect(migratedAction.attributes.config).toEqual({ + apiUrl: 'https://example.com', + usesTableApi: true, + isOAuth: false, + }); + }); + + test('it does not set isOAuth config for other connectors', () => { + const migration830 = getActionsMigrations(encryptedSavedObjectsSetup)['8.3.0']; + const action = getMockData(); + const migratedAction = migration830(action, context); + + expect(migratedAction).toEqual(action); + }); + }); }); describe('handles errors during migrations', () => { @@ -348,7 +394,7 @@ function getMockData( }; } -function getMockDataForServiceNow( +function getMockDataForServiceNow716( overwrites: Record = {} ): SavedObjectUnsanitizedDoc> { return { @@ -363,3 +409,11 @@ function getMockDataForServiceNow( type: 'action', }; } + +function getMockDataForServiceNow83( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return getMockDataForServiceNow716({ + config: { apiUrl: 'https://example.com', usesTableApi: true }, + }); +} diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index 4c113a09b81b5..f785fa9ee4ac9 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -78,12 +78,22 @@ export function getActionsMigrations( (doc) => doc // no-op ); + const migrationActions830 = createEsoMigration( + encryptedSavedObjects, + (doc): doc is SavedObjectUnsanitizedDoc => + doc.attributes.actionTypeId === '.servicenow' || + doc.attributes.actionTypeId === '.servicenow-sir' || + doc.attributes.actionTypeId === '.servicenow-itom', + pipeMigrations(addIsOAuthToServiceNowConnectors) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), '7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'), + '8.3.0': executeMigrationWithErrorHandling(migrationActions830, '8.3.0'), }; } @@ -219,6 +229,29 @@ const addUsesTableApiToServiceNowConnectors = ( }; }; +const addIsOAuthToServiceNowConnectors = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + if ( + doc.attributes.actionTypeId !== '.servicenow' && + doc.attributes.actionTypeId !== '.servicenow-sir' && + doc.attributes.actionTypeId !== '.servicenow-itom' + ) { + return doc; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + config: { + ...doc.attributes.config, + isOAuth: false, + }, + }, + }; +}; + function pipeMigrations(...migrations: ActionMigration[]): ActionMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts index 9dcc3ef05266e..c685fff8abfc6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts @@ -7,6 +7,7 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; import getPort from 'get-port'; import http from 'http'; @@ -19,14 +20,7 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - const mockServiceNow = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - }, - secrets: { - password: 'elastic', - username: 'changeme', - }, + const mockServiceNowCommon = { params: { subAction: 'addEvent', subActionParams: { @@ -44,6 +38,30 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { }, }, }; + const mockServiceNowBasic = { + ...mockServiceNowCommon, + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + }; + const mockServiceNowOAuth = { + ...mockServiceNowCommon, + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + isOAuth: true, + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', + }, + secrets: { + clientSecret: 'xyz', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\nddddddd\n-----END RSA PRIVATE KEY-----', + }, + }; describe('ServiceNow ITOM', () => { let simulatedActionId = ''; @@ -76,7 +94,7 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { }); describe('ServiceNow ITOM - Action Creation', () => { - it('should return 200 when creating a servicenow action successfully', async () => { + it('should return 200 when creating a servicenow Basic Auth connector successfully', async () => { const { body: createdAction } = await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -86,7 +104,7 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(200); @@ -99,6 +117,10 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { is_missing_secrets: false, config: { apiUrl: serviceNowSimulatorURL, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, }, }); @@ -115,11 +137,67 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { is_missing_secrets: false, config: { apiUrl: serviceNowSimulatorURL, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, }, }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + it('should return 200 when creating a servicenow OAuth connector successfully', async () => { + const { body: createdConnector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNowOAuth.secrets, + }) + .expect(200); + + expect(createdConnector).to.eql({ + id: createdConnector.id, + is_preconfigured: false, + is_deprecated: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isOAuth: true, + clientId: mockServiceNowOAuth.config.clientId, + jwtKeyId: mockServiceNowOAuth.config.jwtKeyId, + userIdentifierValue: mockServiceNowOAuth.config.userIdentifierValue, + }, + }); + + const { body: fetchedConnector } = await supertest + .get(`/api/actions/connector/${createdConnector.id}`) + .expect(200); + + expect(fetchedConnector).to.eql({ + id: fetchedConnector.id, + is_preconfigured: false, + is_deprecated: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isOAuth: true, + clientId: mockServiceNowOAuth.config.clientId, + jwtKeyId: mockServiceNowOAuth.config.jwtKeyId, + userIdentifierValue: mockServiceNowOAuth.config.userIdentifierValue, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow Basic Auth connector with no apiUrl', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -139,7 +217,30 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + isOAuth: true, + }, + secrets: mockServiceNowOAuth.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow connector with a not present in allowedHosts apiUrl', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -149,7 +250,7 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { config: { apiUrl: 'http://servicenow.mynonexistent.com', }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(400) .then((resp: any) => { @@ -162,7 +263,29 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + it('should respond with a 400 Bad Request when creating a servicenow Basic Auth connector without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: Either basic auth or OAuth credentials must be specified', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector without secrets', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -170,6 +293,7 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { name: 'A servicenow action', connector_type_id: '.servicenow-itom', config: { + ...mockServiceNowOAuth.config, apiUrl: serviceNowSimulatorURL, }, }) @@ -179,10 +303,84 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + 'error validating action type secrets: Either basic auth or OAuth credentials must be specified', }); }); }); + + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector with missing fields', async () => { + const badConfigs = [ + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + clientId: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: clientId must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + userIdentifierValue: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: userIdentifierValue must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + jwtKeyId: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: jwtKeyId must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: { + ...mockServiceNowOAuth.secrets, + clientSecret: null, + }, + errorMessage: `error validating action type secrets: clientSecret and privateKey must both be specified`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: { + ...mockServiceNowOAuth.secrets, + privateKey: null, + }, + errorMessage: `error validating action type secrets: clientSecret and privateKey must both be specified`, + }, + ]; + + await asyncForEach(badConfigs, async (badConfig) => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: badConfig.config, + secrets: badConfig.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: badConfig.errorMessage, + }); + }); + }); + }); }); describe('ServiceNow ITOM - Executor', () => { @@ -196,7 +394,7 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }); simulatedActionId = body.id; }); @@ -284,7 +482,7 @@ export default function serviceNowITOMTest({ getService }: FtrProviderContext) { .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ - params: mockServiceNow.params, + params: mockServiceNowBasic.params, }) .expect(200); expect(result.status).to.eql('ok'); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts index 4cc65d7103a58..0f81753bbc731 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts @@ -7,6 +7,7 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; import getPort from 'get-port'; import http from 'http'; @@ -19,15 +20,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - const mockServiceNow = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - usesTableApi: false, - }, - secrets: { - password: 'elastic', - username: 'changeme', - }, + const mockServiceNowCommon = { params: { subAction: 'pushToService', subActionParams: { @@ -51,6 +44,33 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { }, }; + const mockServiceNowBasic = { + ...mockServiceNowCommon, + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + usesTableApi: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + }; + const mockServiceNowOAuth = { + ...mockServiceNowCommon, + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + usesTableApi: false, + isOAuth: true, + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', + }, + secrets: { + clientSecret: 'xyz', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\nddddddd\n-----END RSA PRIVATE KEY-----', + }, + }; + describe('ServiceNow ITSM', () => { let simulatedActionId = ''; let serviceNowSimulatorURL: string = ''; @@ -82,7 +102,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { }); describe('ServiceNow ITSM - Action Creation', () => { - it('should return 200 when creating a servicenow action successfully', async () => { + it('should return 200 when creating a servicenow Basic Auth connector successfully', async () => { const { body: createdAction } = await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -93,7 +113,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { apiUrl: serviceNowSimulatorURL, usesTableApi: false, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(200); @@ -107,6 +127,10 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, }, }); @@ -124,6 +148,64 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, + }, + }); + }); + + it('should return 200 when creating a servicenow OAuth connector successfully', async () => { + const { body: createdConnector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNowOAuth.secrets, + }) + .expect(200); + + expect(createdConnector).to.eql({ + id: createdConnector.id, + is_preconfigured: false, + is_deprecated: false, + name: 'A servicenow action', + connector_type_id: '.servicenow', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + usesTableApi: false, + isOAuth: true, + clientId: mockServiceNowOAuth.config.clientId, + jwtKeyId: mockServiceNowOAuth.config.jwtKeyId, + userIdentifierValue: mockServiceNowOAuth.config.userIdentifierValue, + }, + }); + + const { body: fetchedConnector } = await supertest + .get(`/api/actions/connector/${createdConnector.id}`) + .expect(200); + + expect(fetchedConnector).to.eql({ + id: fetchedConnector.id, + is_preconfigured: false, + is_deprecated: false, + name: 'A servicenow action', + connector_type_id: '.servicenow', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + usesTableApi: false, + isOAuth: true, + clientId: mockServiceNowOAuth.config.clientId, + jwtKeyId: mockServiceNowOAuth.config.jwtKeyId, + userIdentifierValue: mockServiceNowOAuth.config.userIdentifierValue, }, }); }); @@ -138,7 +220,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(200); @@ -149,7 +231,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { expect(fetchedAction.config.usesTableApi).to.be(true); }); - it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow Basic Auth connector with no apiUrl', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -169,7 +251,30 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + isOAuth: true, + }, + secrets: mockServiceNowOAuth.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow connector with a not present in allowedHosts apiUrl', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -179,7 +284,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { config: { apiUrl: 'http://servicenow.mynonexistent.com', }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(400) .then((resp: any) => { @@ -192,7 +297,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + it('should respond with a 400 Bad Request when creating a servicenow Basic Auth connector without secrets', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -209,10 +314,107 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + 'error validating action type secrets: Either basic auth or OAuth credentials must be specified', }); }); }); + + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: Either basic auth or OAuth credentials must be specified', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector with missing fields', async () => { + const badConfigs = [ + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + clientId: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: clientId must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + userIdentifierValue: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: userIdentifierValue must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + jwtKeyId: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: jwtKeyId must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: { + ...mockServiceNowOAuth.secrets, + clientSecret: null, + }, + errorMessage: `error validating action type secrets: clientSecret and privateKey must both be specified`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: { + ...mockServiceNowOAuth.secrets, + privateKey: null, + }, + errorMessage: `error validating action type secrets: clientSecret and privateKey must both be specified`, + }, + ]; + + await asyncForEach(badConfigs, async (badConfig) => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: badConfig.config, + secrets: badConfig.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: badConfig.errorMessage, + }); + }); + }); + }); }); describe('ServiceNow ITSM - Executor', () => { @@ -227,7 +429,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { apiUrl: serviceNowSimulatorURL, usesTableApi: false, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }); simulatedActionId = body.id; }); @@ -289,7 +491,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { savedObjectId: 'success', }, @@ -312,10 +514,10 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { incident: { - ...mockServiceNow.params.subActionParams.incident, + ...mockServiceNowBasic.params.subActionParams.incident, short_description: 'success', }, comments: [{ comment: 'boo' }], @@ -339,10 +541,10 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { incident: { - ...mockServiceNow.params.subActionParams.incident, + ...mockServiceNowBasic.params.subActionParams.incident, short_description: 'success', }, comments: [{ commentId: 'success' }], @@ -393,9 +595,9 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, + incident: mockServiceNowBasic.params.subActionParams.incident, comments: [], }, }, @@ -429,7 +631,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { apiUrl: serviceNowSimulatorURL, usesTableApi: true, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }); simulatedActionId = body.id; }); @@ -440,9 +642,9 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, + incident: mockServiceNowBasic.params.subActionParams.incident, comments: [], }, }, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts index 305bbef7cf70a..0f5640f7edd3e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts @@ -7,6 +7,7 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; import getPort from 'get-port'; import http from 'http'; @@ -19,7 +20,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - const mockServiceNow = { + const mockServiceNowCommon = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', usesTableApi: false, @@ -55,6 +56,33 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { }, }; + const mockServiceNowBasic = { + ...mockServiceNowCommon, + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + usesTableApi: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + }; + const mockServiceNowOAuth = { + ...mockServiceNowCommon, + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + usesTableApi: false, + isOAuth: true, + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', + }, + secrets: { + clientSecret: 'xyz', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\nddddddd\n-----END RSA PRIVATE KEY-----', + }, + }; + describe('ServiceNow SIR', () => { let simulatedActionId = ''; let serviceNowSimulatorURL: string = ''; @@ -86,7 +114,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { }); describe('ServiceNow SIR - Action Creation', () => { - it('should return 200 when creating a servicenow action successfully', async () => { + it('should return 200 when creating a servicenow Basic Auth connector successfully', async () => { const { body: createdAction } = await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -97,7 +125,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { apiUrl: serviceNowSimulatorURL, usesTableApi: false, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(200); @@ -111,6 +139,10 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, }, }); @@ -128,6 +160,64 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, + }, + }); + }); + + it('should return 200 when creating a servicenow OAuth connector successfully', async () => { + const { body: createdConnector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNowOAuth.secrets, + }) + .expect(200); + + expect(createdConnector).to.eql({ + id: createdConnector.id, + is_preconfigured: false, + is_deprecated: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + usesTableApi: false, + isOAuth: true, + clientId: mockServiceNowOAuth.config.clientId, + jwtKeyId: mockServiceNowOAuth.config.jwtKeyId, + userIdentifierValue: mockServiceNowOAuth.config.userIdentifierValue, + }, + }); + + const { body: fetchedConnector } = await supertest + .get(`/api/actions/connector/${createdConnector.id}`) + .expect(200); + + expect(fetchedConnector).to.eql({ + id: fetchedConnector.id, + is_preconfigured: false, + is_deprecated: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + usesTableApi: false, + isOAuth: true, + clientId: mockServiceNowOAuth.config.clientId, + jwtKeyId: mockServiceNowOAuth.config.jwtKeyId, + userIdentifierValue: mockServiceNowOAuth.config.userIdentifierValue, }, }); }); @@ -142,7 +232,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(200); @@ -153,7 +243,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { expect(fetchedAction.config.usesTableApi).to.be(true); }); - it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow Basic Auth connector with no apiUrl', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -173,7 +263,30 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + isOAuth: true, + }, + secrets: mockServiceNowOAuth.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow connector with a not present in allowedHosts apiUrl', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -183,7 +296,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { config: { apiUrl: 'http://servicenow.mynonexistent.com', }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }) .expect(400) .then((resp: any) => { @@ -196,7 +309,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + it('should respond with a 400 Bad Request when creating a servicenow Basic Auth connector without secrets', async () => { await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -213,10 +326,107 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + 'error validating action type secrets: Either basic auth or OAuth credentials must be specified', }); }); }); + + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: Either basic auth or OAuth credentials must be specified', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow OAuth connector with missing fields', async () => { + const badConfigs = [ + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + clientId: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: clientId must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + userIdentifierValue: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: userIdentifierValue must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + jwtKeyId: null, + }, + secrets: mockServiceNowOAuth.secrets, + errorMessage: `error validating action type config: jwtKeyId must be provided when isOAuth = true`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: { + ...mockServiceNowOAuth.secrets, + clientSecret: null, + }, + errorMessage: `error validating action type secrets: clientSecret and privateKey must both be specified`, + }, + { + config: { + ...mockServiceNowOAuth.config, + apiUrl: serviceNowSimulatorURL, + }, + secrets: { + ...mockServiceNowOAuth.secrets, + privateKey: null, + }, + errorMessage: `error validating action type secrets: clientSecret and privateKey must both be specified`, + }, + ]; + + await asyncForEach(badConfigs, async (badConfig) => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: badConfig.config, + secrets: badConfig.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: badConfig.errorMessage, + }); + }); + }); + }); }); describe('ServiceNow SIR - Executor', () => { @@ -230,8 +440,9 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { config: { apiUrl: serviceNowSimulatorURL, usesTableApi: false, + isOAuth: false, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }); simulatedActionId = body.id; }); @@ -293,7 +504,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { savedObjectId: 'success', }, @@ -316,10 +527,10 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { incident: { - ...mockServiceNow.params.subActionParams.incident, + ...mockServiceNowBasic.params.subActionParams.incident, short_description: 'success', }, comments: [{ comment: 'boo' }], @@ -343,10 +554,10 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { incident: { - ...mockServiceNow.params.subActionParams.incident, + ...mockServiceNowBasic.params.subActionParams.incident, short_description: 'success', }, comments: [{ commentId: 'success' }], @@ -397,9 +608,9 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, + incident: mockServiceNowBasic.params.subActionParams.incident, comments: [], }, }, @@ -433,7 +644,7 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { apiUrl: serviceNowSimulatorURL, usesTableApi: true, }, - secrets: mockServiceNow.secrets, + secrets: mockServiceNowBasic.secrets, }); simulatedActionId = body.id; }); @@ -444,9 +655,9 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...mockServiceNowBasic.params, subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, + incident: mockServiceNowBasic.params.subActionParams.incident, comments: [], }, }, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts index 7b28161c18238..4f23a5ff3a727 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; import { getUrlPrefix } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -83,6 +84,23 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(connectorWithoutService.body.config.service).to.eql('other'); }); + it('8.3.0 migrates service now connectors to have `isOAuth` property', async () => { + const serviceNowConnectorIds = [ + '7d04bc30-c4c0-11ec-ae29-917aa31a5b75', + '8a9331b0-c4c0-11ec-ae29-917aa31a5b75', + '6d3a1250-c4c0-11ec-ae29-917aa31a5b75', + ]; + + await asyncForEach(serviceNowConnectorIds, async (serviceNowConnectorId) => { + const connectorResponse = await supertest.get( + `${getUrlPrefix(``)}/api/actions/action/${serviceNowConnectorId}` + ); + + expect(connectorResponse.status).to.eql(200); + expect(connectorResponse.body.config.isOAuth).to.eql(false); + }); + }); + it('decryption error during migration', async () => { const badEmailConnector = await supertest.get( `${getUrlPrefix(``)}/api/actions/connector/0f8f2810-0a59-11ec-9a7c-fd0c2b83ff7d` diff --git a/x-pack/test/cases_api_integration/common/lib/utils.ts b/x-pack/test/cases_api_integration/common/lib/utils.ts index 7714c85b11d9a..ec0f9074df099 100644 --- a/x-pack/test/cases_api_integration/common/lib/utils.ts +++ b/x-pack/test/cases_api_integration/common/lib/utils.ts @@ -217,6 +217,23 @@ export const getServiceNowConnector = () => ({ }, }); +export const getServiceNowOAuthConnector = () => ({ + name: 'ServiceNow OAuth Connector', + connector_type_id: '.servicenow', + secrets: { + clientSecret: 'xyz', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\nddddddd\n-----END RSA PRIVATE KEY-----', + }, + config: { + apiUrl: 'http://some.non.existent.com', + usesTableApi: false, + isOAuth: true, + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', + }, +}); + export const getJiraConnector = () => ({ name: 'Jira Connector', connector_type_id: '.jira', @@ -262,7 +279,7 @@ export const getResilientConnector = () => ({ }); export const getServiceNowSIRConnector = () => ({ - name: 'ServiceNow Connector', + name: 'ServiceNow SIR Connector', connector_type_id: '.servicenow-sir', secrets: { username: 'admin', diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts index 26df77bfbc924..de72e0f343026 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, + getServiceNowOAuthConnector, getJiraConnector, getResilientConnector, createConnector, @@ -31,6 +32,10 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the correct connectors', async () => { const snConnector = await createConnector({ supertest, req: getServiceNowConnector() }); + const snOAuthConnector = await createConnector({ + supertest, + req: getServiceNowOAuthConnector(), + }); const emailConnector = await createConnector({ supertest, req: getEmailConnector() }); const jiraConnector = await createConnector({ supertest, req: getJiraConnector() }); const resilientConnector = await createConnector({ supertest, req: getResilientConnector() }); @@ -38,13 +43,15 @@ export default ({ getService }: FtrProviderContext): void => { actionsRemover.add('default', sir.id, 'action', 'actions'); actionsRemover.add('default', snConnector.id, 'action', 'actions'); + actionsRemover.add('default', snOAuthConnector.id, 'action', 'actions'); actionsRemover.add('default', emailConnector.id, 'action', 'actions'); actionsRemover.add('default', jiraConnector.id, 'action', 'actions'); actionsRemover.add('default', resilientConnector.id, 'action', 'actions'); const connectors = await getCaseConnectors({ supertest }); + const sortedConnectors = connectors.sort((a, b) => a.name.localeCompare(b.name)); - expect(connectors).to.eql([ + expect(sortedConnectors).to.eql([ { id: jiraConnector.id, actionTypeId: '.jira', @@ -90,6 +97,27 @@ export default ({ getService }: FtrProviderContext): void => { config: { apiUrl: 'http://some.non.existent.com', usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, + }, + isPreconfigured: false, + isDeprecated: false, + isMissingSecrets: false, + referencedByCount: 0, + }, + { + id: snOAuthConnector.id, + actionTypeId: '.servicenow', + name: 'ServiceNow OAuth Connector', + config: { + apiUrl: 'http://some.non.existent.com', + usesTableApi: false, + isOAuth: true, + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', }, isPreconfigured: false, isDeprecated: false, @@ -99,10 +127,14 @@ export default ({ getService }: FtrProviderContext): void => { { id: sir.id, actionTypeId: '.servicenow-sir', - name: 'ServiceNow Connector', + name: 'ServiceNow SIR Connector', config: { apiUrl: 'http://some.non.existent.com', usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, }, isPreconfigured: false, isDeprecated: false, diff --git a/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts b/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts index c4115b5c4902d..0ca47597e7b6b 100644 --- a/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, + getServiceNowOAuthConnector, getJiraConnector, getResilientConnector, createConnector, @@ -39,7 +40,11 @@ export default ({ getService }: FtrProviderContext): void => { req: getServiceNowConnector(), auth: authSpace1, }); - + const snOAuthConnector = await createConnector({ + supertest, + req: getServiceNowOAuthConnector(), + auth: authSpace1, + }); const emailConnector = await createConnector({ supertest, req: getEmailConnector(), @@ -66,13 +71,15 @@ export default ({ getService }: FtrProviderContext): void => { actionsRemover.add(space, sir.id, 'action', 'actions'); actionsRemover.add(space, snConnector.id, 'action', 'actions'); + actionsRemover.add(space, snOAuthConnector.id, 'action', 'actions'); actionsRemover.add(space, emailConnector.id, 'action', 'actions'); actionsRemover.add(space, jiraConnector.id, 'action', 'actions'); actionsRemover.add(space, resilientConnector.id, 'action', 'actions'); const connectors = await getCaseConnectors({ supertest, auth: authSpace1 }); + const sortedConnectors = connectors.sort((a, b) => a.name.localeCompare(b.name)); - expect(connectors).to.eql([ + expect(sortedConnectors).to.eql([ { id: jiraConnector.id, actionTypeId: '.jira', @@ -118,6 +125,27 @@ export default ({ getService }: FtrProviderContext): void => { config: { apiUrl: 'http://some.non.existent.com', usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, + }, + isPreconfigured: false, + isDeprecated: false, + isMissingSecrets: false, + referencedByCount: 0, + }, + { + id: snOAuthConnector.id, + actionTypeId: '.servicenow', + name: 'ServiceNow OAuth Connector', + config: { + apiUrl: 'http://some.non.existent.com', + usesTableApi: false, + isOAuth: true, + clientId: 'abc', + userIdentifierValue: 'elastic', + jwtKeyId: 'def', }, isPreconfigured: false, isDeprecated: false, @@ -127,10 +155,14 @@ export default ({ getService }: FtrProviderContext): void => { { id: sir.id, actionTypeId: '.servicenow-sir', - name: 'ServiceNow Connector', + name: 'ServiceNow SIR Connector', config: { apiUrl: 'http://some.non.existent.com', usesTableApi: false, + isOAuth: false, + clientId: null, + jwtKeyId: null, + userIdentifierValue: null, }, isPreconfigured: false, isDeprecated: false, @@ -147,6 +179,12 @@ export default ({ getService }: FtrProviderContext): void => { auth: authSpace1, }); + const snOAuthConnector = await createConnector({ + supertest, + req: getServiceNowOAuthConnector(), + auth: authSpace1, + }); + const emailConnector = await createConnector({ supertest, req: getEmailConnector(), @@ -173,6 +211,7 @@ export default ({ getService }: FtrProviderContext): void => { actionsRemover.add(space, sir.id, 'action', 'actions'); actionsRemover.add(space, snConnector.id, 'action', 'actions'); + actionsRemover.add(space, snOAuthConnector.id, 'action', 'actions'); actionsRemover.add(space, emailConnector.id, 'action', 'actions'); actionsRemover.add(space, jiraConnector.id, 'action', 'actions'); actionsRemover.add(space, resilientConnector.id, 'action', 'actions'); diff --git a/x-pack/test/functional/es_archives/actions/data.json b/x-pack/test/functional/es_archives/actions/data.json index 79e7920872ab0..75206672358db 100644 --- a/x-pack/test/functional/es_archives/actions/data.json +++ b/x-pack/test/functional/es_archives/actions/data.json @@ -205,4 +205,96 @@ "updated_at": "2021-08-31T12:43:37.117Z" } } +} + +{ + "type": "doc", + "value": { + "id": "action:7d04bc30-c4c0-11ec-ae29-917aa31a5b75", + "index": ".kibana_1", + "source": { + "action": { + "actionTypeId" : ".servicenow-sir", + "name" : "test servicenow SecOps", + "isMissingSecrets" : false, + "config" : { + "apiUrl": "https://devtestsecops.service-now.com", + "usesTableApi": false + }, + "secrets" : "kPp4tl4ueQ2ZNWSfATR3dFrbxd+NNBo4MY8izS6GJf358Lmeg/YaYjb2rIymrbPktR6HnPBRaVyXWlRTvBGstRicJc0LJHZbx3wNJlTRIj4UFlVqZLGQWQ/GcSqFLSZ1JQbKwgAvyfLtF6BhjAhGYEovK3/OLUNzGc3gvUOOHBiPWjiAY8A=" + }, + "migrationVersion": { + "action": "8.0.0" + }, + "coreMigrationVersion" : "8.2.0", + "references": [ + ], + "namespaces": [ + "default" + ], + "type": "action", + "updated_at": "2022-04-25T17:52:35.201Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "action:8a9331b0-c4c0-11ec-ae29-917aa31a5b75", + "index": ".kibana_1", + "source": { + "action": { + "actionTypeId" : ".servicenow-itom", + "name" : "test servicenow ITOM", + "isMissingSecrets" : false, + "config" : { + "apiUrl": "https://devtestsecops.service-now.com" + }, + "secrets" : "yYThM4vbrSTIg5IjKWE+eMDrxzL7UO0JQIyh6FvEMgqoNREUxRrIavSo25v+DXQIX1DyfsvjjKg97pNPlZhvS3siCwDZZafSFrwkCKDl+S4KHORgIMX+slilcQeuEnzwit7bFxcY7Y/AcNF8Ks6jO0Gs1UR58ibSPUALXoK2VOlJnHSgtvE=" + }, + "migrationVersion": { + "action": "8.0.0" + }, + "coreMigrationVersion" : "8.2.0", + "references": [ + ], + "namespaces": [ + "default" + ], + "type": "action", + "updated_at": "2022-04-25T17:52:35.201Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "action:6d3a1250-c4c0-11ec-ae29-917aa31a5b75", + "index": ".kibana_1", + "source": { + "action": { + "actionTypeId" : ".servicenow", + "name" : "test servicenow ITSM", + "isMissingSecrets" : false, + "config" : { + "usesTableApi": false, + "apiUrl": "https://devtestsecops.service-now.com" + }, + "secrets" : "zfXUDtG0CyJkJUKnQ8rSqo75hb6ZhbRUWkV1NiFEjApM87b72Rcqz3Fv+sbm8eBDOO1Fdd9CVyK+Bfly4ZwVCgL2lR0qIbPzz34q36r267dnGVsaERyJIVv2WPy+EGdiRZKgfpy4XFbMNT1R3gyIsUkd4TT+McqGfVTont2XTFIpMW2A9y8=" + }, + "migrationVersion": { + "action": "8.0.0" + }, + "coreMigrationVersion" : "8.2.0", + "references": [ + ], + "namespaces": [ + "default" + ], + "type": "action", + "updated_at": "2022-04-25T17:52:35.201Z" + } + } } \ No newline at end of file