[Connector] API for adding OAuth support to ServiceNow connectors (#131084)

* Adding new OAuth fields to ServiceNow ExternalIncidentServiceConfigurationBase and ExternalIncidentServiceSecretConfiguration

* Creating new function in ConnectorTokenClient for updating or replacing token

* Update servicenow executors to get Oauth access tokens if configured. Still need to update unit tests for services

* Creating wrapper function for createService to only create one axios instance

* Fixing translation check error

* Adding migration for adding isOAuth to service now connectors

* Fixing unit tests

* Fixing functional test

* Not requiring privateKeyPassword

* Fixing tests

* Adding functional tests for connector creation

* Adding functional tests

* Fixing functional test

* PR feedback

* Fixing test

* PR feedback

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ying Mao 2022-04-29 09:35:39 -04:00 committed by GitHub
parent cf46ec9950
commit 9d15ab1ec2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 2694 additions and 298 deletions

View file

@ -14,6 +14,7 @@ const createConnectorTokenClientMock = () => {
get: jest.fn(),
update: jest.fn(),
deleteConnectorTokens: jest.fn(),
updateOrReplace: jest.fn(),
};
return mocked;
};

View file

@ -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'
);
});
});

View file

@ -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',
});
}
}
}

View file

@ -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<Logger>;
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"');
});

View file

@ -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, string>
): 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;

View file

@ -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();

View file

@ -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}`

View file

@ -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<Logger>;
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,
});
});
});

View file

@ -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<T = ExternalService> {
connectorId: string;
credentials: ExternalServiceCredentials;
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
serviceConfig: SNProductsConfigValue;
connectorTokenClient: ConnectorTokenClientContract;
createServiceFn: ServiceFactory<T>;
}
export function createServiceWrapper<T = ExternalService>({
connectorId,
credentials,
logger,
configurationUtilities,
serviceConfig,
connectorTokenClient,
createServiceFn,
}: CreateServiceWrapperOpts<T>): 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,
});
}

View file

@ -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<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
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<ExternalService>({
connectorId: actionId,
credentials: {
config,
secrets,
},
logger,
configurationUtilities,
externalServiceConfig
);
serviceConfig: externalServiceConfig,
connectorTokenClient,
createServiceFn: createService,
});
const apiAsRecord = api as unknown as Record<string, unknown>;
throwIfSubActionIsNotSupported({ api: apiAsRecord, subAction, supportedSubActions, logger });
@ -260,18 +269,22 @@ async function executorITOM(
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
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<ExternalServiceITOM>({
connectorId: actionId,
credentials: {
config,
secrets,
},
logger,
configurationUtilities,
externalServiceConfig
) as ExternalServiceITOM;
serviceConfig: externalServiceConfig,
connectorTokenClient,
createServiceFn: createService,
});
const apiAsRecord = api as unknown as Record<string, unknown>;

View file

@ -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(

View file

@ -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();

View file

@ -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;
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 || !username || !password) {
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);

View file

@ -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(() => {

View file

@ -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<ExternalServiceITOM> = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
serviceConfig: SNProductsConfigValue
): ExternalServiceITOM => {
const snService = createExternalService(
export const createExternalServiceITOM: ServiceFactory<ExternalServiceITOM> = ({
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) => {

View file

@ -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(() => {

View file

@ -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<ExternalServiceSIR> = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
serviceConfig: SNProductsConfigValue
): ExternalServiceSIR => {
const snService = createExternalService(
export const createExternalServiceSIR: ServiceFactory<ExternalServiceSIR> = ({
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) => {

View file

@ -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',
},
});

View file

@ -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<ObservableResponse[]>;
}
export type ServiceFactory<T = ExternalService> = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
serviceConfig: SNProductsConfigValue
) => T;
interface ServiceFactoryOpts {
credentials: ExternalServiceCredentials;
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
serviceConfig: SNProductsConfigValue;
axiosInstance: AxiosInstance;
}
export type ServiceFactory<T = ExternalService> = ({
credentials,
logger,
configurationUtilities,
serviceConfig,
axiosInstance,
}: ServiceFactoryOpts) => T;
/**
* ITOM

View file

@ -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';
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<Logger>;
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`
);
});
});
});

View file

@ -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;
};

View file

@ -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`
);
});
});
});

View file

@ -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,
};

View file

@ -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<string, unknown> = {}
): SavedObjectUnsanitizedDoc<Omit<RawAction, 'isMissingSecrets'>> {
return {
@ -363,3 +409,11 @@ function getMockDataForServiceNow(
type: 'action',
};
}
function getMockDataForServiceNow83(
overwrites: Record<string, unknown> = {}
): SavedObjectUnsanitizedDoc<Omit<RawAction, 'isMissingSecrets'>> {
return getMockDataForServiceNow716({
config: { apiUrl: 'https://example.com', usesTableApi: true },
});
}

View file

@ -78,12 +78,22 @@ export function getActionsMigrations(
(doc) => doc // no-op
);
const migrationActions830 = createEsoMigration(
encryptedSavedObjects,
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> =>
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<RawAction>
): SavedObjectUnsanitizedDoc<RawAction> => {
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<RawAction>) =>
migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc);

View file

@ -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,7 @@ 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')
@ -179,10 +280,107 @@ 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 without secrets', async () => {
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,
},
})
.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-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');

View file

@ -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: [],
},
},

View file

@ -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: [],
},
},

View file

@ -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`

View file

@ -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',

View file

@ -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,

View file

@ -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');

View file

@ -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"
}
}
}