mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
462495c9b1
commit
c5499186ea
25 changed files with 1231 additions and 20 deletions
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 ?? [];
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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"',
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
)
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 } });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
100
x-pack/plugins/lists/server/services/extension_points/types.ts
Normal file
100
x-pack/plugins/lists/server/services/extension_points/types.ts
Normal 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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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() {}
|
||||
|
|
|
@ -84,6 +84,7 @@ const createRequestContextMock = (
|
|||
lists: {
|
||||
getListClient: jest.fn(() => clients.lists.listClient),
|
||||
getExceptionListClient: jest.fn(() => clients.lists.exceptionListClient),
|
||||
getExtensionPointClient: jest.fn(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -433,6 +433,7 @@ export class Plugin implements ISecuritySolutionPlugin {
|
|||
registerIngestCallback,
|
||||
licenseService,
|
||||
exceptionListsClient: exceptionListClient,
|
||||
registerListsServerExtension: this.lists?.registerExtension,
|
||||
});
|
||||
|
||||
this.telemetryReceiver.start(
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue