[Security Solution][Endpoint] New route for create an exception list and return the existing one if it already exists (#139618)

* new route for create an exception list and return the existing one if alredy exists

* Fixes unit test and shows error when ignore_existing set to false and there is a conflict

* Remove query param and update route name to be more specific

* Fixes unit test

* Enforce list_id and type types for internal route. Added unit tests

* Uses existing constants to define list_ids

* Don't create host isolation exeptions api client if not needed when checking links availability

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
David Sánchez 2022-09-08 11:42:35 +02:00 committed by GitHub
parent a6a2706b8d
commit e459752466
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 288 additions and 78 deletions

View file

@ -41,3 +41,6 @@ export * from './update_exception_list_item_validation';
export * from './update_exception_list_schema';
export * from './update_list_item_schema';
export * from './update_list_schema';
// Internal routes
export * from './internal/create_exception_list_schema';

View file

@ -0,0 +1,69 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { left } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { ExceptionListTypeEnum } from '../../../common/exception_list';
import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { internalCreateExceptionListSchema } from '.';
import { getCreateExceptionListSchemaMock } from '../../create_exception_list_schema/index.mock';
describe('create_exception_list_schema', () => {
test('it should accept artifact list_id', () => {
const payload = {
...getCreateExceptionListSchemaMock(),
list_id: ExceptionListTypeEnum.ENDPOINT_BLOCKLISTS,
};
const decoded = internalCreateExceptionListSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should fail when invalid list_id', () => {
const payload = {
...getCreateExceptionListSchemaMock(),
list_id: ExceptionListTypeEnum.DETECTION,
};
const decoded = internalCreateExceptionListSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "detection" supplied to "list_id"',
]);
expect(message.schema).toEqual({});
});
test('it should accept artifact type', () => {
const payload = {
...getCreateExceptionListSchemaMock(),
list_id: ExceptionListTypeEnum.ENDPOINT_BLOCKLISTS,
type: ExceptionListTypeEnum.ENDPOINT_BLOCKLISTS,
};
const decoded = internalCreateExceptionListSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should fail when invalid type', () => {
const payload = {
...getCreateExceptionListSchemaMock(),
list_id: ExceptionListTypeEnum.ENDPOINT_BLOCKLISTS,
type: ExceptionListTypeEnum.DETECTION,
};
const decoded = internalCreateExceptionListSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "detection" supplied to "type"',
]);
expect(message.schema).toEqual({});
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
ENDPOINT_BLOCKLISTS_LIST_ID,
ENDPOINT_EVENT_FILTERS_LIST_ID,
ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID,
ENDPOINT_TRUSTED_APPS_LIST_ID,
} from '@kbn/securitysolution-list-constants';
import * as t from 'io-ts';
import {
createExceptionListSchema,
CreateExceptionListSchemaDecoded,
} from '../../create_exception_list_schema';
export const internalCreateExceptionListSchema = t.intersection([
t.exact(
t.type({
type: t.keyof({
endpoint: null,
endpoint_events: null,
endpoint_host_isolation_exceptions: null,
endpoint_blocklists: null,
}),
})
),
t.exact(
t.partial({
// TODO: Move the ALL_ENDPOINT_ARTIFACT_LIST_IDS inside the package and use it here instead
list_id: t.keyof({
[ENDPOINT_TRUSTED_APPS_LIST_ID]: null,
[ENDPOINT_EVENT_FILTERS_LIST_ID]: null,
[ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: null,
[ENDPOINT_BLOCKLISTS_LIST_ID]: null,
}),
})
),
createExceptionListSchema,
]);
export type InternalCreateExceptionListSchema = t.OutputOf<
typeof internalCreateExceptionListSchema
>;
// This type is used after a decode since some things are defaults after a decode.
export type InternalCreateExceptionListSchemaDecoded = CreateExceptionListSchemaDecoded;

View file

@ -20,6 +20,12 @@ export const LIST_PRIVILEGES_URL = `${LIST_URL}/privileges`;
export const EXCEPTION_LIST_URL = '/api/exception_lists';
export const EXCEPTION_LIST_ITEM_URL = '/api/exception_lists/items';
/**
* Internal exception list routes
*/
export const INTERNAL_EXCEPTION_LIST_URL = `/internal${EXCEPTION_LIST_URL}`;
export const INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL = `${INTERNAL_EXCEPTION_LIST_URL}/_create`;
/**
* Exception list spaces
*/

View file

@ -0,0 +1,69 @@
/*
* 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 { validate } from '@kbn/securitysolution-io-ts-utils';
import {
CreateExceptionListSchemaDecoded,
exceptionListSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { IKibanaResponse, KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
import { SiemResponseFactory, getExceptionListClient } from '../routes';
import { ListsRequestHandlerContext } from '../types';
export const createExceptionListHandler = async (
context: ListsRequestHandlerContext,
request: KibanaRequest<unknown, unknown, CreateExceptionListSchemaDecoded, 'post'>,
response: KibanaResponseFactory,
siemResponse: SiemResponseFactory,
options: { ignoreExisting: boolean } = { ignoreExisting: false }
): Promise<IKibanaResponse> => {
const {
name,
tags,
meta,
namespace_type: namespaceType,
description,
list_id: listId,
type,
version,
} = request.body;
const exceptionLists = await getExceptionListClient(context);
const exceptionList = await exceptionLists.getExceptionList({
id: undefined,
listId,
namespaceType,
});
if (exceptionList != null) {
if (options.ignoreExisting) {
return response.ok({ body: exceptionList });
}
return siemResponse.error({
body: `exception list id: "${listId}" already exists`,
statusCode: 409,
});
} else {
const createdList = await exceptionLists.createExceptionList({
description,
immutable: false,
listId,
meta,
name,
namespaceType,
tags,
type,
version,
});
const [validated, errors] = validate(createdList, exceptionListSchema);
if (errors != null) {
return siemResponse.error({ body: errors, statusCode: 500 });
} else {
return response.ok({ body: validated ?? {} });
}
}
};

View file

@ -5,19 +5,17 @@
* 2.0.
*/
import { validate } from '@kbn/securitysolution-io-ts-utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import {
CreateExceptionListSchemaDecoded,
createExceptionListSchema,
exceptionListSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants';
import type { ListsPluginRouter } from '../types';
import { createExceptionListHandler } from '../handlers/create_exception_list_handler';
import { buildRouteValidation, buildSiemResponse } from './utils';
import { getExceptionListClient } from './utils/get_exception_list_client';
export const createExceptionListRoute = (router: ListsPluginRouter): void => {
router.post(
@ -36,46 +34,7 @@ export const createExceptionListRoute = (router: ListsPluginRouter): void => {
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
const {
name,
tags,
meta,
namespace_type: namespaceType,
description,
list_id: listId,
type,
version,
} = request.body;
const exceptionLists = await getExceptionListClient(context);
const exceptionList = await exceptionLists.getExceptionList({
id: undefined,
listId,
namespaceType,
});
if (exceptionList != null) {
return siemResponse.error({
body: `exception list id: "${listId}" already exists`,
statusCode: 409,
});
} else {
const createdList = await exceptionLists.createExceptionList({
description,
immutable: false,
listId,
meta,
name,
namespaceType,
tags,
type,
version,
});
const [validated, errors] = validate(createdList, exceptionListSchema);
if (errors != null) {
return siemResponse.error({ body: errors, statusCode: 500 });
} else {
return response.ok({ body: validated ?? {} });
}
}
return await createExceptionListHandler(context, request, response, siemResponse);
} catch (err) {
const error = transformError(err);
return siemResponse.error({

View file

@ -44,3 +44,6 @@ export * from './update_exception_list_route';
export * from './update_list_item_route';
export * from './update_list_route';
export * from './utils';
// internal
export * from './internal/create_exceptions_list_route';

View file

@ -31,6 +31,7 @@ import {
findListRoute,
importExceptionsRoute,
importListItemRoute,
internalCreateExceptionListRoute,
patchListItemRoute,
patchListRoute,
readEndpointListItemRoute,
@ -103,4 +104,7 @@ export const initRoutes = (router: ListsPluginRouter, config: ConfigType): void
// exception list items summary
summaryExceptionListRoute(router);
// internal routes
internalCreateExceptionListRoute(router);
};

View file

@ -0,0 +1,48 @@
/*
* 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 { transformError } from '@kbn/securitysolution-es-utils';
import {
InternalCreateExceptionListSchemaDecoded,
internalCreateExceptionListSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL } from '@kbn/securitysolution-list-constants';
import { createExceptionListHandler } from '../../handlers/create_exception_list_handler';
import type { ListsPluginRouter } from '../../types';
import { buildRouteValidation, buildSiemResponse } from '../utils';
export const internalCreateExceptionListRoute = (router: ListsPluginRouter): void => {
router.post(
{
options: {
tags: ['access:lists-all'],
},
path: INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL,
validate: {
body: buildRouteValidation<
typeof internalCreateExceptionListSchema,
InternalCreateExceptionListSchemaDecoded
>(internalCreateExceptionListSchema),
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
return await createExceptionListHandler(context, request, response, siemResponse, {
ignoreExisting: true,
});
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -56,7 +56,10 @@ describe('links', () => {
it('it returns all links without filtering when not having isolation permissions but has at least one host isolation exceptions entry', async () => {
fakeHttpServices.get.mockResolvedValue({ total: 1 });
const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([]));
const filteredLinks = await getManagementFilteredLinks(
coreMockStarted,
getPlugins(['superuser'])
);
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
expect(filteredLinks).toEqual(links);
});

View file

@ -222,11 +222,13 @@ export const getManagementFilteredLinks = async (
plugins.fleet?.authz,
currentUserResponse.roles
);
const hostIsolationExceptionsApiClientInstance = HostIsolationExceptionsApiClient.getInstance(
core.http
);
if (!privileges.canAccessEndpointManagement) {
return getFilteredLinks([SecurityPageName.hostIsolationExceptions]);
}
if (!privileges.canIsolateHost) {
const hostIsolationExceptionsApiClientInstance = HostIsolationExceptionsApiClient.getInstance(
core.http
);
const summaryResponse = await hostIsolationExceptionsApiClientInstance.summary();
if (!summaryResponse.total) {
return getFilteredLinks([SecurityPageName.hostIsolationExceptions]);

View file

@ -7,7 +7,11 @@
import type { CoreStart, HttpSetup } from '@kbn/core/public';
import type { CreateExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types';
import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants';
import {
EXCEPTION_LIST_ITEM_URL,
EXCEPTION_LIST_URL,
INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL,
} from '@kbn/securitysolution-list-constants';
import { coreMock } from '@kbn/core/public/mocks';
import { ExceptionsListItemGenerator } from '../../../../common/endpoint/data_generators/exceptions_list_item_generator';
import { ExceptionsListApiClient } from './exceptions_list_api_client';
@ -62,9 +66,12 @@ describe('Exceptions List Api Client', () => {
const exceptionsListApiClientInstance = getInstance();
expect(fakeHttpServices.post).toHaveBeenCalledTimes(1);
expect(fakeHttpServices.post).toHaveBeenCalledWith(EXCEPTION_LIST_URL, {
body: JSON.stringify(getFakeListDefinition()),
});
expect(fakeHttpServices.post).toHaveBeenCalledWith(
INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL,
{
body: JSON.stringify(getFakeListDefinition()),
}
);
expect(exceptionsListApiClientInstance).toBeDefined();
});
@ -117,22 +124,6 @@ describe('Exceptions List Api Client', () => {
expect(err.response.status).toBe(500);
}
});
it('Creating an instance when list already exists does not throw', async () => {
fakeHttpServices.post.mockRejectedValueOnce({
response: {
status: 409,
},
});
const newFakeListId = 'fakeListIdV4';
const notFailedInstance = new ExceptionsListApiClient(
fakeHttpServices,
newFakeListId,
getFakeListDefinition()
);
await notFailedInstance.find(getQueryParams());
expect(notFailedInstance).toBeDefined();
});
});
describe('Wen using public methods', () => {

View file

@ -9,12 +9,18 @@ import type {
CreateExceptionListItemSchema,
CreateExceptionListSchema,
ExceptionListItemSchema,
ExceptionListSchema,
ExceptionListSummarySchema,
FoundExceptionListItemSchema,
ListId,
UpdateExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants';
import {
EXCEPTION_LIST_ITEM_URL,
EXCEPTION_LIST_URL,
INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL,
} from '@kbn/securitysolution-list-constants';
import type { HttpStart } from '@kbn/core/public';
import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../common/constants';
@ -43,7 +49,7 @@ export class ExceptionsListApiClient {
}
/**
* PrivateStatic method that creates the list and don't throw if list already exists.
* PrivateStatic method that creates the list.
* This method is being used when initializing an instance only once.
*/
private async createExceptionList(): Promise<void> {
@ -55,19 +61,14 @@ export class ExceptionsListApiClient {
new Promise<void>((resolve, reject) => {
const asyncFunction = async () => {
try {
await this.http.post<ExceptionListItemSchema>(EXCEPTION_LIST_URL, {
await this.http.post<ExceptionListSchema>(INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL, {
body: JSON.stringify({ ...this.listDefinition, list_id: this.listId }),
});
resolve();
} catch (err) {
// Ignore 409 errors. List already created
if (err.response?.status !== 409) {
ExceptionsListApiClient.wasListCreated.delete(this.listId);
reject(err);
}
resolve();
ExceptionsListApiClient.wasListCreated.delete(this.listId);
reject(err);
}
};
asyncFunction();