[Lists] Lists plugin support for Server side extension points (#121324)

* Lists plugin framework for registering extension points
* Support for two extension points for Exceptions List
* `ExceptionListClient` changed to executed extension points
* Security Solution: Change security solution `getExceptionListClient()` to use the Lists plugin factory
This commit is contained in:
Paul Tavares 2022-01-05 17:22:35 -05:00 committed by GitHub
parent 462495c9b1
commit c5499186ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1231 additions and 20 deletions

View file

@ -11,6 +11,9 @@ export class ErrorWithStatusCode extends Error {
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
// For debugging - capture name of subclasses
this.name = this.constructor.name;
}
public getStatusCode = (): number => this.statusCode;

View file

@ -17,7 +17,14 @@ export type {
UpdateExceptionListItemOptions,
} from './services/exception_lists/exception_list_client_types';
export { ExceptionListClient } from './services/exception_lists/exception_list_client';
export type { ListPluginSetup, ListsApiRequestHandlerContext } from './types';
export type {
ListPluginSetup,
ListsApiRequestHandlerContext,
ListsServerExtensionRegistrar,
ExtensionPoint,
ExceptionsListPreCreateItemServerExtension,
ExceptionListPreUpdateItemServerExtension,
} from './types';
export type { ExportExceptionListAndItemsReturn } from './services/exception_lists/export_exception_list_and_items';
export const config: PluginConfigDescriptor = {

View file

@ -13,6 +13,7 @@ const createSetupMock = (): jest.Mocked<ListPluginSetup> => {
const mock: jest.Mocked<ListPluginSetup> = {
getExceptionListClient: jest.fn().mockReturnValue(getExceptionListClientMock()),
getListClient: jest.fn().mockReturnValue(getListClientMock()),
registerExtension: jest.fn(),
};
return mock;
};

View file

@ -26,16 +26,23 @@ import { getSpaceId } from './get_space_id';
import { getUser } from './get_user';
import { initSavedObjects } from './saved_objects';
import { ExceptionListClient } from './services/exception_lists/exception_list_client';
import {
ExtensionPointStorage,
ExtensionPointStorageClientInterface,
ExtensionPointStorageInterface,
} from './services/extension_points';
export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {}, PluginsStart> {
private readonly logger: Logger;
private readonly config: ConfigType;
private readonly extensionPoints: ExtensionPointStorageInterface;
private spaces: SpacesServiceStart | undefined | null;
private security: SecurityPluginStart | undefined | null;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = this.initializerContext.logger.get();
this.config = this.initializerContext.config.get<ConfigType>();
this.extensionPoints = new ExtensionPointStorage(this.logger);
}
public setup(core: CoreSetup): ListPluginSetup {
@ -51,9 +58,15 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {},
initRoutes(router, config);
return {
getExceptionListClient: (savedObjectsClient, user): ExceptionListClient => {
getExceptionListClient: (
savedObjectsClient,
user,
enableServerExtensionPoints = true
): ExceptionListClient => {
return new ExceptionListClient({
enableServerExtensionPoints,
savedObjectsClient,
serverExtensionsClient: this.extensionPoints.getClient(),
user,
});
},
@ -65,6 +78,9 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {},
user,
});
},
registerExtension: (extension): void => {
this.extensionPoints.add(extension);
},
};
}
@ -75,12 +91,13 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {},
}
public stop(): void {
this.extensionPoints.clear();
this.logger.debug('Stopping plugin');
}
private createRouteHandlerContext = (): ContextProvider => {
return async (context, request): ContextProviderReturn => {
const { spaces, config, security } = this;
const { spaces, config, security, extensionPoints } = this;
const {
core: {
savedObjects: { client: savedObjectsClient },
@ -98,8 +115,11 @@ export class ListPlugin implements Plugin<ListPluginSetup, ListsPluginStart, {},
getExceptionListClient: (): ExceptionListClient =>
new ExceptionListClient({
savedObjectsClient,
serverExtensionsClient: this.extensionPoints.getClient(),
user,
}),
getExtensionPointClient: (): ExtensionPointStorageClientInterface =>
extensionPoints.getClient(),
getListClient: (): ListClient =>
new ListClient({
config,

View file

@ -5,7 +5,13 @@
* 2.0.
*/
import { savedObjectsClientMock } from 'src/core/server/mocks';
import { loggingSystemMock, savedObjectsClientMock } from 'src/core/server/mocks';
import { getSavedObjectType } from '@kbn/securitysolution-list-utils';
import {
EXCEPTION_LIST_NAMESPACE,
EXCEPTION_LIST_NAMESPACE_AGNOSTIC,
} from '@kbn/securitysolution-list-constants';
import type { SavedObjectsUpdateResponse } from 'kibana/server';
import { getFoundExceptionListSchemaMock } from '../../../common/schemas/response/found_exception_list_schema.mock';
import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock';
@ -15,8 +21,19 @@ import {
getExceptionListSchemaMock,
getTrustedAppsListSchemaMock,
} from '../../../common/schemas/response/exception_list_schema.mock';
import { ExtensionPointStorage, ExtensionPointStorageClientInterface } from '../extension_points';
import type { ExceptionListSoSchema } from '../../schemas/saved_objects';
import { DATE_NOW, ID, _VERSION } from '../../../common/constants.mock';
import type { SavedObject } from '../../../../../../src/core/types';
import { ExceptionListClient } from './exception_list_client';
import type {
CreateExceptionListItemOptions,
UpdateExceptionListItemOptions,
} from './exception_list_client_types';
const isExceptionsListSavedObjectType = (type: string): boolean =>
type === EXCEPTION_LIST_NAMESPACE || type === EXCEPTION_LIST_NAMESPACE_AGNOSTIC;
export class ExceptionListClientMock extends ExceptionListClient {
public getExceptionList = jest.fn().mockResolvedValue(getExceptionListSchemaMock());
@ -47,11 +64,198 @@ export class ExceptionListClientMock extends ExceptionListClient {
}
export const getExceptionListClientMock = (
savedObject?: ReturnType<typeof savedObjectsClientMock.create>
savedObject?: ReturnType<typeof savedObjectsClientMock.create>,
serverExtensionsClient: ExtensionPointStorageClientInterface = new ExtensionPointStorage(
loggingSystemMock.createLogger()
).getClient()
): ExceptionListClient => {
const mock = new ExceptionListClientMock({
savedObjectsClient: savedObject ? savedObject : savedObjectsClientMock.create(),
serverExtensionsClient,
user: 'elastic',
});
return mock;
};
export const getCreateExceptionListItemOptionsMock = (): CreateExceptionListItemOptions => {
const {
comments,
description,
entries,
item_id: itemId,
list_id: listId,
meta,
name,
namespace_type: namespaceType,
os_types: osTypes,
tags,
type,
} = getExceptionListItemSchemaMock();
return {
comments,
description,
entries,
itemId,
listId,
meta,
name,
namespaceType,
osTypes,
tags,
type,
};
};
export const getUpdateExceptionListItemOptionsMock = (): UpdateExceptionListItemOptions => {
const { comments, entries, itemId, namespaceType, name, osTypes, description, meta, tags, type } =
getCreateExceptionListItemOptionsMock();
return {
_version: undefined,
comments,
description,
entries,
id: ID,
itemId,
meta,
name,
namespaceType,
osTypes,
tags,
type,
};
};
export const getExceptionListSoSchemaMock = (
overrides: Partial<ExceptionListSoSchema> = {}
): ExceptionListSoSchema => {
/* eslint-disable @typescript-eslint/naming-convention */
const {
comments,
created_at,
created_by,
description,
entries,
item_id,
list_id,
meta,
name,
os_types,
tags,
tie_breaker_id,
type,
updated_by,
} = getExceptionListItemSchemaMock();
/* eslint-enable @typescript-eslint/naming-convention */
const soSchema: ExceptionListSoSchema = {
comments,
created_at,
created_by,
description,
entries,
immutable: undefined,
item_id,
list_id,
list_type: 'item',
meta,
name,
os_types,
tags,
tie_breaker_id,
type,
updated_by,
version: undefined,
...overrides,
};
return soSchema;
};
/**
* Returns a Saved Object with the `ExceptionListSoSchema` as the attributes
* @param attributesOverrides
* @param savedObjectOverrides
*/
export const getExceptionListItemSavedObject = (
attributesOverrides: Partial<ExceptionListSoSchema> = {},
savedObjectOverrides: Partial<Omit<SavedObject, 'attributes'>> = {}
): SavedObject<ExceptionListSoSchema> => {
return {
attributes: getExceptionListSoSchemaMock(attributesOverrides),
coreMigrationVersion: undefined,
error: undefined,
id: ID,
migrationVersion: undefined,
namespaces: undefined,
originId: undefined,
references: [],
type: getSavedObjectType({ namespaceType: 'agnostic' }),
updated_at: DATE_NOW,
version: _VERSION,
...savedObjectOverrides,
};
};
/**
* Returns a saved objects client mock that includes method mocks to handle working with the exceptions list client.
* @param [soClient] can be provided on input and its methods will be mocked for exceptions list only and will preserve existing ones for other types
*/
export const getExceptionListSavedObjectClientMock = (
soClient: ReturnType<typeof savedObjectsClientMock.create> = savedObjectsClientMock.create()
): ReturnType<typeof savedObjectsClientMock.create> => {
// mock `.create()`
const origCreateMock = soClient.create.getMockImplementation();
soClient.create.mockImplementation(async (...args) => {
const [type, attributes] = args;
if (isExceptionsListSavedObjectType(type)) {
return getExceptionListItemSavedObject(attributes as ExceptionListSoSchema, { type });
}
if (origCreateMock) {
return origCreateMock(...args);
}
return undefined as unknown as SavedObject;
});
// Mock `.update()`
const origUpdateMock = soClient.update.getMockImplementation();
soClient.update.mockImplementation(async (...args) => {
const [type, id, attributes, { version } = { version: undefined }] = args;
if (isExceptionsListSavedObjectType(type)) {
return getExceptionListItemSavedObject(attributes as ExceptionListSoSchema, {
id,
type,
version: version ?? _VERSION,
});
}
if (origUpdateMock) {
return origUpdateMock(...args);
}
return undefined as unknown as SavedObjectsUpdateResponse;
});
// Mock `.get()`
const origGetMock = soClient.get.getMockImplementation();
soClient.get.mockImplementation(async (...args) => {
const [type, id] = args;
if (isExceptionsListSavedObjectType(type)) {
return getExceptionListItemSavedObject({}, { id });
}
if (origGetMock) {
return origGetMock(...args);
}
return undefined as unknown as SavedObject;
});
return soClient;
};

View file

@ -7,8 +7,20 @@
import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock';
import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock';
import {
ExtensionPointStorageContextMock,
createExtensionPointStorageMock,
} from '../extension_points/extension_point_storage.mock';
import type { ExtensionPointCallbackArgument } from '../extension_points';
import { getExceptionListClientMock } from './exception_list_client.mock';
import {
getCreateExceptionListItemOptionsMock,
getExceptionListClientMock,
getExceptionListSavedObjectClientMock,
getUpdateExceptionListItemOptionsMock,
} from './exception_list_client.mock';
import { ExceptionListClient } from './exception_list_client';
import { DataValidationError } from './utils/errors';
describe('exception_list_client', () => {
describe('Mock client sanity checks', () => {
@ -32,4 +44,117 @@ describe('exception_list_client', () => {
expect(listItem).toEqual(getExceptionListItemSchemaMock());
});
});
describe('server extension points execution', () => {
let extensionPointStorageContext: ExtensionPointStorageContextMock;
let exceptionListClient: ExceptionListClient;
beforeEach(() => {
extensionPointStorageContext = createExtensionPointStorageMock();
});
it('should initialize class instance with `enableServerExtensionPoints` enabled by default', async () => {
exceptionListClient = new ExceptionListClient({
savedObjectsClient: getExceptionListSavedObjectClientMock(),
serverExtensionsClient: extensionPointStorageContext.extensionPointStorage.getClient(),
user: 'elastic',
});
await exceptionListClient.createExceptionListItem(getCreateExceptionListItemOptionsMock());
expect(extensionPointStorageContext.exceptionPreCreate.callback).toHaveBeenCalled();
});
// Test client methods that use the `pipeRun()` method of the ExtensionPointStorageClient`
describe.each([
[
'createExceptionListItem',
(): ReturnType<ExceptionListClient['createExceptionListItem']> => {
return exceptionListClient.createExceptionListItem(
getCreateExceptionListItemOptionsMock()
);
},
(): ExtensionPointStorageContextMock['exceptionPreCreate']['callback'] => {
return extensionPointStorageContext.exceptionPreCreate.callback;
},
],
[
'updateExceptionListItem',
(): ReturnType<ExceptionListClient['updateExceptionListItem']> => {
return exceptionListClient.updateExceptionListItem(
getUpdateExceptionListItemOptionsMock()
);
},
(): ExtensionPointStorageContextMock['exceptionPreUpdate']['callback'] => {
return extensionPointStorageContext.exceptionPreUpdate.callback;
},
],
])(
'and calling `ExceptionListClient#%s()`',
(methodName, callExceptionListClientMethod, getExtensionPointCallback) => {
describe('and server extension points are enabled', () => {
beforeEach(() => {
exceptionListClient = new ExceptionListClient({
savedObjectsClient: getExceptionListSavedObjectClientMock(),
serverExtensionsClient:
extensionPointStorageContext.extensionPointStorage.getClient(),
user: 'elastic',
});
});
it('should execute extension point callbacks', async () => {
await callExceptionListClientMethod();
expect(getExtensionPointCallback()).toHaveBeenCalled();
});
it('should validate extension point callback returned data and throw if not valid', async () => {
const extensionPointCallback = getExtensionPointCallback();
extensionPointCallback.mockImplementation(async (args) => {
const { entries, ...rest } = args as ExtensionPointCallbackArgument;
expect(entries).toBeTruthy(); // Test entries to exist since we exclude it.
return rest as ExtensionPointCallbackArgument;
});
const methodResponsePromise = callExceptionListClientMethod();
await expect(methodResponsePromise).rejects.toBeInstanceOf(DataValidationError);
await expect(methodResponsePromise).rejects.toEqual(
expect.objectContaining({
reason: ['Invalid value "undefined" supplied to "entries"'],
})
);
});
it('should use data returned from extension point callbacks when saving', async () => {
await expect(callExceptionListClientMethod()).resolves.toEqual(
expect.objectContaining({
name: 'some name-1',
})
);
});
});
describe('and server extension points are DISABLED', () => {
beforeEach(() => {
exceptionListClient = new ExceptionListClient({
enableServerExtensionPoints: false,
savedObjectsClient: getExceptionListSavedObjectClientMock(),
serverExtensionsClient:
extensionPointStorageContext.extensionPointStorage.getClient(),
user: 'elastic',
});
});
it('should NOT call server extension points', async () => {
await callExceptionListClientMethod();
expect(getExtensionPointCallback()).not.toHaveBeenCalled();
});
});
}
);
});
});

View file

@ -6,16 +6,20 @@
*/
import { SavedObjectsClientContract } from 'kibana/server';
import type {
import {
ExceptionListItemSchema,
ExceptionListSchema,
ExceptionListSummarySchema,
FoundExceptionListItemSchema,
FoundExceptionListSchema,
ImportExceptionsResponseSchema,
createExceptionListItemSchema,
updateExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
import type { ExtensionPointStorageClientInterface } from '../extension_points';
import {
ConstructorOptions,
CreateEndpointListItemOptions,
@ -66,15 +70,28 @@ import {
importExceptionsAsArray,
importExceptionsAsStream,
} from './import_exception_list_and_items';
import {
transformCreateExceptionListItemOptionsToCreateExceptionListItemSchema,
transformUpdateExceptionListItemOptionsToUpdateExceptionListItemSchema,
validateData,
} from './utils';
export class ExceptionListClient {
private readonly user: string;
private readonly savedObjectsClient: SavedObjectsClientContract;
private readonly serverExtensionsClient: ExtensionPointStorageClientInterface;
private readonly enableServerExtensionPoints: boolean;
constructor({ user, savedObjectsClient }: ConstructorOptions) {
constructor({
user,
savedObjectsClient,
serverExtensionsClient,
enableServerExtensionPoints = true,
}: ConstructorOptions) {
this.user = user;
this.savedObjectsClient = savedObjectsClient;
this.serverExtensionsClient = serverExtensionsClient;
this.enableServerExtensionPoints = enableServerExtensionPoints;
}
/**
@ -369,7 +386,7 @@ export class ExceptionListClient {
type,
}: CreateExceptionListItemOptions): Promise<ExceptionListItemSchema> => {
const { savedObjectsClient, user } = this;
return createExceptionListItem({
let itemData: CreateExceptionListItemOptions = {
comments,
description,
entries,
@ -379,9 +396,26 @@ export class ExceptionListClient {
name,
namespaceType,
osTypes,
savedObjectsClient,
tags,
type,
};
if (this.enableServerExtensionPoints) {
itemData = await this.serverExtensionsClient.pipeRun(
'exceptionsListPreCreateItem',
itemData,
(data) => {
return validateData(
createExceptionListItemSchema,
transformCreateExceptionListItemOptionsToCreateExceptionListItemSchema(data)
);
}
);
}
return createExceptionListItem({
...itemData,
savedObjectsClient,
user,
});
};
@ -417,7 +451,7 @@ export class ExceptionListClient {
type,
}: UpdateExceptionListItemOptions): Promise<ExceptionListItemSchema | null> => {
const { savedObjectsClient, user } = this;
return updateExceptionListItem({
let updatedItem: UpdateExceptionListItemOptions = {
_version,
comments,
description,
@ -428,9 +462,26 @@ export class ExceptionListClient {
name,
namespaceType,
osTypes,
savedObjectsClient,
tags,
type,
};
if (this.enableServerExtensionPoints) {
updatedItem = await this.serverExtensionsClient.pipeRun(
'exceptionsListPreUpdateItem',
updatedItem,
(data) => {
return validateData(
updateExceptionListItemSchema,
transformUpdateExceptionListItemOptionsToUpdateExceptionListItemSchema(data)
);
}
);
}
return updateExceptionListItem({
...updatedItem,
savedObjectsClient,
user,
});
};

View file

@ -50,9 +50,14 @@ import {
VersionOrUndefined,
} from '@kbn/securitysolution-io-ts-types';
import { ExtensionPointStorageClientInterface } from '../extension_points';
export interface ConstructorOptions {
user: string;
savedObjectsClient: SavedObjectsClientContract;
serverExtensionsClient: ExtensionPointStorageClientInterface;
/** Set to `false` if wanting to disable executing registered server extension points. Default is true. */
enableServerExtensionPoints?: boolean;
}
export interface GetExceptionListOptions {

View file

@ -0,0 +1,18 @@
/*
* 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 { ErrorWithStatusCode } from '../../../error_with_status_code';
export class DataValidationError extends ErrorWithStatusCode {
constructor(public readonly reason: string[], statusCode: number = 500) {
super('Data validation failure', statusCode);
}
public getReason(): string[] {
return this.reason ?? [];
}
}

View file

@ -11,17 +11,25 @@ import {
CommentsArray,
CreateComment,
CreateCommentsArray,
CreateExceptionListItemSchema,
ExceptionListItemSchema,
ExceptionListSchema,
FoundExceptionListItemSchema,
FoundExceptionListSchema,
UpdateCommentsArrayOrUndefined,
UpdateExceptionListItemSchema,
exceptionListItemType,
exceptionListType,
} from '@kbn/securitysolution-io-ts-list-types';
import { getExceptionListType } from '@kbn/securitysolution-list-utils';
import { ExceptionListSoSchema } from '../../../schemas/saved_objects';
import {
CreateExceptionListItemOptions,
UpdateExceptionListItemOptions,
} from '../exception_list_client_types';
export { validateData } from './validate_data';
export const transformSavedObjectToExceptionList = ({
savedObject,
@ -292,3 +300,42 @@ export const transformCreateCommentsToComments = ({
id: uuid.v4(),
}));
};
export const transformCreateExceptionListItemOptionsToCreateExceptionListItemSchema = ({
listId,
itemId,
namespaceType,
osTypes,
...rest
}: CreateExceptionListItemOptions): CreateExceptionListItemSchema => {
return {
...rest,
item_id: itemId,
list_id: listId,
namespace_type: namespaceType,
os_types: osTypes,
};
};
export const transformUpdateExceptionListItemOptionsToUpdateExceptionListItemSchema = ({
itemId,
namespaceType,
osTypes,
// The `UpdateExceptionListItemOptions` type differs from the schema in that some properties are
// marked as having `undefined` as a valid value, where the schema, however, requires it.
// So we assign defaults here
description = '',
name = '',
type = 'simple',
...rest
}: UpdateExceptionListItemOptions): UpdateExceptionListItemSchema => {
return {
...rest,
description,
item_id: itemId,
name,
namespace_type: namespaceType,
os_types: osTypes,
type,
};
};

View file

@ -0,0 +1,50 @@
/*
* 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 {
CreateExceptionListItemSchema,
createExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { getCreateExceptionListItemOptionsMock } from '../exception_list_client.mock';
import { validateData } from './validate_data';
import { DataValidationError } from './errors';
import { transformCreateExceptionListItemOptionsToCreateExceptionListItemSchema } from './index';
describe('when using `validateData()` utility', () => {
let data: CreateExceptionListItemSchema;
beforeEach(() => {
data = transformCreateExceptionListItemOptionsToCreateExceptionListItemSchema(
getCreateExceptionListItemOptionsMock()
);
});
it('should return `undefined` if data is valid', () => {
expect(validateData(createExceptionListItemSchema, data)).toBeUndefined();
});
it('should return an `DataValidationError` if validation failed', () => {
const { entries, ...modifiedData } = data;
modifiedData.list_id = '';
expect(entries).toBeTruthy(); // Avoid linting error
const validationResult = validateData(
createExceptionListItemSchema,
modifiedData as CreateExceptionListItemSchema
);
expect(validationResult).toBeInstanceOf(DataValidationError);
expect(validationResult?.reason).toEqual([
'Invalid value "undefined" supplied to "entries"',
'Invalid value "" supplied to "list_id"',
]);
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 * as t from 'io-ts';
import { pipe } from 'fp-ts/pipeable';
import { fold } from 'fp-ts/Either';
import { exactCheck, formatErrors } from '@kbn/securitysolution-io-ts-utils';
import { DataValidationError } from './errors';
/**
* Validates that some data is valid by using an `io-ts` schema as the validator.
* Returns either `undefined` if the data is valid, or a `DataValidationError`, which includes
* a `reason` property with the errors encountered
* @param validator
* @param data
*/
export const validateData = <D>(validator: t.Type<D>, data: D): undefined | DataValidationError => {
return pipe(
validator.decode(data),
(decoded) => exactCheck(data, decoded),
fold(
(errors: t.Errors) => {
const errorStrings = formatErrors(errors);
return new DataValidationError(errorStrings, 400);
},
() => undefined
)
);
};

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export class ExtensionPointError extends Error {
public readonly meta?: unknown;
constructor(message: string, meta?: unknown) {
super(message);
this.name = this.constructor.name;
this.meta = meta;
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { MockedLogger, loggerMock } from '@kbn/logging/mocks';
import { ExtensionPointStorage } from './extension_point_storage';
import {
ExceptionListPreUpdateItemServerExtension,
ExceptionsListPreCreateItemServerExtension,
ExtensionPointStorageInterface,
} from './types';
export interface ExtensionPointStorageContextMock {
extensionPointStorage: ExtensionPointStorageInterface;
/** Mocked logger instance used in initializing the ExtensionPointStorage instance */
logger: MockedLogger;
/** An Exception List Item pre-create extension point added to the storage. Appends `-1` to the data's `name` attribute */
exceptionPreCreate: jest.Mocked<ExceptionsListPreCreateItemServerExtension>;
/** An Exception List Item pre-update extension point added to the storage. Appends `-2` to the data's `name` attribute */
exceptionPreUpdate: jest.Mocked<ExceptionListPreUpdateItemServerExtension>;
}
export const createExtensionPointStorageMock = (
logger: ReturnType<typeof loggerMock.create> = loggerMock.create()
): ExtensionPointStorageContextMock => {
const extensionPointStorage = new ExtensionPointStorage(logger);
const exceptionPreCreate: ExtensionPointStorageContextMock['exceptionPreCreate'] = {
callback: jest.fn(async (data) => {
return {
...data,
name: `${data.name}-1`,
};
}),
type: 'exceptionsListPreCreateItem',
};
const exceptionPreUpdate: ExtensionPointStorageContextMock['exceptionPreUpdate'] = {
callback: jest.fn(async (data) => {
return {
...data,
name: `${data.name}-1`,
};
}),
type: 'exceptionsListPreUpdateItem',
};
extensionPointStorage.add(exceptionPreCreate);
extensionPointStorage.add(exceptionPreUpdate);
return {
exceptionPreCreate,
exceptionPreUpdate,
extensionPointStorage,
logger,
};
};

View file

@ -0,0 +1,57 @@
/*
* 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 {
ExceptionsListPreCreateItemServerExtension,
ExtensionPointStorageInterface,
} from './types';
import { createExtensionPointStorageMock } from './extension_point_storage.mock';
import { ExtensionPointStorageClient } from './extension_point_storage_client';
describe('When using ExtensionPointStorage', () => {
let storageService: ExtensionPointStorageInterface;
let preCreateExtensionPointMock: ExceptionsListPreCreateItemServerExtension;
beforeEach(() => {
const storageContext = createExtensionPointStorageMock();
storageService = storageContext.extensionPointStorage;
preCreateExtensionPointMock = storageContext.exceptionPreCreate;
});
it('should be able to add() extension point and get() it', () => {
storageService.add(preCreateExtensionPointMock);
const extensionPointSet = storageService.get('exceptionsListPreCreateItem');
expect(extensionPointSet?.size).toBe(1);
expect(extensionPointSet?.has(preCreateExtensionPointMock)).toBe(true);
});
it('should return `undefined` on get() when no extension points are registered', () => {
storageService.clear();
expect(storageService.get('exceptionsListPreCreateItem')).toBeUndefined();
});
it('should capture `.stack` from where extension point was registered', () => {
storageService.add(preCreateExtensionPointMock);
expect(storageService.getExtensionRegistrationSource(preCreateExtensionPointMock)).toContain(
'extension_point_storage.test'
);
});
it('should clear() all extensions', () => {
storageService.clear();
expect(storageService.get('exceptionsListPreCreateItem')).toBeUndefined();
expect(storageService.get('exceptionsListPreUpdateItem')).toBeUndefined();
});
it('should return a client', () => {
expect(storageService.getClient()).toBeInstanceOf(ExtensionPointStorageClient);
});
});

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from 'kibana/server';
import {
ExtensionPoint,
ExtensionPointStorageClientInterface,
ExtensionPointStorageInterface,
NarrowExtensionPointToType,
} from './types';
import { ExtensionPointStorageClient } from './extension_point_storage_client';
export class ExtensionPointStorage implements ExtensionPointStorageInterface {
private readonly store = new Map<ExtensionPoint['type'], Set<ExtensionPoint>>();
private readonly registeredFrom = new Map<ExtensionPoint, string>();
constructor(private readonly logger: Logger) {}
add(extension: ExtensionPoint): void {
if (!this.store.has(extension.type)) {
this.store.set(extension.type, new Set());
}
const extensionPointsForType = this.store.get(extension.type);
if (extensionPointsForType) {
extensionPointsForType.add(extension);
// Capture stack trace from where this extension point was registered, so that it can be used when
// errors occur or callbacks don't return the expected result
const from = new Error('REGISTERED FROM:').stack ?? 'REGISTERED FROM: unknown';
this.registeredFrom.set(
extension,
from.substring(from.indexOf('REGISTERED FROM:')).concat('\n ----------------------')
);
}
}
clear(): void {
this.store.clear();
this.registeredFrom.clear();
}
getExtensionRegistrationSource(extensionPoint: ExtensionPoint): string | undefined {
return this.registeredFrom.get(extensionPoint);
}
get<T extends ExtensionPoint['type']>(
extensionType: T
): Set<NarrowExtensionPointToType<T>> | undefined {
const extensionDefinitions = this.store.get(extensionType);
if (extensionDefinitions) {
return extensionDefinitions as Set<NarrowExtensionPointToType<T>>;
}
return undefined;
}
/**
* returns a client interface that does not expose the full set of methods available in the storage
*/
getClient(): ExtensionPointStorageClientInterface {
return new ExtensionPointStorageClient(this, this.logger);
}
}

View file

@ -0,0 +1,206 @@
/*
* 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 { loggerMock } from '@kbn/logging/mocks';
import { CreateExceptionListItemOptions } from '../exception_lists/exception_list_client_types';
import { getCreateExceptionListItemOptionsMock } from '../exception_lists/exception_list_client.mock';
import { DataValidationError } from '../exception_lists/utils/errors';
import { ExtensionPointError } from './errors';
import {
ExceptionListPreUpdateItemServerExtension,
ExceptionsListPreCreateItemServerExtension,
ExtensionPoint,
ExtensionPointStorageClientInterface,
ExtensionPointStorageInterface,
} from './types';
import { createExtensionPointStorageMock } from './extension_point_storage.mock';
describe('When using the ExtensionPointStorageClient', () => {
let storageClient: ExtensionPointStorageClientInterface;
let logger: ReturnType<typeof loggerMock.create>;
let extensionPointStorage: ExtensionPointStorageInterface;
let preCreateExtensionPointMock1: jest.Mocked<ExceptionsListPreCreateItemServerExtension>;
let extensionPointsMocks: Array<jest.Mocked<ExtensionPoint>>;
let callbackRunLog: string;
const addAllExtensionPoints = (): void => {
extensionPointsMocks.forEach((extensionPoint) => extensionPointStorage.add(extensionPoint));
};
beforeEach(() => {
const storageContext = createExtensionPointStorageMock();
callbackRunLog = '';
({ logger, extensionPointStorage } = storageContext);
extensionPointStorage.clear();
// Generic callback function that also logs to the `callbackRunLog` its id, so we know the order in which they ran.
// Each callback also appends its `id` to the item's name property, so that we know the value from one callback is
// flowing to the next.
const callbackFn = async <
T extends ExtensionPoint = ExtensionPoint,
A extends Parameters<T['callback']>[0] = Parameters<T['callback']>[0]
>(
id: number,
arg: A
): Promise<A> => {
callbackRunLog += id;
return {
...arg,
name: `${arg.name}-${id}`,
};
};
preCreateExtensionPointMock1 = {
callback: jest.fn(
callbackFn.bind(window, 1) as ExceptionsListPreCreateItemServerExtension['callback']
),
type: 'exceptionsListPreCreateItem',
};
extensionPointsMocks = [
preCreateExtensionPointMock1,
{
callback: jest.fn(
callbackFn.bind(window, 2) as ExceptionsListPreCreateItemServerExtension['callback']
),
type: 'exceptionsListPreCreateItem',
},
{
callback: jest.fn(
callbackFn.bind(window, 3) as ExceptionListPreUpdateItemServerExtension['callback']
),
type: 'exceptionsListPreUpdateItem',
},
{
callback: jest.fn(
callbackFn.bind(window, 4) as ExceptionsListPreCreateItemServerExtension['callback']
),
type: 'exceptionsListPreCreateItem',
},
{
callback: jest.fn(
callbackFn.bind(window, 5) as ExceptionsListPreCreateItemServerExtension['callback']
),
type: 'exceptionsListPreCreateItem',
},
];
storageClient = extensionPointStorage.getClient();
});
it('should get() a Set of extension points by type', () => {
extensionPointStorage.add(preCreateExtensionPointMock1);
const extensionPointSet = storageClient.get('exceptionsListPreCreateItem');
expect(extensionPointSet?.size).toBe(1);
expect(extensionPointSet?.has(preCreateExtensionPointMock1)).toBe(true);
});
it('should return `undefined` when get() does not have any extension points', () => {
expect(storageClient.get('exceptionsListPreUpdateItem')).toBeUndefined();
});
describe('and executing a `pipeRun()`', () => {
let createExceptionListItemOptionsMock: CreateExceptionListItemOptions;
beforeEach(() => {
createExceptionListItemOptionsMock = getCreateExceptionListItemOptionsMock();
addAllExtensionPoints();
});
it('should run extension point callbacks serially', async () => {
await storageClient.pipeRun(
'exceptionsListPreCreateItem',
createExceptionListItemOptionsMock
);
expect(callbackRunLog).toEqual('1245');
});
it('should pass the return value of one extensionPoint to the next', async () => {
await storageClient.pipeRun(
'exceptionsListPreCreateItem',
createExceptionListItemOptionsMock
);
expect(extensionPointsMocks[0].callback).toHaveBeenCalledWith(
createExceptionListItemOptionsMock
);
expect(extensionPointsMocks[1].callback).toHaveBeenCalledWith({
...createExceptionListItemOptionsMock,
name: `${createExceptionListItemOptionsMock.name}-1`,
});
expect(extensionPointsMocks[3].callback).toHaveBeenCalledWith({
...createExceptionListItemOptionsMock,
name: `${createExceptionListItemOptionsMock.name}-1-2`,
});
expect(extensionPointsMocks[4].callback).toHaveBeenCalledWith({
...createExceptionListItemOptionsMock,
name: `${createExceptionListItemOptionsMock.name}-1-2-4`,
});
});
it('should return a data structure similar to the one provided initially', async () => {
const result = await storageClient.pipeRun(
'exceptionsListPreCreateItem',
createExceptionListItemOptionsMock
);
expect(result).toEqual({
...createExceptionListItemOptionsMock,
name: `${createExceptionListItemOptionsMock.name}-1-2-4-5`,
});
});
it("should log an error if extension point callback Throw's", async () => {
const extensionError = new Error('foo');
preCreateExtensionPointMock1.callback.mockImplementation(async () => {
throw extensionError;
});
await storageClient.pipeRun(
'exceptionsListPreCreateItem',
createExceptionListItemOptionsMock
);
expect(logger.error).toHaveBeenCalledWith(expect.any(ExtensionPointError));
expect(logger.error.mock.calls[0][0]).toMatchObject({ meta: extensionError });
});
it('should continue to other extension points after encountering one that `throw`s', async () => {
const extensionError = new Error('foo');
preCreateExtensionPointMock1.callback.mockImplementation(async () => {
throw extensionError;
});
const result = await storageClient.pipeRun(
'exceptionsListPreCreateItem',
createExceptionListItemOptionsMock
);
expect(result).toEqual({
...createExceptionListItemOptionsMock,
name: `${createExceptionListItemOptionsMock.name}-2-4-5`,
});
});
it('should log an error and Throw if external callback returned invalid data', async () => {
const validationError = new DataValidationError(['no bueno!']);
await expect(() =>
storageClient.pipeRun(
'exceptionsListPreCreateItem',
createExceptionListItemOptionsMock,
() => {
return validationError;
}
)
).rejects.toBe(validationError);
expect(logger.error).toHaveBeenCalledWith(expect.any(ExtensionPointError));
expect(logger.error.mock.calls[0][0]).toMatchObject({ meta: { validationError } });
});
});
});

View file

@ -0,0 +1,98 @@
/*
* 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 'kibana/server';
import type {
ExtensionPoint,
ExtensionPointCallbackArgument,
ExtensionPointStorageClientInterface,
ExtensionPointStorageInterface,
NarrowExtensionPointToType,
} from './types';
import { ExtensionPointError } from './errors';
export class ExtensionPointStorageClient implements ExtensionPointStorageClientInterface {
constructor(
private readonly storage: ExtensionPointStorageInterface,
private readonly logger: Logger
) {}
/**
* Retrieve a list (`Set`) of extension points that are registered for a given type
* @param extensionType
*/
get<T extends ExtensionPoint['type']>(
extensionType: T
): Set<NarrowExtensionPointToType<T>> | undefined {
return this.storage.get(extensionType);
}
/**
* Runs a set of callbacks by piping the Response from one extension point callback to the next callback
* and finally returning the last callback payload.
*
* @param extensionType
* @param initialCallbackInput The initial argument given to the first extension point callback
* @param callbackResponseValidator A function to validate the returned data from an extension point callback
*/
async pipeRun<
T extends ExtensionPoint['type'],
D extends NarrowExtensionPointToType<T> = NarrowExtensionPointToType<T>,
P extends Parameters<D['callback']> = Parameters<D['callback']>
>(
extensionType: T,
initialCallbackInput: P[0],
callbackResponseValidator?: (data: P[0]) => Error | undefined
): Promise<P[0]> {
let inputArgument = initialCallbackInput;
const externalExtensions = this.get(extensionType);
if (!externalExtensions || externalExtensions.size === 0) {
return inputArgument;
}
for (const externalExtension of externalExtensions) {
const extensionRegistrationSource =
this.storage.getExtensionRegistrationSource(externalExtension);
try {
inputArgument = await externalExtension.callback(
inputArgument as ExtensionPointCallbackArgument
);
} catch (error) {
// Log the error that the external callback threw and keep going with the running of others
this.logger?.error(
new ExtensionPointError(
`Extension point execution error for ${externalExtension.type}: ${extensionRegistrationSource}`,
error
)
);
}
if (callbackResponseValidator) {
// Before calling the next one, make sure the returned payload is valid
const validationError = callbackResponseValidator(inputArgument);
if (validationError) {
this.logger?.error(
new ExtensionPointError(
`Extension point for ${externalExtension.type} returned data that failed validation: ${extensionRegistrationSource}`,
{
validationError,
}
)
);
throw validationError;
}
}
}
return inputArgument;
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './types';
export { ExtensionPointStorage } from './extension_point_storage';
export type { ExtensionPointStorageInterface, ExtensionPointStorageClientInterface } from './types';

View file

@ -0,0 +1,100 @@
/*
* 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 { PromiseType } from 'utility-types';
import { UnionToIntersection } from '@kbn/utility-types';
import {
CreateExceptionListItemOptions,
UpdateExceptionListItemOptions,
} from '../exception_lists/exception_list_client_types';
export type ServerExtensionCallback<A extends object | void = void, R = unknown> = (
args: A
) => Promise<R>;
interface ServerExtensionPointDefinition<
T extends string,
Args extends object | void = void,
Response = void
> {
type: T;
callback: ServerExtensionCallback<Args, Response>;
}
/**
* Extension point is triggered prior to creating a new Exception List Item. Throw'ing will cause
* the create operation to fail
*/
export type ExceptionsListPreCreateItemServerExtension = ServerExtensionPointDefinition<
'exceptionsListPreCreateItem',
CreateExceptionListItemOptions,
CreateExceptionListItemOptions
>;
/**
* Extension point is triggered prior to updating the Exception List Item. Throw'ing will cause the
* update operation to fail
*/
export type ExceptionListPreUpdateItemServerExtension = ServerExtensionPointDefinition<
'exceptionsListPreUpdateItem',
UpdateExceptionListItemOptions,
UpdateExceptionListItemOptions
>;
export type ExtensionPoint =
| ExceptionsListPreCreateItemServerExtension
| ExceptionListPreUpdateItemServerExtension;
/**
* A Map of extension point type and associated Set of callbacks
*/
/**
* Registration function for server-side extension points
*/
export type ListsServerExtensionRegistrar = (extension: ExtensionPoint) => void;
export type NarrowExtensionPointToType<T extends ExtensionPoint['type']> = {
type: T;
} & ExtensionPoint;
/**
* An intersection of all callback arguments for use internally when
* casting (ex. in `ExtensionPointStorageClient#pipeRun()`
*/
export type ExtensionPointCallbackArgument = UnionToIntersection<
PromiseType<ReturnType<ExtensionPoint['callback']>>
>;
export interface ExtensionPointStorageClientInterface {
get<T extends ExtensionPoint['type']>(
extensionType: T
): Set<NarrowExtensionPointToType<T>> | undefined;
pipeRun<
T extends ExtensionPoint['type'],
D extends NarrowExtensionPointToType<T> = NarrowExtensionPointToType<T>,
P extends Parameters<D['callback']> = Parameters<D['callback']>
>(
extensionType: T,
initialCallbackInput: P[0],
callbackResponseValidator?: (data: P[0]) => Error | undefined
): Promise<P[0]>;
}
export interface ExtensionPointStorageInterface {
add(extension: ExtensionPoint): void;
clear(): void;
getExtensionRegistrationSource(extensionPoint: ExtensionPoint): string | undefined;
get<T extends ExtensionPoint['type']>(
extensionType: T
): Set<NarrowExtensionPointToType<T>> | undefined;
getClient(): ExtensionPointStorageClientInterface;
}

View file

@ -18,6 +18,10 @@ import type { SpacesPluginStart } from '../../spaces/server';
import { ListClient } from './services/lists/list_client';
import { ExceptionListClient } from './services/exception_lists/exception_list_client';
import type {
ExtensionPointStorageClientInterface,
ListsServerExtensionRegistrar,
} from './services/extension_points';
export type ContextProvider = IContextProvider<ListsRequestHandlerContext, 'lists'>;
export type ListsPluginStart = void;
@ -34,12 +38,14 @@ export type GetListClientType = (
export type GetExceptionListClientType = (
savedObjectsClient: SavedObjectsClientContract,
user: string
user: string,
disableServerExtensionPoints?: boolean
) => ExceptionListClient;
export interface ListPluginSetup {
getExceptionListClient: GetExceptionListClientType;
getListClient: GetListClientType;
registerExtension: ListsServerExtensionRegistrar;
}
/**
@ -48,6 +54,7 @@ export interface ListPluginSetup {
export interface ListsApiRequestHandlerContext {
getListClient: () => ListClient;
getExceptionListClient: () => ExceptionListClient;
getExtensionPointClient: () => ExtensionPointStorageClientInterface;
}
/**
@ -65,3 +72,10 @@ export type ListsPluginRouter = IRouter<ListsRequestHandlerContext>;
* @internal
*/
export type ContextProviderReturn = Promise<ListsApiRequestHandlerContext>;
export type {
ExtensionPoint,
ExceptionListPreUpdateItemServerExtension,
ExceptionsListPreCreateItemServerExtension,
ListsServerExtensionRegistrar,
} from './services/extension_points';

View file

@ -6,7 +6,7 @@
*/
import { KibanaRequest, Logger } from 'src/core/server';
import { ExceptionListClient } from '../../../lists/server';
import { CreateExceptionListItemOptions, ExceptionListClient } from '../../../lists/server';
import {
CasesClient,
PluginStartContract as CasesPluginStartContract,
@ -39,6 +39,7 @@ import {
EndpointInternalFleetServicesInterface,
EndpointScopedFleetServicesInterface,
} from './services/endpoint_fleet_services';
import type { ListsServerExtensionRegistrar } from '../../../lists/server';
export interface EndpointAppContextServiceSetupContract {
securitySolutionRequestContextFactory: IRequestContextFactory;
@ -57,6 +58,7 @@ export type EndpointAppContextServiceStartContract = Partial<
alerting: AlertsPluginStartContract;
config: ConfigType;
registerIngestCallback?: FleetStartContract['registerExternalCallback'];
registerListsServerExtension?: ListsServerExtensionRegistrar;
licenseService: LicenseService;
exceptionListsClient: ExceptionListClient | undefined;
cases: CasesPluginStartContract | undefined;
@ -118,6 +120,18 @@ export class EndpointAppContextService {
getPackagePolicyDeleteCallback(dependencies.exceptionListsClient)
);
}
if (this.startDependencies.registerListsServerExtension) {
const { registerListsServerExtension } = this.startDependencies;
registerListsServerExtension({
type: 'exceptionsListPreCreateItem',
callback: async (arg: CreateExceptionListItemOptions) => {
// this.startDependencies?.logger.info('exceptionsListPreCreateItem called!');
return arg;
},
});
}
}
public stop() {}

View file

@ -84,6 +84,7 @@ const createRequestContextMock = (
lists: {
getListClient: jest.fn(() => clients.lists.listClient),
getExceptionListClient: jest.fn(() => clients.lists.exceptionListClient),
getExtensionPointClient: jest.fn(),
},
};
};

View file

@ -433,6 +433,7 @@ export class Plugin implements ISecuritySolutionPlugin {
registerIngestCallback,
licenseService,
exceptionListsClient: exceptionListClient,
registerListsServerExtension: this.lists?.registerExtension,
});
this.telemetryReceiver.start(

View file

@ -6,7 +6,6 @@
*/
import { Logger, KibanaRequest, RequestHandlerContext } from 'kibana/server';
import { ExceptionListClient } from '../../lists/server';
import { DEFAULT_SPACE_ID } from '../common/constants';
import { AppClientFactory } from './client';
@ -116,10 +115,7 @@ export class RequestContextFactory implements IRequestContextFactory {
}
const username = security?.authc.getCurrentUser(request)?.username || 'elastic';
return new ExceptionListClient({
savedObjectsClient: context.core.savedObjects.client,
user: username,
});
return lists.getExceptionListClient(context.core.savedObjects.client, username);
},
};
}