From d63f769636b3f418c0e278dbed062780fda97e5c Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Wed, 9 Dec 2020 11:00:50 +0100 Subject: [PATCH 01/52] [ML] API integration tests - skip GetAnomaliesTableData --- .../apis/ml/results/get_anomalies_table_data.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts b/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts index b485dfb15d64e..0e5cf4488b065 100644 --- a/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts +++ b/x-pack/test/api_integration/apis/ml/results/get_anomalies_table_data.ts @@ -55,7 +55,9 @@ export default ({ getService }: FtrProviderContext) => { await ml.api.cleanMlIndices(); }); - it('should fetch anomalies table data', async () => { + // Failing ES snapshot promotion after a ml-cpp change + // See https://github.com/elastic/kibana/issues/85363 + it.skip('should fetch anomalies table data', async () => { const requestBody = { jobIds: [JOB_CONFIG.job_id], criteriaFields: [{ fieldName: 'detector_index', fieldValue: 0 }], From 73fbf2a703e2ac1754bad920ea2fc0059d4400eb Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 9 Dec 2020 11:05:59 +0100 Subject: [PATCH 02/52] [GS] add tag and dashboard suggestion results (#85144) * initial draft * polish * fix mocks * add tests * tests on suggestions * add comment * add FTR tests * factorize getSearchableTypes * move to bottom --- .../public/api.mock.ts | 28 +- .../saved_objects_tagging_oss/public/api.ts | 29 +- .../saved_objects_tagging_oss/public/index.ts | 1 + x-pack/plugins/global_search/public/mocks.ts | 1 + x-pack/plugins/global_search/public/plugin.ts | 3 +- .../fetch_server_searchable_types.test.ts | 36 ++ .../services/fetch_server_searchable_types.ts | 18 + .../public/services/search_service.mock.ts | 12 +- .../services/search_service.test.mocks.ts | 5 + .../public/services/search_service.test.ts | 159 ++++++-- .../public/services/search_service.ts | 24 ++ x-pack/plugins/global_search/public/types.ts | 8 +- x-pack/plugins/global_search/server/mocks.ts | 2 + x-pack/plugins/global_search/server/plugin.ts | 2 + .../server/routes/get_searchable_types.ts | 24 ++ .../global_search/server/routes/index.test.ts | 10 +- .../global_search/server/routes/index.ts | 2 + .../get_searchable_types.test.ts | 78 ++++ .../server/services/search_service.mock.ts | 12 +- .../server/services/search_service.test.ts | 127 ++++-- .../server/services/search_service.ts | 17 + x-pack/plugins/global_search/server/types.ts | 12 +- .../public/components/search_bar.test.tsx | 8 +- .../public/components/search_bar.tsx | 82 +++- .../global_search_bar/public/plugin.tsx | 2 +- .../suggestions/get_suggestions.test.ts | 170 +++++++++ .../public/suggestions/get_suggestions.ts | 83 ++++ .../public/suggestions/index.ts | 7 + .../public/providers/application.test.ts | 361 ++++++++++-------- .../public/providers/application.ts | 5 +- .../providers/saved_objects/provider.test.ts | 204 +++++----- .../providers/saved_objects/provider.ts | 19 +- x-pack/plugins/lens/public/search_provider.ts | 1 + .../saved_objects_tagging/public/plugin.ts | 1 + .../public/services/tags/tags_cache.ts | 6 +- .../page_objects/navigational_search.ts | 5 + .../global_search/global_search_bar.ts | 41 ++ 37 files changed, 1226 insertions(+), 379 deletions(-) create mode 100644 x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts create mode 100644 x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts create mode 100644 x-pack/plugins/global_search/server/routes/get_searchable_types.ts create mode 100644 x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts create mode 100644 x-pack/plugins/global_search_bar/public/suggestions/index.ts diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index 87a3fd8f5b499..1e66a9baa812e 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -18,10 +18,10 @@ */ import { ITagsClient } from '../common'; -import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent } from './api'; +import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent, ITagsCache } from './api'; -const createClientMock = (): jest.Mocked => { - const mock = { +const createClientMock = () => { + const mock: jest.Mocked = { create: jest.fn(), get: jest.fn(), getAll: jest.fn(), @@ -32,14 +32,25 @@ const createClientMock = (): jest.Mocked => { return mock; }; +const createCacheMock = () => { + const mock: jest.Mocked = { + getState: jest.fn(), + getState$: jest.fn(), + }; + + return mock; +}; + interface SavedObjectsTaggingApiMock { client: jest.Mocked; + cache: jest.Mocked; ui: SavedObjectsTaggingApiUiMock; } const createApiMock = (): SavedObjectsTaggingApiMock => { - const mock = { + const mock: SavedObjectsTaggingApiMock = { client: createClientMock(), + cache: createCacheMock(), ui: createApiUiMock(), }; @@ -50,8 +61,8 @@ type SavedObjectsTaggingApiUiMock = Omit, components: SavedObjectsTaggingApiUiComponentMock; }; -const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { - const mock = { +const createApiUiMock = () => { + const mock: SavedObjectsTaggingApiUiMock = { components: createApiUiComponentsMock(), // TS is very picky with type guards hasTagDecoration: jest.fn() as any, @@ -69,8 +80,8 @@ const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { type SavedObjectsTaggingApiUiComponentMock = jest.Mocked; -const createApiUiComponentsMock = (): SavedObjectsTaggingApiUiComponentMock => { - const mock = { +const createApiUiComponentsMock = () => { + const mock: SavedObjectsTaggingApiUiComponentMock = { TagList: jest.fn(), TagSelector: jest.fn(), SavedObjectSaveModalTagSelector: jest.fn(), @@ -82,6 +93,7 @@ const createApiUiComponentsMock = (): SavedObjectsTaggingApiUiComponentMock => { export const taggingApiMock = { create: createApiMock, createClient: createClientMock, + createCache: createCacheMock, createUi: createApiUiMock, createComponents: createApiUiComponentsMock, }; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 81f7cc9326a77..987930af1e3e4 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -17,22 +17,49 @@ * under the License. */ +import { Observable } from 'rxjs'; import { SearchFilterConfig, EuiTableFieldDataColumnType } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import { SavedObject, SavedObjectReference } from '../../../core/types'; import { SavedObjectsFindOptionsReference } from '../../../core/public'; import { SavedObject as SavedObjectClass } from '../../saved_objects/public'; import { TagDecoratedSavedObject } from './decorator'; -import { ITagsClient } from '../common'; +import { ITagsClient, Tag } from '../common'; /** * @public */ export interface SavedObjectsTaggingApi { + /** + * The client to perform tag-related operations on the server-side + */ client: ITagsClient; + /** + * A client-side auto-refreshing cache of the existing tags. Can be used + * to synchronously access the list of tags. + */ + cache: ITagsCache; + /** + * UI API to use to add tagging capabilities to an application + */ ui: SavedObjectsTaggingApiUi; } +/** + * @public + */ +export interface ITagsCache { + /** + * Return the current state of the cache + */ + getState(): Tag[]; + + /** + * Return an observable that will emit everytime the cache's state mutates. + */ + getState$(): Observable; +} + /** * @public */ diff --git a/src/plugins/saved_objects_tagging_oss/public/index.ts b/src/plugins/saved_objects_tagging_oss/public/index.ts index bc824621830d2..ef3087f944add 100644 --- a/src/plugins/saved_objects_tagging_oss/public/index.ts +++ b/src/plugins/saved_objects_tagging_oss/public/index.ts @@ -26,6 +26,7 @@ export { SavedObjectsTaggingApi, SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent, + ITagsCache, TagListComponentProps, TagSelectorComponentProps, GetSearchBarFilterOptions, diff --git a/x-pack/plugins/global_search/public/mocks.ts b/x-pack/plugins/global_search/public/mocks.ts index 97dc01e92dbfe..8b0bfec66f61d 100644 --- a/x-pack/plugins/global_search/public/mocks.ts +++ b/x-pack/plugins/global_search/public/mocks.ts @@ -20,6 +20,7 @@ const createStartMock = (): jest.Mocked => { return { find: searchMock.find, + getSearchableTypes: searchMock.getSearchableTypes, }; }; diff --git a/x-pack/plugins/global_search/public/plugin.ts b/x-pack/plugins/global_search/public/plugin.ts index 6af8ec32a581d..a861911d935b4 100644 --- a/x-pack/plugins/global_search/public/plugin.ts +++ b/x-pack/plugins/global_search/public/plugin.ts @@ -45,13 +45,14 @@ export class GlobalSearchPlugin start({ http }: CoreStart, { licensing }: GlobalSearchPluginStartDeps) { this.licenseChecker = new LicenseChecker(licensing.license$); - const { find } = this.searchService.start({ + const { find, getSearchableTypes } = this.searchService.start({ http, licenseChecker: this.licenseChecker, }); return { find, + getSearchableTypes, }; } diff --git a/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts new file mode 100644 index 0000000000000..002ea0cff20d8 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { fetchServerSearchableTypes } from './fetch_server_searchable_types'; + +describe('fetchServerSearchableTypes', () => { + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + }); + + it('perform a GET request to the endpoint with valid options', () => { + http.get.mockResolvedValue({ results: [] }); + + fetchServerSearchableTypes(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith('/internal/global_search/searchable_types'); + }); + + it('returns the results from the server', async () => { + const types = ['typeA', 'typeB']; + + http.get.mockResolvedValue({ types }); + + const results = await fetchServerSearchableTypes(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(results).toEqual(types); + }); +}); diff --git a/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts new file mode 100644 index 0000000000000..c4a0724991870 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpStart } from 'src/core/public'; + +interface ServerSearchableTypesResponse { + types: string[]; +} + +export const fetchServerSearchableTypes = async (http: HttpStart) => { + const { types } = await http.get( + '/internal/global_search/searchable_types' + ); + return types; +}; diff --git a/x-pack/plugins/global_search/public/services/search_service.mock.ts b/x-pack/plugins/global_search/public/services/search_service.mock.ts index eca69148288b9..0aa65e39f026c 100644 --- a/x-pack/plugins/global_search/public/services/search_service.mock.ts +++ b/x-pack/plugins/global_search/public/services/search_service.mock.ts @@ -7,17 +7,21 @@ import { SearchServiceSetup, SearchServiceStart } from './search_service'; import { of } from 'rxjs'; -const createSetupMock = (): jest.Mocked => { - return { +const createSetupMock = () => { + const mock: jest.Mocked = { registerResultProvider: jest.fn(), }; + + return mock; }; -const createStartMock = (): jest.Mocked => { - const mock = { +const createStartMock = () => { + const mock: jest.Mocked = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; mock.find.mockReturnValue(of({ results: [] })); + mock.getSearchableTypes.mockResolvedValue([]); return mock; }; diff --git a/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts index 1caabd6a1681c..bbc513c78759e 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts @@ -9,6 +9,11 @@ jest.doMock('./fetch_server_results', () => ({ fetchServerResults: fetchServerResultsMock, })); +export const fetchServerSearchableTypesMock = jest.fn(); +jest.doMock('./fetch_server_searchable_types', () => ({ + fetchServerSearchableTypes: fetchServerSearchableTypesMock, +})); + export const getDefaultPreferenceMock = jest.fn(); jest.doMock('./utils', () => { const original = jest.requireActual('./utils'); diff --git a/x-pack/plugins/global_search/public/services/search_service.test.ts b/x-pack/plugins/global_search/public/services/search_service.test.ts index 419ad847d6c29..297a27e3c837c 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchServerResultsMock, getDefaultPreferenceMock } from './search_service.test.mocks'; +import { + fetchServerResultsMock, + getDefaultPreferenceMock, + fetchServerSearchableTypesMock, +} from './search_service.test.mocks'; import { Observable, of } from 'rxjs'; import { take } from 'rxjs/operators'; @@ -41,10 +45,17 @@ describe('SearchService', () => { const createProvider = ( id: string, - source: Observable = of([]) + { + source = of([]), + types = [], + }: { + source?: Observable; + types?: string[] | Promise; + } = {} ): jest.Mocked => ({ id, find: jest.fn().mockImplementation((term, options, context) => source), + getSearchableTypes: jest.fn().mockReturnValue(types), }); const expectedResult = (id: string) => expect.objectContaining({ id }); @@ -85,6 +96,9 @@ describe('SearchService', () => { fetchServerResultsMock.mockClear(); fetchServerResultsMock.mockReturnValue(of()); + fetchServerSearchableTypesMock.mockClear(); + fetchServerSearchableTypesMock.mockResolvedValue([]); + getDefaultPreferenceMock.mockClear(); getDefaultPreferenceMock.mockReturnValue('default_pref'); }); @@ -189,7 +203,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -229,22 +243,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [providerResult('A1'), providerResult('A2')], d: [providerResult('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [providerResult('B1')], c: [providerResult('B2'), providerResult('B3')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -272,13 +284,12 @@ describe('SearchService', () => { ); registerResultProvider( - createProvider( - 'A', - hot('a-b-|', { + createProvider('A', { + source: hot('a-b-|', { a: [providerResult('P1')], b: [providerResult('P2')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -302,7 +313,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const aborted$ = hot('----a--|', { a: undefined }); @@ -326,7 +337,7 @@ describe('SearchService', () => { b: [providerResult('2')], c: [providerResult('3')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -346,22 +357,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [providerResult('A1'), providerResult('A2')], d: [providerResult('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [providerResult('B1')], c: [providerResult('B2'), providerResult('B3')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -394,7 +403,7 @@ describe('SearchService', () => { url: { path: '/foo', prependBasePath: false }, }); - const provider = createProvider('A', of([resultA, resultB])); + const provider = createProvider('A', { source: of([resultA, resultB]) }); registerResultProvider(provider); const { find } = service.start(startDeps()); @@ -423,7 +432,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -438,5 +447,91 @@ describe('SearchService', () => { }); }); }); + + describe('#getSearchableTypes()', () => { + it('returns the types registered by the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('returns the types registered by the server', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'server-b']); + + service.setup({ + config: createConfig(), + }); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types).toEqual(['server-a', 'server-b']); + }); + + it('merges the types registered by the providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-c', 'type-d'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['type-a', 'type-b', 'type-c', 'type-d']); + }); + + it('merges the types registered by the providers and the server', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'server-b']); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['server-a', 'server-b', 'type-a', 'type-b']); + }); + + it('removes duplicates', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'dupe-1']); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'dupe-1', 'dupe-2'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-b', 'dupe-2'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['dupe-1', 'dupe-2', 'server-a', 'type-a', 'type-b']); + }); + }); }); }); diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 64bd2fd6c930f..015143d34886f 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -6,6 +6,7 @@ import { merge, Observable, timer, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { uniq } from 'lodash'; import { duration } from 'moment'; import { i18n } from '@kbn/i18n'; import { HttpStart } from 'src/core/public'; @@ -24,6 +25,7 @@ import { GlobalSearchClientConfigType } from '../config'; import { GlobalSearchFindOptions } from './types'; import { getDefaultPreference } from './utils'; import { fetchServerResults } from './fetch_server_results'; +import { fetchServerSearchableTypes } from './fetch_server_searchable_types'; /** @public */ export interface SearchServiceSetup { @@ -75,6 +77,11 @@ export interface SearchServiceStart { params: GlobalSearchFindParams, options: GlobalSearchFindOptions ): Observable; + + /** + * Returns all the searchable types registered by the underlying result providers. + */ + getSearchableTypes(): Promise; } interface SetupDeps { @@ -96,6 +103,7 @@ export class SearchService { private http?: HttpStart; private maxProviderResults = defaultMaxProviderResults; private licenseChecker?: ILicenseChecker; + private serverTypes?: string[]; setup({ config, maxProviderResults = defaultMaxProviderResults }: SetupDeps): SearchServiceSetup { this.config = config; @@ -118,9 +126,25 @@ export class SearchService { return { find: (params, options) => this.performFind(params, options), + getSearchableTypes: () => this.getSearchableTypes(), }; } + private async getSearchableTypes() { + const providerTypes = ( + await Promise.all( + [...this.providers.values()].map((provider) => provider.getSearchableTypes()) + ) + ).flat(); + + // only need to fetch from server once + if (!this.serverTypes) { + this.serverTypes = await fetchServerSearchableTypes(this.http!); + } + + return uniq([...providerTypes, ...this.serverTypes]); + } + private performFind(params: GlobalSearchFindParams, options: GlobalSearchFindOptions) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { diff --git a/x-pack/plugins/global_search/public/types.ts b/x-pack/plugins/global_search/public/types.ts index 2707a2fded222..7235347d4aa38 100644 --- a/x-pack/plugins/global_search/public/types.ts +++ b/x-pack/plugins/global_search/public/types.ts @@ -13,7 +13,7 @@ import { import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; -export type GlobalSearchPluginStart = Pick; +export type GlobalSearchPluginStart = Pick; /** * GlobalSearch result provider, to be registered using the {@link GlobalSearchPluginSetup | global search API} @@ -44,4 +44,10 @@ export interface GlobalSearchResultProvider { search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions ): Observable; + + /** + * Method that should return all the possible {@link GlobalSearchProviderResult.type | type} of results that + * this provider can return. + */ + getSearchableTypes: () => string[] | Promise; } diff --git a/x-pack/plugins/global_search/server/mocks.ts b/x-pack/plugins/global_search/server/mocks.ts index e7c133edf95c8..88be7f6e861a1 100644 --- a/x-pack/plugins/global_search/server/mocks.ts +++ b/x-pack/plugins/global_search/server/mocks.ts @@ -26,12 +26,14 @@ const createStartMock = (): jest.Mocked => { return { find: searchMock.find, + getSearchableTypes: searchMock.getSearchableTypes, }; }; const createRouteHandlerContextMock = (): jest.Mocked => { const handlerContextMock = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; handlerContextMock.find.mockReturnValue(of([])); diff --git a/x-pack/plugins/global_search/server/plugin.ts b/x-pack/plugins/global_search/server/plugin.ts index 87e7f96b34c0c..9d6844dde50f0 100644 --- a/x-pack/plugins/global_search/server/plugin.ts +++ b/x-pack/plugins/global_search/server/plugin.ts @@ -59,6 +59,7 @@ export class GlobalSearchPlugin core.http.registerRouteHandlerContext('globalSearch', (_, req) => { return { find: (term, options) => this.searchServiceStart!.find(term, options, req), + getSearchableTypes: () => this.searchServiceStart!.getSearchableTypes(req), }; }); @@ -75,6 +76,7 @@ export class GlobalSearchPlugin }); return { find: this.searchServiceStart.find, + getSearchableTypes: this.searchServiceStart.getSearchableTypes, }; } diff --git a/x-pack/plugins/global_search/server/routes/get_searchable_types.ts b/x-pack/plugins/global_search/server/routes/get_searchable_types.ts new file mode 100644 index 0000000000000..f9cc69e4a28ae --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/get_searchable_types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; + +export const registerInternalSearchableTypesRoute = (router: IRouter) => { + router.get( + { + path: '/internal/global_search/searchable_types', + validate: false, + }, + async (ctx, req, res) => { + const types = await ctx.globalSearch!.getSearchableTypes(); + return res.ok({ + body: { + types, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/global_search/server/routes/index.test.ts b/x-pack/plugins/global_search/server/routes/index.test.ts index 64675bc13cb1c..1111f01d13055 100644 --- a/x-pack/plugins/global_search/server/routes/index.test.ts +++ b/x-pack/plugins/global_search/server/routes/index.test.ts @@ -14,7 +14,6 @@ describe('registerRoutes', () => { registerRoutes(router); expect(router.post).toHaveBeenCalledTimes(1); - expect(router.post).toHaveBeenCalledWith( expect.objectContaining({ path: '/internal/global_search/find', @@ -22,7 +21,14 @@ describe('registerRoutes', () => { expect.any(Function) ); - expect(router.get).toHaveBeenCalledTimes(0); + expect(router.get).toHaveBeenCalledTimes(1); + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/internal/global_search/searchable_types', + }), + expect.any(Function) + ); + expect(router.delete).toHaveBeenCalledTimes(0); expect(router.put).toHaveBeenCalledTimes(0); }); diff --git a/x-pack/plugins/global_search/server/routes/index.ts b/x-pack/plugins/global_search/server/routes/index.ts index 7840b95614993..0eeb443b72b53 100644 --- a/x-pack/plugins/global_search/server/routes/index.ts +++ b/x-pack/plugins/global_search/server/routes/index.ts @@ -6,7 +6,9 @@ import { IRouter } from 'src/core/server'; import { registerInternalFindRoute } from './find'; +import { registerInternalSearchableTypesRoute } from './get_searchable_types'; export const registerRoutes = (router: IRouter) => { registerInternalFindRoute(router); + registerInternalSearchableTypesRoute(router); }; diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts new file mode 100644 index 0000000000000..b3b6862599d6d --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { setupServer } from '../../../../../../src/core/server/test_utils'; +import { globalSearchPluginMock } from '../../mocks'; +import { registerInternalSearchableTypesRoute } from '../get_searchable_types'; + +type SetupServerReturn = UnwrapPromise>; +const pluginId = Symbol('globalSearch'); + +describe('GET /internal/global_search/searchable_types', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let globalSearchHandlerContext: ReturnType< + typeof globalSearchPluginMock.createRouteHandlerContext + >; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(pluginId)); + + globalSearchHandlerContext = globalSearchPluginMock.createRouteHandlerContext(); + httpSetup.registerRouteHandlerContext( + pluginId, + 'globalSearch', + () => globalSearchHandlerContext + ); + + const router = httpSetup.createRouter('/'); + + registerInternalSearchableTypesRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('calls the handler context with correct parameters', async () => { + await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(globalSearchHandlerContext.getSearchableTypes).toHaveBeenCalledTimes(1); + }); + + it('returns the types returned from the service', async () => { + globalSearchHandlerContext.getSearchableTypes.mockResolvedValue(['type-a', 'type-b']); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(response.body).toEqual({ + types: ['type-a', 'type-b'], + }); + }); + + it('returns the default error when the observable throws any other error', async () => { + globalSearchHandlerContext.getSearchableTypes.mockRejectedValue(new Error()); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(response.body).toEqual( + expect.objectContaining({ + message: 'An internal server error occurred.', + statusCode: 500, + }) + ); + }); +}); diff --git a/x-pack/plugins/global_search/server/services/search_service.mock.ts b/x-pack/plugins/global_search/server/services/search_service.mock.ts index eca69148288b9..0aa65e39f026c 100644 --- a/x-pack/plugins/global_search/server/services/search_service.mock.ts +++ b/x-pack/plugins/global_search/server/services/search_service.mock.ts @@ -7,17 +7,21 @@ import { SearchServiceSetup, SearchServiceStart } from './search_service'; import { of } from 'rxjs'; -const createSetupMock = (): jest.Mocked => { - return { +const createSetupMock = () => { + const mock: jest.Mocked = { registerResultProvider: jest.fn(), }; + + return mock; }; -const createStartMock = (): jest.Mocked => { - const mock = { +const createStartMock = () => { + const mock: jest.Mocked = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; mock.find.mockReturnValue(of({ results: [] })); + mock.getSearchableTypes.mockResolvedValue([]); return mock; }; diff --git a/x-pack/plugins/global_search/server/services/search_service.test.ts b/x-pack/plugins/global_search/server/services/search_service.test.ts index c8d656a524e94..b3e4981b35392 100644 --- a/x-pack/plugins/global_search/server/services/search_service.test.ts +++ b/x-pack/plugins/global_search/server/services/search_service.test.ts @@ -36,10 +36,17 @@ describe('SearchService', () => { const createProvider = ( id: string, - source: Observable = of([]) + { + source = of([]), + types = [], + }: { + source?: Observable; + types?: string[] | Promise; + } = {} ): jest.Mocked => ({ id, find: jest.fn().mockImplementation((term, options, context) => source), + getSearchableTypes: jest.fn().mockReturnValue(types), }); const expectedResult = (id: string) => expect.objectContaining({ id }); @@ -122,7 +129,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -142,22 +149,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [result('A1'), result('A2')], d: [result('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [result('B1')], c: [result('B2'), result('B3')], - }) - ) + }), + }) ); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -183,7 +188,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const aborted$ = hot('----a--|', { a: undefined }); @@ -208,7 +213,7 @@ describe('SearchService', () => { b: [result('2')], c: [result('3')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -229,22 +234,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [result('A1'), result('A2')], d: [result('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [result('B1')], c: [result('B2'), result('B3')], - }) - ) + }), + }) ); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -278,7 +281,7 @@ describe('SearchService', () => { url: { path: '/foo', prependBasePath: false }, }); - const provider = createProvider('A', of([resultA, resultB])); + const provider = createProvider('A', { source: of([resultA, resultB]) }); registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -308,7 +311,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -323,5 +326,77 @@ describe('SearchService', () => { }); }); }); + + describe('#getSearchableTypes()', () => { + it('returns the types registered by the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('supports promises', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A', { types: Promise.resolve(['type-a', 'type-b']) }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('merges the types registered by the providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-c', 'type-d'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types.sort()).toEqual(['type-a', 'type-b', 'type-c', 'type-d']); + }); + + it('removes duplicates', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider1 = createProvider('A', { types: ['type-a', 'dupe'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-b', 'dupe'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types.sort()).toEqual(['dupe', 'type-a', 'type-b']); + }); + }); }); }); diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index 9ea62abac704c..88250820861a6 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -6,6 +6,7 @@ import { Observable, timer, merge, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { uniq } from 'lodash'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; import { @@ -71,6 +72,11 @@ export interface SearchServiceStart { options: GlobalSearchFindOptions, request: KibanaRequest ): Observable; + + /** + * Returns all the searchable types registered by the underlying result providers. + */ + getSearchableTypes(request: KibanaRequest): Promise; } interface SetupDeps { @@ -119,9 +125,20 @@ export class SearchService { this.contextFactory = getContextFactory(core); return { find: (params, options, request) => this.performFind(params, options, request), + getSearchableTypes: (request) => this.getSearchableTypes(request), }; } + private async getSearchableTypes(request: KibanaRequest) { + const context = this.contextFactory!(request); + const allTypes = ( + await Promise.all( + [...this.providers.values()].map((provider) => provider.getSearchableTypes(context)) + ) + ).flat(); + return uniq(allTypes); + } + private performFind( params: GlobalSearchFindParams, options: GlobalSearchFindOptions, diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts index 0878a965ea8c3..48c40fdb66e13 100644 --- a/x-pack/plugins/global_search/server/types.ts +++ b/x-pack/plugins/global_search/server/types.ts @@ -22,7 +22,7 @@ import { import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; -export type GlobalSearchPluginStart = Pick; +export type GlobalSearchPluginStart = Pick; /** * globalSearch route handler context. @@ -37,6 +37,10 @@ export interface RouteHandlerGlobalSearchContext { params: GlobalSearchFindParams, options: GlobalSearchFindOptions ): Observable; + /** + * See {@link SearchServiceStart.getSearchableTypes | the getSearchableTypes API} + */ + getSearchableTypes: () => Promise; } /** @@ -114,4 +118,10 @@ export interface GlobalSearchResultProvider { options: GlobalSearchProviderFindOptions, context: GlobalSearchProviderContext ): Observable; + + /** + * Method that should return all the possible {@link GlobalSearchProviderResult.type | type} of results that + * this provider can return. + */ + getSearchableTypes: (context: GlobalSearchProviderContext) => string[] | Promise; } diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 5ba00c293d213..1ed011d3cc3b1 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -11,8 +11,8 @@ import { of, BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { mountWithIntl } from '@kbn/test/jest'; import { applicationServiceMock } from '../../../../../src/core/public/mocks'; -import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { globalSearchPluginMock } from '../../../global_search/public/mocks'; +import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { SearchBar } from './search_bar'; type Result = { id: string; type: string } | string; @@ -86,7 +86,7 @@ describe('SearchBar', () => { component = mountWithIntl( { it('supports keyboard shortcuts', () => { mountWithIntl( { component = mountWithIntl( void; taggingApi?: SavedObjectTaggingPluginStart; @@ -43,16 +45,19 @@ interface Props { darkMode: boolean; } -const clearField = (field: HTMLInputElement) => { +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + +const setFieldValue = (field: HTMLInputElement, value: string) => { const nativeInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); const nativeInputValueSetter = nativeInputValue ? nativeInputValue.set : undefined; if (nativeInputValueSetter) { - nativeInputValueSetter.call(field, ''); + nativeInputValueSetter.call(field, value); } - field.dispatchEvent(new Event('change')); }; +const clearField = (field: HTMLInputElement) => setFieldValue(field, ''); + const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' '); const blurEvent = new FocusEvent('blur'); @@ -92,6 +97,19 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi return option; }; +const suggestionToOption = (suggestion: SearchSuggestion): EuiSelectableTemplateSitewideOption => { + const { key, label, description, icon, suggestedSearch } = suggestion; + return { + key, + label, + type: '__suggestion__', + icon: { type: icon }, + suggestion: suggestedSearch, + meta: [{ text: description }], + 'data-test-subj': `nav-search-option`, + }; +}; + export function SearchBar({ globalSearch, taggingApi, @@ -105,16 +123,34 @@ export function SearchBar({ const [searchRef, setSearchRef] = useState(null); const [buttonRef, setButtonRef] = useState(null); const searchSubscription = useRef(null); - const [options, _setOptions] = useState([] as EuiSelectableTemplateSitewideOption[]); - const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + const [options, _setOptions] = useState([]); + const [searchableTypes, setSearchableTypes] = useState([]); + + useEffect(() => { + const fetch = async () => { + const types = await globalSearch.getSearchableTypes(); + setSearchableTypes(types); + }; + fetch(); + }, [globalSearch]); + + const loadSuggestions = useCallback( + (searchTerm: string) => { + return getSuggestions({ + searchTerm, + searchableTypes, + tagCache: taggingApi?.cache, + }); + }, + [taggingApi, searchableTypes] + ); const setOptions = useCallback( - (_options: GlobalSearchResult[]) => { + (_options: GlobalSearchResult[], suggestions: SearchSuggestion[]) => { if (!isMounted()) { return; } - - _setOptions(_options.map(resultToOption)); + _setOptions([...suggestions.map(suggestionToOption), ..._options.map(resultToOption)]); }, [isMounted, _setOptions] ); @@ -127,7 +163,9 @@ export function SearchBar({ searchSubscription.current = null; } - let arr: GlobalSearchResult[] = []; + const suggestions = loadSuggestions(searchValue); + + let aggregatedResults: GlobalSearchResult[] = []; if (searchValue.length !== 0) { trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); } @@ -145,20 +183,20 @@ export function SearchBar({ tags: tagIds, }; - searchSubscription.current = globalSearch(searchParams, {}).subscribe({ + searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { - arr = [...results, ...arr].sort(sortByScore); - setOptions(arr); + aggregatedResults = [...results, ...aggregatedResults].sort(sortByScore); + setOptions(aggregatedResults, suggestions); return; } // if searchbar is empty, filter to only applications and sort alphabetically results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); - arr = [...results, ...arr].sort(sortByTitle); + aggregatedResults = [...results, ...aggregatedResults].sort(sortByTitle); - setOptions(arr); + setOptions(aggregatedResults, suggestions); }, error: () => { // Not doing anything on error right now because it'll either just show the previous @@ -169,7 +207,7 @@ export function SearchBar({ }); }, 350, - [searchValue] + [searchValue, loadSuggestions] ); const onKeyDown = (event: KeyboardEvent) => { @@ -191,7 +229,15 @@ export function SearchBar({ } // @ts-ignore - ts error is "union type is too complex to express" - const { url, type } = selected; + const { url, type, suggestion } = selected; + + // if the type is a suggestion, we change the query on the input and trigger a new search + // by setting the searchValue (only setting the field value does not trigger a search) + if (type === '__suggestion__') { + setFieldValue(searchRef!, suggestion); + setSearchValue(suggestion); + return; + } // errors in tracking should not prevent selection behavior try { diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx index 0d17bf4612737..80111e7746a75 100644 --- a/x-pack/plugins/global_search_bar/public/plugin.tsx +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -70,7 +70,7 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { ReactDOM.render( = {}): Tag => ({ + id: 'tag-id', + name: 'some-tag', + description: 'Some tag', + color: '#FF00CC', + ...parts, +}); + +describe('getSuggestions', () => { + let tagCache: ReturnType; + const searchableTypes = ['application', 'dashboard', 'maps']; + + beforeEach(() => { + tagCache = taggingApiMock.createCache(); + + tagCache.getState.mockReturnValue([ + createTag({ + id: 'basic', + name: 'normal', + }), + createTag({ + id: 'caps', + name: 'BAR', + }), + createTag({ + id: 'whitespace', + name: 'white space', + }), + ]); + }); + + describe('tag suggestion', () => { + it('returns a suggestion when matching the name of a tag', () => { + const suggestions = getSuggestions({ + searchTerm: 'normal', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: normal', + suggestedSearch: 'tag:normal', + }) + ); + }); + it('ignores leading or trailing spaces a suggestion when matching the name of a tag', () => { + const suggestions = getSuggestions({ + searchTerm: ' normal ', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: normal', + suggestedSearch: 'tag:normal', + }) + ); + }); + it('does not return suggestions when partially matching', () => { + const suggestions = getSuggestions({ + searchTerm: 'norm', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(0); + }); + it('ignores the case when matching the tag', () => { + const suggestions = getSuggestions({ + searchTerm: 'baR', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: BAR', + suggestedSearch: 'tag:BAR', + }) + ); + }); + it('escapes the name in the query when containing whitespaces', () => { + const suggestions = getSuggestions({ + searchTerm: 'white space', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: white space', + suggestedSearch: 'tag:"white space"', + }) + ); + }); + }); + + describe('type suggestion', () => { + it('returns a suggestion when matching a searchable type', () => { + const suggestions = getSuggestions({ + searchTerm: 'application', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: application', + suggestedSearch: 'type:application', + }) + ); + }); + it('ignores leading or trailing spaces in the search term', () => { + const suggestions = getSuggestions({ + searchTerm: ' application ', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: application', + suggestedSearch: 'type:application', + }) + ); + }); + it('does not return suggestions when partially matching', () => { + const suggestions = getSuggestions({ + searchTerm: 'appl', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(0); + }); + it('ignores the case when matching the type', () => { + const suggestions = getSuggestions({ + searchTerm: 'DASHboard', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: dashboard', + suggestedSearch: 'type:dashboard', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts b/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts new file mode 100644 index 0000000000000..c097e365045af --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ITagsCache } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; + +interface GetSuggestionOptions { + searchTerm: string; + searchableTypes: string[]; + tagCache?: ITagsCache; +} + +export interface SearchSuggestion { + key: string; + label: string; + description: string; + icon: string; + suggestedSearch: string; +} + +export const getSuggestions = ({ + searchTerm, + searchableTypes, + tagCache, +}: GetSuggestionOptions): SearchSuggestion[] => { + const results: SearchSuggestion[] = []; + const suggestionTerm = searchTerm.trim(); + + const matchingType = findIgnoreCase(searchableTypes, suggestionTerm); + if (matchingType) { + const suggestedSearch = escapeIfWhiteSpaces(matchingType); + results.push({ + key: '__type__suggestion__', + label: `type: ${matchingType}`, + icon: 'filter', + description: i18n.translate('xpack.globalSearchBar.suggestions.filterByTypeLabel', { + defaultMessage: 'Filter by type', + }), + suggestedSearch: `type:${suggestedSearch}`, + }); + } + + if (tagCache && searchTerm) { + const matchingTag = tagCache + .getState() + .find((tag) => equalsIgnoreCase(tag.name, suggestionTerm)); + if (matchingTag) { + const suggestedSearch = escapeIfWhiteSpaces(matchingTag.name); + results.push({ + key: '__tag__suggestion__', + label: `tag: ${matchingTag.name}`, + icon: 'tag', + description: i18n.translate('xpack.globalSearchBar.suggestions.filterByTagLabel', { + defaultMessage: 'Filter by tag name', + }), + suggestedSearch: `tag:${suggestedSearch}`, + }); + } + } + + return results; +}; + +const findIgnoreCase = (array: string[], target: string) => { + for (const item of array) { + if (equalsIgnoreCase(item, target)) { + return item; + } + } + return undefined; +}; + +const equalsIgnoreCase = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +const escapeIfWhiteSpaces = (term: string) => { + if (/\s/g.test(term)) { + return `"${term}"`; + } + return term; +}; diff --git a/x-pack/plugins/global_search_bar/public/suggestions/index.ts b/x-pack/plugins/global_search_bar/public/suggestions/index.ts new file mode 100644 index 0000000000000..aa1402a93692b --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/suggestions/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getSuggestions, SearchSuggestion } from './get_suggestions'; diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts index 7beed42de4c4f..dadcf626ace4a 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.test.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -71,205 +71,228 @@ describe('applicationResultProvider', () => { expect(provider.id).toBe('application'); }); - it('calls `getAppResults` with the term and the list of apps', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - createApp({ id: 'app3', title: 'App 3' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - - await provider.find({ term: 'term' }, defaultOption).toPromise(); - - expect(getAppResultsMock).toHaveBeenCalledTimes(1); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [ - expectApp('app1'), - expectApp('app2'), - expectApp('app3'), - ]); - }); - - it('calls `getAppResults` when filtering by type with `application` included', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - - await provider - .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) - .toPromise(); + describe('#find', () => { + it('calls `getAppResults` with the term and the list of apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + expectApp('app3'), + ]); + }); - expect(getAppResultsMock).toHaveBeenCalledTimes(1); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1'), expectApp('app2')]); - }); + it('calls `getAppResults` when filtering by type with `application` included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider + .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + ]); + }); - it('does not call `getAppResults` and return no results when filtering by type with `application` not included', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - createApp({ id: 'app3', title: 'App 3' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); + it('does not call `getAppResults` and return no results when filtering by type with `application` not included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); - const results = await provider - .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) - .toPromise(); + it('does not call `getAppResults` and returns no results when filtering by tag', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); - expect(getAppResultsMock).not.toHaveBeenCalled(); - expect(results).toEqual([]); - }); + it('ignores inaccessible apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'disabled', title: 'disabled', status: AppStatus.inaccessible }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - it('does not call `getAppResults` and returns no results when filtering by tag', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - createApp({ id: 'app3', title: 'App 3' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); + it('ignores apps with non-visible navlink', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }), + createApp({ + id: 'disabled', + title: 'disabled', + navLinkStatus: AppNavLinkStatus.disabled, + }), + createApp({ id: 'hidden', title: 'hidden', navLinkStatus: AppNavLinkStatus.hidden }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - const results = await provider - .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) - .toPromise(); + it('ignores chromeless apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), + ]) + ); - expect(getAppResultsMock).not.toHaveBeenCalled(); - expect(results).toEqual([]); - }); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); - it('ignores inaccessible apps', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'disabled', title: 'disabled', status: AppStatus.inaccessible }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + it('sorts the results returned by `getAppResults`', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(results).toEqual([ + expectResult('r100'), + expectResult('r75'), + expectResult('r60'), + expectResult('r50'), + ]); + }); - it('ignores apps with non-visible navlink', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }), - createApp({ id: 'disabled', title: 'disabled', navLinkStatus: AppNavLinkStatus.disabled }), - createApp({ id: 'hidden', title: 'hidden', navLinkStatus: AppNavLinkStatus.hidden }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + it('only returns the highest `maxResults` results', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + const provider = createApplicationResultProvider(Promise.resolve(application)); - it('ignores chromeless apps', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), - ]) - ); + const options = { + ...defaultOption, + maxResults: 2, + }; + const results = await provider.find({ term: 'term' }, options).toPromise(); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + expect(results).toEqual([expectResult('r100'), expectResult('r75')]); + }); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + it('only emits once, even if `application$` emits multiple times', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - it('sorts the results returned by `getAppResults`', async () => { - getAppResultsMock.mockReturnValue([ - createResult({ id: 'r60', score: 60 }), - createResult({ id: 'r100', score: 100 }), - createResult({ id: 'r50', score: 50 }), - createResult({ id: 'r75', score: 75 }), - ]); + application.applications$ = hot('--a---b', { a: appMap, b: appMap }); - const provider = createApplicationResultProvider(Promise.resolve(application)); - const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); - - expect(results).toEqual([ - expectResult('r100'), - expectResult('r75'), - expectResult('r60'), - expectResult('r50'), - ]); - }); + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { + a: application, + }) as unknown) as Promise; - it('only returns the highest `maxResults` results', async () => { - getAppResultsMock.mockReturnValue([ - createResult({ id: 'r60', score: 60 }), - createResult({ id: 'r100', score: 100 }), - createResult({ id: 'r50', score: 50 }), - createResult({ id: 'r75', score: 75 }), - ]); + const provider = createApplicationResultProvider(applicationPromise); - const provider = createApplicationResultProvider(Promise.resolve(application)); + const options = { + ...defaultOption, + aborted$: hot('|'), + }; - const options = { - ...defaultOption, - maxResults: 2, - }; - const results = await provider.find({ term: 'term' }, options).toPromise(); + const resultObs = provider.find({ term: 'term' }, options); - expect(results).toEqual([expectResult('r100'), expectResult('r75')]); - }); + expectObservable(resultObs).toBe('--(a|)', { a: [] }); + }); + }); - it('only emits once, even if `application$` emits multiple times', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - application.applications$ = hot('--a---b', { a: appMap, b: appMap }); + application.applications$ = hot('---a', { a: appMap, b: appMap }); - // test scheduler doesnt play well with promises. need to workaround by passing - // an observable instead. Behavior with promise is asserted in previous tests of the suite - const applicationPromise = (hot('a', { - a: application, - }) as unknown) as Promise; + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { + a: application, + }) as unknown) as Promise; - const provider = createApplicationResultProvider(applicationPromise); + const provider = createApplicationResultProvider(applicationPromise); - const options = { - ...defaultOption, - aborted$: hot('|'), - }; + const options = { + ...defaultOption, + aborted$: hot('-(a|)', { a: undefined }), + }; - const resultObs = provider.find({ term: 'term' }, options); + const resultObs = provider.find({ term: 'term' }, options); - expectObservable(resultObs).toBe('--(a|)', { a: [] }); + expectObservable(resultObs).toBe('-|'); + }); }); }); - it('only emits results until `aborted$` emits', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - - application.applications$ = hot('---a', { a: appMap, b: appMap }); - - // test scheduler doesnt play well with promises. need to workaround by passing - // an observable instead. Behavior with promise is asserted in previous tests of the suite - const applicationPromise = (hot('a', { - a: application, - }) as unknown) as Promise; - - const provider = createApplicationResultProvider(applicationPromise); - - const options = { - ...defaultOption, - aborted$: hot('-(a|)', { a: undefined }), - }; - - const resultObs = provider.find({ term: 'term' }, options); - - expectObservable(resultObs).toBe('-|'); + describe('#getSearchableTypes', () => { + it('returns only the `application` type', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + expect(await provider.getSearchableTypes()).toEqual(['application']); }); }); }); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts index fd6eb0dc1878b..5b4c58161c0ae 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -10,6 +10,8 @@ import { ApplicationStart } from 'src/core/public'; import { GlobalSearchResultProvider } from '../../../global_search/public'; import { getAppResults } from './get_app_results'; +const applicationType = 'application'; + export const createApplicationResultProvider = ( applicationPromise: Promise ): GlobalSearchResultProvider => { @@ -27,7 +29,7 @@ export const createApplicationResultProvider = ( return { id: 'application', find: ({ term, types, tags }, { aborted$, maxResults }) => { - if (tags || (types && !types.includes('application'))) { + if (tags || (types && !types.includes(applicationType))) { return of([]); } return searchableApps$.pipe( @@ -39,5 +41,6 @@ export const createApplicationResultProvider = ( }) ); }, + getSearchableTypes: () => [applicationType], }; }; diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index da9276278dbbf..5d24b33f2619e 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -115,117 +115,127 @@ describe('savedObjectsResultProvider', () => { expect(provider.id).toBe('savedObjects'); }); - it('calls `savedObjectClient.find` with the correct parameters', async () => { - await provider.find({ term: 'term' }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title', 'description'], - type: ['typeA', 'typeB'], + describe('#find()', () => { + it('calls `savedObjectClient.find` with the correct parameters', async () => { + await provider.find({ term: 'term' }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + type: ['typeA', 'typeB'], + }); }); - }); - it('filters searchable types depending on the `types` parameter', async () => { - await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title'], - type: ['typeA'], + it('filters searchable types depending on the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); }); - }); - it('ignore the case for the `types` parameter', async () => { - await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title'], - type: ['typeA'], + it('ignore the case for the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); }); - }); - it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { - await provider - .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) - .toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title', 'description'], - hasReference: [ - { type: 'tag', id: 'tag-id-1' }, - { type: 'tag', id: 'tag-id-2' }, - ], - type: ['typeA', 'typeB'], + it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { + await provider + .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) + .toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + hasReference: [ + { type: 'tag', id: 'tag-id-1' }, + { type: 'tag', id: 'tag-id-2' }, + ], + type: ['typeA', 'typeB'], + }); }); - }); - it('does not call `savedObjectClient.find` if all params are empty', async () => { - const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); + it('does not call `savedObjectClient.find` if all params are empty', async () => { + const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); - expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); - expect(results).toEqual([[]]); - }); + expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); + expect(results).toEqual([[]]); + }); - it('converts the saved objects to results', async () => { - context.core.savedObjects.client.find.mockResolvedValue( - createFindResponse([ - createObject({ id: 'resultA', type: 'typeA', score: 50 }, { title: 'titleA' }), - createObject({ id: 'resultB', type: 'typeB', score: 78 }, { description: 'titleB' }), - ]) - ); + it('converts the saved objects to results', async () => { + context.core.savedObjects.client.find.mockResolvedValue( + createFindResponse([ + createObject({ id: 'resultA', type: 'typeA', score: 50 }, { title: 'titleA' }), + createObject({ id: 'resultB', type: 'typeB', score: 78 }, { description: 'titleB' }), + ]) + ); - const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); - expect(results).toEqual([ - { - id: 'resultA', - title: 'titleA', - type: 'typeA', - url: '/type-a/resultA', - score: 50, - }, - { - id: 'resultB', - title: 'titleB', - type: 'typeB', - url: '/type-b/resultB', - score: 78, - }, - ]); - }); + const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); + expect(results).toEqual([ + { + id: 'resultA', + title: 'titleA', + type: 'typeA', + url: '/type-a/resultA', + score: 50, + }, + { + id: 'resultB', + title: 'titleB', + type: 'typeB', + url: '/type-b/resultB', + score: 78, + }, + ]); + }); - it('only emits results until `aborted$` emits', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - // test scheduler doesnt play well with promises. need to workaround by passing - // an observable instead. Behavior with promise is asserted in previous tests of the suite - context.core.savedObjects.client.find.mockReturnValue( - hot('---a', { a: createFindResponse([]) }) as any - ); + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + context.core.savedObjects.client.find.mockReturnValue( + hot('---a', { a: createFindResponse([]) }) as any + ); + + const resultObs = provider.find( + { term: 'term' }, + { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, + context + ); + + expectObservable(resultObs).toBe('-|'); + }); + }); + }); - const resultObs = provider.find( - { term: 'term' }, - { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, - context - ); + describe('#getSearchableTypes', () => { + it('returns the searchable saved object types', async () => { + const types = await provider.getSearchableTypes(context); - expectObservable(resultObs).toBe('-|'); + expect(types.sort()).toEqual(['typeA', 'typeB']); }); }); }); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 3e2c42e7896fd..489e8f71c2d53 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -6,7 +6,7 @@ import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; -import { SavedObjectsFindOptionsReference } from 'src/core/server'; +import { SavedObjectsFindOptionsReference, ISavedObjectTypeRegistry } from 'src/core/server'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; @@ -23,10 +23,7 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = savedObjects: { client, typeRegistry }, } = core; - const searchableTypes = typeRegistry - .getVisibleTypes() - .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) - .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const searchableTypes = getSearchableTypes(typeRegistry, types); const searchFields = uniq( searchableTypes.map((type) => type.management!.defaultSearchField!) @@ -51,9 +48,21 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = map(([res, cap]) => mapToResults(res.saved_objects, typeRegistry, cap)) ); }, + getSearchableTypes: ({ core }) => { + const { + savedObjects: { typeRegistry }, + } = core; + return getSearchableTypes(typeRegistry).map((type) => type.name); + }, }; }; +const getSearchableTypes = (typeRegistry: ISavedObjectTypeRegistry, types?: string[]) => + typeRegistry + .getVisibleTypes() + .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) + .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const uniq = (values: T[]): T[] => [...new Set(values)]; const includeIgnoreCase = (list: string[], item: string) => diff --git a/x-pack/plugins/lens/public/search_provider.ts b/x-pack/plugins/lens/public/search_provider.ts index 02b7900a4c003..55454b54dde79 100644 --- a/x-pack/plugins/lens/public/search_provider.ts +++ b/x-pack/plugins/lens/public/search_provider.ts @@ -79,4 +79,5 @@ export const getSearchProvider: ( }) ); }, + getSearchableTypes: () => ['application'], }); diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index a8614f74125f4..70ba6c86e04cb 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -81,6 +81,7 @@ export class SavedObjectTaggingPlugin return { client: this.tagClient, + cache: this.tagCache, ui: getUiApi({ cache: this.tagCache, client: this.tagClient, diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts index 712b4665f32ef..0df62eb600428 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts @@ -7,12 +7,10 @@ import { Duration } from 'moment'; import { Observable, BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { ITagsCache } from '../../../../../../src/plugins/saved_objects_tagging_oss/public'; import { Tag, TagAttributes } from '../../../common/types'; -export interface ITagsCache { - getState(): Tag[]; - getState$(): Observable; -} +export { ITagsCache }; export interface ITagsChangeListener { onDelete: (id: string) => void; diff --git a/x-pack/test/functional/page_objects/navigational_search.ts b/x-pack/test/functional/page_objects/navigational_search.ts index 77df829e31019..0924b2f850739 100644 --- a/x-pack/test/functional/page_objects/navigational_search.ts +++ b/x-pack/test/functional/page_objects/navigational_search.ts @@ -43,6 +43,11 @@ export function NavigationalSearchProvider({ getService, getPageObjects }: FtrPr } } + async getFieldValue() { + const field = await testSubjects.find('nav-search-input'); + return field.getAttribute('value'); + } + async clearField() { const field = await testSubjects.find('nav-search-input'); await field.clearValueWithKeyboard(); diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts index 97d50bda899fd..f0c70ee8f718d 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts @@ -43,6 +43,47 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(await browser.getCurrentUrl()).to.contain('discover'); }); + describe('search suggestions', () => { + it('shows a suggestion when searching for a term matching a type', async () => { + await navigationalSearch.searchFor('dashboard'); + + let results = await navigationalSearch.getDisplayedResults(); + expect(results[0].label).to.eql('type: dashboard'); + + await navigationalSearch.clickOnOption(0); + await navigationalSearch.waitForResultsLoaded(); + + const searchTerm = await navigationalSearch.getFieldValue(); + expect(searchTerm).to.eql('type:dashboard'); + + results = await navigationalSearch.getDisplayedResults(); + expect(results.map((result) => result.label)).to.eql([ + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + it('shows a suggestion when searching for a term matching a tag name', async () => { + await navigationalSearch.searchFor('tag-1'); + + let results = await navigationalSearch.getDisplayedResults(); + expect(results[0].label).to.eql('tag: tag-1'); + + await navigationalSearch.clickOnOption(0); + await navigationalSearch.waitForResultsLoaded(); + + const searchTerm = await navigationalSearch.getFieldValue(); + expect(searchTerm).to.eql('tag:tag-1'); + + results = await navigationalSearch.getDisplayedResults(); + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + }); + describe('advanced search syntax', () => { it('allows to filter by type', async () => { await navigationalSearch.searchFor('type:dashboard'); From ec0bfe9f1485b1247453dc96543de3dae98c6da9 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 9 Dec 2020 12:16:42 +0100 Subject: [PATCH 03/52] clear using keyboard (#85042) --- x-pack/test/functional/page_objects/lens_page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 16a8e0594816f..2159f939a56f7 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -270,7 +270,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, async editDimensionLabel(label: string) { - await testSubjects.setValue('indexPattern-label-edit', label); + await testSubjects.setValue('indexPattern-label-edit', label, { clearWithKeyboard: true }); }, async editDimensionFormat(format: string) { const formatInput = await testSubjects.find('indexPattern-dimension-format'); From 7fc7fe325cb37b378d6f643499ac18a797e9e870 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 9 Dec 2020 12:29:42 +0100 Subject: [PATCH 04/52] [ILM] Show forcemerge in hot when rollover is searchable snapshot is enabled (#85292) * pivot to different rollover validation mechanism * implement stakeholder feedback to show forcemerge in hot * replace ternary with if..else statements * make rollover validation test more comprehensive --- .../__jest__/components/edit_policy.test.tsx | 7 +++- .../components/phases/hot_phase/hot_phase.tsx | 8 ++--- .../sections/edit_policy/form/schema.ts | 6 ++++ .../sections/edit_policy/form/validations.ts | 36 +++++++++---------- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 65952e81ae0ff..32964ab2ce84d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -324,7 +324,7 @@ describe('edit policy', () => { }); }); describe('hot phase', () => { - test('should show errors when trying to save with no max size and no max age', async () => { + test('should show errors when trying to save with no max size, no max age and no max docs', async () => { const rendered = mountWithIntl(component); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeFalsy(); await setPolicyName(rendered, 'mypolicy'); @@ -338,6 +338,11 @@ describe('edit policy', () => { maxAgeInput.simulate('change', { target: { value: '' } }); }); waitForFormLibValidation(rendered); + const maxDocsInput = findTestSubject(rendered, 'hot-selectedMaxDocuments'); + await act(async () => { + maxDocsInput.simulate('change', { target: { value: '' } }); + }); + waitForFormLibValidation(rendered); await save(rendered); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeTruthy(); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index e86bbd9e747bc..5ce4fae596e8e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -24,7 +24,7 @@ import { useFormData, UseField, SelectField, NumericField } from '../../../../.. import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues } from '../../../form'; +import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; import { useEditPolicyContext } from '../../../edit_policy_context'; @@ -51,8 +51,6 @@ export const HotPhase: FunctionComponent = () => { const isRolloverEnabled = get(formData, useRolloverPath); const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); - return ( <> { {(field) => { const showErrorCallout = field.errors.some( - (e) => e.validationType === ROLLOVER_EMPTY_VALIDATION + (e) => e.code === ROLLOVER_EMPTY_VALIDATION ); if (showErrorCallout !== showEmptyRolloverFieldsError) { setShowEmptyRolloverFieldsError(showErrorCallout); @@ -236,8 +234,8 @@ export const HotPhase: FunctionComponent = () => { {isRolloverEnabled && ( <> + {} {license.canUseSearchableSnapshot() && } - {!isUsingSearchableSnapshotInHotPhase && } )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index fa9def6864be0..6485122771a46 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -8,6 +8,9 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../../../shared_imports'; import { defaultSetPriority, defaultPhaseIndexPriority } from '../../../constants'; +import { ROLLOVER_FORM_PATHS } from '../constants'; + +const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); import { FormInternal } from '../types'; @@ -127,6 +130,7 @@ export const schema: FormSchema = { validator: ifExistsNumberGreaterThanZero, }, ], + fieldsToValidateOnChange: rolloverFormPaths, }, max_docs: { label: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.maximumDocumentsLabel', { @@ -141,6 +145,7 @@ export const schema: FormSchema = { }, ], serializer: serializers.stringToNumber, + fieldsToValidateOnChange: rolloverFormPaths, }, max_size: { label: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.maximumIndexSizeLabel', { @@ -154,6 +159,7 @@ export const schema: FormSchema = { validator: ifExistsNumberGreaterThanZero, }, ], + fieldsToValidateOnChange: rolloverFormPaths, }, }, forcemerge: { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts index f2e26a552efc9..a5d7d68d21915 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts @@ -56,33 +56,31 @@ export const ROLLOVER_EMPTY_VALIDATION = 'ROLLOVER_EMPTY_VALIDATION'; * This validator checks that and updates form values by setting errors states imperatively to * indicate this error state. */ -export const rolloverThresholdsValidator: ValidationFunc = ({ form }) => { +export const rolloverThresholdsValidator: ValidationFunc = ({ form, path }) => { const fields = form.getFields(); if ( !( - fields[ROLLOVER_FORM_PATHS.maxAge].value || - fields[ROLLOVER_FORM_PATHS.maxDocs].value || - fields[ROLLOVER_FORM_PATHS.maxSize].value + fields[ROLLOVER_FORM_PATHS.maxAge]?.value || + fields[ROLLOVER_FORM_PATHS.maxDocs]?.value || + fields[ROLLOVER_FORM_PATHS.maxSize]?.value ) ) { - fields[ROLLOVER_FORM_PATHS.maxAge].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + if (path === ROLLOVER_FORM_PATHS.maxAge) { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumAgeRequiredMessage, - }, - ]); - fields[ROLLOVER_FORM_PATHS.maxDocs].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + }; + } else if (path === ROLLOVER_FORM_PATHS.maxDocs) { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumDocumentsRequiredMessage, - }, - ]); - fields[ROLLOVER_FORM_PATHS.maxSize].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + }; + } else { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumSizeRequiredMessage, - }, - ]); + }; + } } else { fields[ROLLOVER_FORM_PATHS.maxAge].clearErrors(ROLLOVER_EMPTY_VALIDATION); fields[ROLLOVER_FORM_PATHS.maxDocs].clearErrors(ROLLOVER_EMPTY_VALIDATION); From 9d8dd6dc5763b700816843bf92d75d313e311635 Mon Sep 17 00:00:00 2001 From: Daniil Date: Wed, 9 Dec 2020 14:40:59 +0300 Subject: [PATCH 05/52] Fix agg select external link (#85380) --- .../vis_default_editor/public/components/agg_select.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/vis_default_editor/public/components/agg_select.tsx b/src/plugins/vis_default_editor/public/components/agg_select.tsx index 9d45b72d35cc0..689cc52691bb6 100644 --- a/src/plugins/vis_default_editor/public/components/agg_select.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_select.tsx @@ -77,15 +77,15 @@ function DefaultEditorAggSelect({ } const helpLink = value && aggHelpLink && ( - - + + - - + + ); const errors = aggError ? [aggError] : []; From 2a8c609bf91e8c0011d6e5cc058636d8479a78ed Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 9 Dec 2020 12:45:40 +0100 Subject: [PATCH 06/52] [Uptime]Refactor header and action menu (#83779) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../plugins/uptime/public/apps/render_app.tsx | 8 +- .../plugins/uptime/public/apps/uptime_app.tsx | 10 +- .../certificates/cert_refresh_btn.tsx | 51 ++ .../__snapshots__/page_header.test.tsx.snap | 442 ++++++++++++++---- .../header}/__tests__/page_header.test.tsx | 8 +- .../components/common/header/action_menu.tsx | 36 ++ .../components/common/header/page_header.tsx | 65 +++ .../components/common/header/page_tabs.tsx | 80 ++++ .../__snapshots__/monitor_list.test.tsx.snap | 31 +- .../columns/__tests__/enable_alert.test.tsx | 27 ++ .../columns/define_connectors.tsx | 6 +- .../monitor_list/monitor_list_header.tsx | 14 - .../uptime/public/pages/certificates.tsx | 125 ++--- .../plugins/uptime/public/pages/monitor.tsx | 32 +- .../plugins/uptime/public/pages/overview.tsx | 9 +- .../uptime/public/pages/page_header.tsx | 107 ----- .../plugins/uptime/public/pages/settings.tsx | 13 +- .../uptime/public/pages/translations.ts | 14 - x-pack/plugins/uptime/public/routes.tsx | 26 +- .../functional/services/uptime/overview.ts | 12 +- .../apps/uptime/simple_down_alert.ts | 9 +- 23 files changed, 722 insertions(+), 411 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx rename x-pack/plugins/uptime/public/{pages => components/common/header}/__tests__/__snapshots__/page_header.test.tsx.snap (64%) rename x-pack/plugins/uptime/public/{pages => components/common/header}/__tests__/page_header.test.tsx (82%) create mode 100644 x-pack/plugins/uptime/public/components/common/header/action_menu.tsx create mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.tsx create mode 100644 x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx delete mode 100644 x-pack/plugins/uptime/public/pages/page_header.tsx diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1f36a5a71537b..39b485d8875ba 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20323,8 +20323,6 @@ "xpack.uptime.breadcrumbs.overviewBreadcrumbText": "アップタイム", "xpack.uptime.certificates.heading": "TLS証明書({total})", "xpack.uptime.certificates.refresh": "更新", - "xpack.uptime.certificates.returnToOverviewLinkLabel": "概要に戻る", - "xpack.uptime.certificates.settingsLinkLabel": "設定", "xpack.uptime.certs.expired": "期限切れ", "xpack.uptime.certs.expires": "有効期限", "xpack.uptime.certs.expireSoon": "まもなく期限切れ", @@ -20462,7 +20460,6 @@ "xpack.uptime.monitorList.table.description": "列にステータス、名前、URL、IP、ダウンタイム履歴、統合が入力されたモニターステータス表です。この表は現在 {length} 項目を表示しています。", "xpack.uptime.monitorList.table.url.name": "Url", "xpack.uptime.monitorList.tlsColumnLabel": "TLS証明書", - "xpack.uptime.monitorList.viewCertificateTitle": "証明書ステータス", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "ミリ秒単位の監視時間", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "監視ステータス", "xpack.uptime.monitorStatusBar.loadingMessage": "読み込み中…", @@ -20528,7 +20525,6 @@ "xpack.uptime.settings.error.couldNotSave": "設定を保存できませんでした!", "xpack.uptime.settings.invalid.error": "値は0よりも大きい値でなければなりません。", "xpack.uptime.settings.invalid.nanError": "値は整数でなければなりません。", - "xpack.uptime.settings.returnToOverviewLinkLabel": "概要に戻る", "xpack.uptime.settings.saveSuccess": "設定が保存されました。", "xpack.uptime.settingsBreadcrumbText": "設定", "xpack.uptime.snapshot.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total}個中{down}個のモニターがダウンしています。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3151b863cc4a1..c3b910c47b121 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20342,8 +20342,6 @@ "xpack.uptime.breadcrumbs.overviewBreadcrumbText": "运行时间", "xpack.uptime.certificates.heading": "TLS 证书 ({total})", "xpack.uptime.certificates.refresh": "刷新", - "xpack.uptime.certificates.returnToOverviewLinkLabel": "返回到概览", - "xpack.uptime.certificates.settingsLinkLabel": "设置", "xpack.uptime.certs.expired": "已过期", "xpack.uptime.certs.expires": "过期", "xpack.uptime.certs.expireSoon": "即将过期", @@ -20481,7 +20479,6 @@ "xpack.uptime.monitorList.table.description": "具有“状态”、“名称”、“URL”、“IP”、“中断历史记录”和“集成”列的“监测状态”表。该表当前显示 {length} 个项目。", "xpack.uptime.monitorList.table.url.name": "URL", "xpack.uptime.monitorList.tlsColumnLabel": "TLS 证书", - "xpack.uptime.monitorList.viewCertificateTitle": "证书状态", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "监测持续时间(毫秒)", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "检测状态", "xpack.uptime.monitorStatusBar.loadingMessage": "正在加载……", @@ -20547,7 +20544,6 @@ "xpack.uptime.settings.error.couldNotSave": "无法保存设置!", "xpack.uptime.settings.invalid.error": "值必须大于 0。", "xpack.uptime.settings.invalid.nanError": "值必须为整数。", - "xpack.uptime.settings.returnToOverviewLinkLabel": "返回到概览", "xpack.uptime.settings.saveSuccess": "设置已保存!", "xpack.uptime.settingsBreadcrumbText": "设置", "xpack.uptime.snapshot.donutChart.ariaLabel": "显示当前状态的饼图。{total} 个监测中有 {down} 个已关闭。", diff --git a/x-pack/plugins/uptime/public/apps/render_app.tsx b/x-pack/plugins/uptime/public/apps/render_app.tsx index c0567ff956ce4..803431dc25b24 100644 --- a/x-pack/plugins/uptime/public/apps/render_app.tsx +++ b/x-pack/plugins/uptime/public/apps/render_app.tsx @@ -21,7 +21,7 @@ export function renderApp( core: CoreStart, plugins: ClientPluginsSetup, startPlugins: ClientPluginsStart, - { element, history }: AppMountParameters + appMountParameters: AppMountParameters ) { const { application: { capabilities }, @@ -47,7 +47,6 @@ export function renderApp( basePath: basePath.get(), darkMode: core.uiSettings.get(DEFAULT_DARK_MODE), commonlyUsedRanges: core.uiSettings.get(DEFAULT_TIMEPICKER_QUICK_RANGES), - history, isApmAvailable: apm, isInfraAvailable: infrastructure, isLogsAvailable: logs, @@ -68,12 +67,13 @@ export function renderApp( ], }), setBadge, + appMountParameters, setBreadcrumbs: core.chrome.setBreadcrumbs, }; - ReactDOM.render(, element); + ReactDOM.render(, appMountParameters.element); return () => { - ReactDOM.unmountComponentAtNode(element); + ReactDOM.unmountComponentAtNode(appMountParameters.element); }; } diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 9bbcde041a794..061398b25e452 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { Provider as ReduxProvider } from 'react-redux'; import { Router } from 'react-router-dom'; -import { I18nStart, ChromeBreadcrumb, CoreStart } from 'kibana/public'; +import { I18nStart, ChromeBreadcrumb, CoreStart, AppMountParameters } from 'kibana/public'; import { KibanaContextProvider, RedirectAppLinks, @@ -28,7 +28,7 @@ import { PageRouter } from '../routes'; import { UptimeAlertsFlyoutWrapper } from '../components/overview/alerts'; import { store } from '../state'; import { kibanaService } from '../state/kibana_service'; -import { ScopedHistory } from '../../../../../src/core/public'; +import { ActionMenu } from '../components/common/header/action_menu'; import { EuiThemeProvider } from '../../../observability/public'; export interface UptimeAppColors { @@ -47,7 +47,6 @@ export interface UptimeAppProps { canSave: boolean; core: CoreStart; darkMode: boolean; - history: ScopedHistory; i18n: I18nStart; isApmAvailable: boolean; isInfraAvailable: boolean; @@ -58,6 +57,7 @@ export interface UptimeAppProps { renderGlobalHelpControls(): void; commonlyUsedRanges: CommonlyUsedRange[]; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + appMountParameters: AppMountParameters; } const Application = (props: UptimeAppProps) => { @@ -71,6 +71,7 @@ const Application = (props: UptimeAppProps) => { renderGlobalHelpControls, setBadge, startPlugins, + appMountParameters, } = props; useEffect(() => { @@ -101,7 +102,7 @@ const Application = (props: UptimeAppProps) => { - + @@ -112,6 +113,7 @@ const Application = (props: UptimeAppProps) => {
+
diff --git a/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx b/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx new file mode 100644 index 0000000000000..d0823276f1885 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHideFor, + EuiShowFor, +} from '@elastic/eui'; +import * as labels from '../../pages/translations'; +import { UptimeRefreshContext } from '../../contexts'; + +export const CertRefreshBtn = () => { + const { refreshApp } = useContext(UptimeRefreshContext); + + return ( + + + + + { + refreshApp(); + }} + data-test-subj="superDatePickerApplyTimeButton" + > + {labels.REFRESH_CERT} + + + + { + refreshApp(); + }} + data-test-subj="superDatePickerApplyTimeButton" + /> + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap similarity index 64% rename from x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap rename to x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap index 7bb578494ab44..05a78624848c6 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap @@ -107,97 +107,86 @@ Array [ }
-
-
- -
-
-
- -
@@ -308,7 +297,7 @@ Array [ }
, ] `; @@ -420,16 +409,91 @@ Array [ } +
+
- TestingHeading - +
+ +
+
, ] `; exports[`PageHeader shallow renders without the date picker: page_header_no_date_picker 1`] = ` Array [ -
, -
, - +
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
, -
, ] `; diff --git a/x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx b/x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx similarity index 82% rename from x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx rename to x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx index 63d4c24f965d9..0b72cc64f8102 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { PageHeader } from '../page_header'; -import { renderWithRouter, MountWithReduxProvider } from '../../lib'; +import { renderWithRouter, MountWithReduxProvider } from '../../../../lib'; describe('PageHeader', () => { it('shallow renders with the date picker', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); @@ -21,7 +21,7 @@ describe('PageHeader', () => { it('shallow renders without the date picker', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); @@ -30,7 +30,7 @@ describe('PageHeader', () => { it('shallow renders extra links', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_with_extra_links'); diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx new file mode 100644 index 0000000000000..b59470f66f796 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { HeaderMenuPortal } from '../../../../../observability/public'; +import { AppMountParameters } from '../../../../../../../src/core/public'; + +const ADD_DATA_LABEL = i18n.translate('xpack.uptime.addDataButtonLabel', { + defaultMessage: 'Add data', +}); + +export const ActionMenu = ({ appMountParameters }: { appMountParameters: AppMountParameters }) => { + const kibana = useKibana(); + + return ( + + + + + {ADD_DATA_LABEL} + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx new file mode 100644 index 0000000000000..63bcb6663619d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { useRouteMatch } from 'react-router-dom'; +import { UptimeDatePicker } from '../uptime_date_picker'; +import { SyntheticsCallout } from '../../overview/synthetics_callout'; +import { PageTabs } from './page_tabs'; +import { CERTIFICATES_ROUTE, MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../common/constants'; +import { CertRefreshBtn } from '../../certificates/cert_refresh_btn'; +import { ToggleAlertFlyoutButton } from '../../overview/alerts/alerts_containers'; + +const StyledPicker = styled(EuiFlexItem)` + &&& { + @media only screen and (max-width: 1024px) and (min-width: 868px) { + .euiSuperDatePicker__flexWrapper { + width: 500px; + } + } + @media only screen and (max-width: 880px) { + flex-grow: 1; + .euiSuperDatePicker__flexWrapper { + width: calc(100% + 8px); + } + } + } +`; + +export const PageHeader = () => { + const isCertRoute = useRouteMatch(CERTIFICATES_ROUTE); + const isSettingsRoute = useRouteMatch(SETTINGS_ROUTE); + + const DatePickerComponent = () => + isCertRoute ? ( + + ) : ( + + + + ); + + const isMonRoute = useRouteMatch(MONITOR_ROUTE); + + return ( + <> + + + + + + + + + {!isSettingsRoute && } + + {isMonRoute && } + {!isMonRoute && } + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx b/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx new file mode 100644 index 0000000000000..68df15c52c65e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; + +import { EuiTabs, EuiTab } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { CERTIFICATES_ROUTE, OVERVIEW_ROUTE, SETTINGS_ROUTE } from '../../../../common/constants'; + +const tabs = [ + { + id: OVERVIEW_ROUTE, + name: i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }), + dataTestSubj: 'uptimeSettingsToOverviewLink', + }, + { + id: CERTIFICATES_ROUTE, + name: 'Certificates', + dataTestSubj: 'uptimeCertificatesLink', + }, + { + id: SETTINGS_ROUTE, + dataTestSubj: 'settings-page-link', + name: i18n.translate('xpack.uptime.page_header.settingsLink', { + defaultMessage: 'Settings', + }), + }, +]; + +export const PageTabs = () => { + const [selectedTabId, setSelectedTabId] = useState(null); + + const history = useHistory(); + + const isOverView = useRouteMatch(OVERVIEW_ROUTE); + const isSettings = useRouteMatch(SETTINGS_ROUTE); + const isCerts = useRouteMatch(CERTIFICATES_ROUTE); + + useEffect(() => { + if (isOverView?.isExact) { + setSelectedTabId(OVERVIEW_ROUTE); + } + if (isCerts) { + setSelectedTabId(CERTIFICATES_ROUTE); + } + if (isSettings) { + setSelectedTabId(SETTINGS_ROUTE); + } + if (!isOverView?.isExact && !isCerts && !isSettings) { + setSelectedTabId(null); + } + }, [isCerts, isSettings, isOverView]); + + const renderTabs = () => { + return tabs.map(({ dataTestSubj, name, id }, index) => ( + setSelectedTabId(id)} + isSelected={id === selectedTabId} + key={index} + data-test-subj={dataTestSubj} + href={history.createHref({ pathname: id })} + > + {name} + + )); + }; + + return ( + + {renderTabs()} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 39f860f76f2bd..bd1aecc9ede48 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -826,26 +826,20 @@ exports[`MonitorList component renders loading state 1`] = ` `; exports[`MonitorList component renders the monitor list 1`] = ` -.c3 { +.c2 { padding-right: 4px; } -.c4 { +.c3 { padding-top: 12px; } -.c1 { - position: absolute; - right: 16px; - top: 16px; -} - .c0 { position: relative; } @media (max-width:574px) { - .c2 { + .c1 { min-width: 230px; } } @@ -936,13 +930,6 @@ exports[`MonitorList component renders the monitor list 1`] = `
- - Certificates status -
-
-
- - + + + + + + +
+ +
+
+ + + +
+
+ +
+
+ -
-
+ -
- - - - - - + + ); } ); + +const SchemaInformation = ({ + closePopover, + setActivePopover, + isOpen, +}: { + closePopover: () => void; + setActivePopover: (value: 'schemaInfo' | null) => void; + isOpen: boolean; +}) => { + const colorMap = useColors(); + const sourceAndSchema = useSelector(selectors.resolverTreeSourceAndSchema); + const setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]); + + const schemaInfoButtonTitle = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.schemaInfoButtonTitle', + { + defaultMessage: 'Schema Information', + } + ); + + const unknownSchemaValue = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.unknownSchemaValue', + { + defaultMessage: 'Unknown', + } + ); + + return ( + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="leftCenter" + > + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaInfoTitle', { + defaultMessage: 'process tree', + })} + + +
+ + <> + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaSource', { + defaultMessage: 'source', + })} + + + {sourceAndSchema?.dataSource ?? unknownSchemaValue} + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaID', { + defaultMessage: 'id', + })} + + + {sourceAndSchema?.schema.id ?? unknownSchemaValue} + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaEdge', { + defaultMessage: 'edge', + })} + + + {sourceAndSchema?.schema.parent ?? unknownSchemaValue} + + + +
+
+ ); +}; + +// This component defines the cube legend that allows users to identify the meaning of the cubes +// Should be updated to be dynamic if and when non process based resolvers are possible +const NodeLegend = ({ + closePopover, + setActivePopover, + isOpen, +}: { + closePopover: () => void; + setActivePopover: (value: 'nodeLegend') => void; + isOpen: boolean; +}) => { + const setAsActivePopover = useCallback(() => setActivePopover('nodeLegend'), [setActivePopover]); + const colorMap = useColors(); + + const nodeLegendButtonTitle = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.nodeLegendButtonTitle', + { + defaultMessage: 'Node Legend', + } + ); + + return ( + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="leftCenter" + > + + {i18n.translate('xpack.securitySolution.resolver.graphControls.nodeLegend', { + defaultMessage: 'legend', + })} + +
+ + <> + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.runningProcessCube', + { + defaultMessage: 'Running Process', + } + )} + + + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.terminatedProcessCube', + { + defaultMessage: 'Terminated Process', + } + )} + + + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.currentlyLoadingCube', + { + defaultMessage: 'Loading Process', + } + )} + + + + + + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.errorCube', { + defaultMessage: 'Error', + })} + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx index cc5f39e985d9e..99c57757fbb6a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx @@ -17,40 +17,44 @@ interface StyledSVGCube { } import { useCubeAssets } from '../use_cube_assets'; import { useSymbolIDs } from '../use_symbol_ids'; +import { NodeDataStatus } from '../../types'; /** * Icon representing a process node. */ export const CubeForProcess = memo(function ({ className, - running, + size = '2.15em', + state, isOrigin, 'data-test-subj': dataTestSubj, }: { 'data-test-subj'?: string; /** - * True if the process represented by the node is still running. + * The state of the process's node data (for endpoint the process's lifecycle events) */ - running: boolean; + state: NodeDataStatus; + /** The css size (px, em, etc...) for the width and height of the svg cube. Defaults to 2.15em */ + size?: string; isOrigin?: boolean; className?: string; }) { - const { cubeSymbol, strokeColor } = useCubeAssets(!running, false); + const { cubeSymbol, strokeColor } = useCubeAssets(state, false); const { processCubeActiveBacking } = useSymbolIDs(); return ( {i18n.translate('xpack.securitySolution.resolver.node_icon', { - defaultMessage: '{running, select, true {Running Process} false {Terminated Process}}', - values: { running }, + defaultMessage: `{state, select, running {Running Process} terminated {Terminated Process} loading {Loading Process} error {Error Process}}`, + values: { state }, })} {isOrigin && ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx index 4936cf0cbb80e..003182bd5f1b7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx @@ -29,6 +29,7 @@ import { useLinkProps } from '../use_link_props'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { deepObjectEntries } from './deep_object_entries'; import { useFormattedDate } from './use_formatted_date'; +import * as nodeDataModel from '../../models/node_data'; const eventDetailRequestError = i18n.translate( 'xpack.securitySolution.resolver.panel.eventDetail.requestError', @@ -39,23 +40,24 @@ const eventDetailRequestError = i18n.translate( export const EventDetail = memo(function EventDetail({ nodeID, - eventID, eventCategory: eventType, }: { nodeID: string; - eventID: string; /** The event type to show in the breadcrumbs */ eventCategory: string; }) { const isEventLoading = useSelector(selectors.isCurrentRelatedEventLoading); - const isProcessTreeLoading = useSelector(selectors.isTreeLoading); + const isTreeLoading = useSelector(selectors.isTreeLoading); + const processEvent = useSelector((state: ResolverState) => + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) + ); + const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); - const isLoading = isEventLoading || isProcessTreeLoading; + const isNodeDataLoading = nodeStatus === 'loading'; + const isLoading = isEventLoading || isTreeLoading || isNodeDataLoading; const event = useSelector(selectors.currentRelatedEventData); - const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) - ); + return isLoading ? ( @@ -90,7 +92,7 @@ const EventDetailContents = memo(function ({ * Event type to use in the breadcrumbs */ eventType: string; - processEvent: SafeResolverEvent | null; + processEvent: SafeResolverEvent | undefined; }) { const timestamp = eventModel.timestampSafeVersion(event); const formattedDate = diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index f6fbd280e7ed5..c6e81f691e2fe 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -37,7 +37,6 @@ export const PanelRouter = memo(function () { return ( ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx index 27a7723d7d656..fedf1ae2499ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx @@ -20,6 +20,7 @@ import { GeneratedText } from '../generated_text'; import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import { processPath, processPID } from '../../models/process_event'; +import * as nodeDataModel from '../../models/node_data'; import { CubeForProcess } from './cube_for_process'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { useCubeAssets } from '../use_cube_assets'; @@ -28,28 +29,35 @@ import { PanelLoading } from './panel_loading'; import { StyledPanel } from '../styles'; import { useLinkProps } from '../use_link_props'; import { useFormattedDate } from './use_formatted_date'; +import { PanelContentError } from './panel_content_error'; const StyledCubeForProcess = styled(CubeForProcess)` position: relative; top: 0.75em; `; +const nodeDetailError = i18n.translate('xpack.securitySolution.resolver.panel.nodeDetail.Error', { + defaultMessage: 'Node details were unable to be retrieved', +}); + export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) { const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) ); - return ( - <> - {processEvent === null ? ( - - - - ) : ( - - - - )} - + const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); + + return nodeStatus === 'loading' ? ( + + + + ) : processEvent ? ( + + + + ) : ( + + + ); }); @@ -65,9 +73,7 @@ const NodeDetailView = memo(function ({ nodeID: string; }) { const processName = eventModel.processNameSafeVersion(processEvent); - const isProcessTerminated = useSelector((state: ResolverState) => - selectors.isProcessTerminated(state)(nodeID) - ); + const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); const relatedEventTotal = useSelector((state: ResolverState) => { return selectors.relatedEventTotalCount(state)(nodeID); }); @@ -171,7 +177,7 @@ const NodeDetailView = memo(function ({ }, ]; }, [processName, nodesLinkNavProps]); - const { descriptionText } = useCubeAssets(isProcessTerminated, false); + const { descriptionText } = useCubeAssets(nodeState, false); const nodeDetailNavProps = useLinkProps({ panelView: 'nodeEvents', @@ -187,7 +193,7 @@ const NodeDetailView = memo(function ({ {processName} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx index d0601fad43f57..6f0c336ab3df4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx @@ -13,21 +13,21 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useSelector } from 'react-redux'; import { Breadcrumbs } from './breadcrumbs'; import * as event from '../../../../common/endpoint/models/event'; -import { ResolverNodeStats } from '../../../../common/endpoint/types'; +import { EventStats } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { ResolverState } from '../../types'; import { StyledPanel } from '../styles'; import { PanelLoading } from './panel_loading'; import { useLinkProps } from '../use_link_props'; +import * as nodeDataModel from '../../models/node_data'; export function NodeEvents({ nodeID }: { nodeID: string }) { const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) ); - const relatedEventsStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) - ); - if (processEvent === null || relatedEventsStats === undefined) { + const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID)); + + if (processEvent === undefined || nodeStats === undefined) { return ( @@ -39,10 +39,10 @@ export function NodeEvents({ nodeID }: { nodeID: string }) { - + ); } @@ -64,7 +64,7 @@ const EventCategoryLinks = memo(function ({ relatedStats, }: { nodeID: string; - relatedStats: ResolverNodeStats; + relatedStats: EventStats; }) { interface EventCountsTableView { eventType: string; @@ -72,7 +72,7 @@ const EventCategoryLinks = memo(function ({ } const rows = useMemo(() => { - return Object.entries(relatedStats.events.byCategory).map( + return Object.entries(relatedStats.byCategory).map( ([eventType, count]): EventCountsTableView => { return { eventType, @@ -80,7 +80,7 @@ const EventCategoryLinks = memo(function ({ }; } ); - }, [relatedStats.events.byCategory]); + }, [relatedStats.byCategory]); const columns = useMemo>>( () => [ diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index c9648c6f562e5..fbfba38295ea4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -42,9 +42,7 @@ export const NodeEventsInCategory = memo(function ({ nodeID: string; eventCategory: string; }) { - const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) - ); + const node = useSelector((state: ResolverState) => selectors.graphNodeForID(state)(nodeID)); const eventCount = useSelector((state: ResolverState) => selectors.totalRelatedEventCountForNode(state)(nodeID) ); @@ -57,13 +55,13 @@ export const NodeEventsInCategory = memo(function ({ const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory); return ( <> - {isLoading || processEvent === null ? ( + {isLoading ? ( ) : ( - {hasError ? ( + {hasError || !node ? ( { useCallback((state: ResolverState) => { const { processNodePositions } = selectors.layout(state); const view: ProcessTableView[] = []; - for (const processEvent of processNodePositions.keys()) { - const name = eventModel.processNameSafeVersion(processEvent); - const nodeID = eventModel.entityIDSafeVersion(processEvent); + for (const treeNode of processNodePositions.keys()) { + const name = nodeModel.nodeName(treeNode); + const nodeID = nodeModel.nodeID(treeNode); if (nodeID !== undefined) { view.push({ name, - timestamp: eventModel.timestampAsDateSafeVersion(processEvent), + timestamp: nodeModel.timestampAsDate(treeNode), nodeID, }); } @@ -119,7 +119,8 @@ export const NodeList = memo(() => { const children = useSelector(selectors.hasMoreChildren); const ancestors = useSelector(selectors.hasMoreAncestors); - const showWarning = children === true || ancestors === true; + const generations = useSelector(selectors.hasMoreGenerations); + const showWarning = children === true || ancestors === true || generations === true; const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []); return ( @@ -141,9 +142,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { const isOrigin = useSelector((state: ResolverState) => { return selectors.originID(state) === nodeID; }); - const isTerminated = useSelector((state: ResolverState) => - nodeID === undefined ? false : selectors.isProcessTerminated(state)(nodeID) - ); + const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); const { descriptionText } = useColors(); const linkProps = useLinkProps({ panelView: 'nodeDetail', panelParameters: { nodeID } }); const dispatch: (action: ResolverAction) => void = useDispatch(); @@ -162,7 +161,12 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { [timestamp, linkProps, dispatch, nodeID] ); return ( - + {name === undefined ? ( {i18n.translate( @@ -175,7 +179,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { ) : ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx index 39a5130ecaf68..6f20063d10d0a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx @@ -23,6 +23,8 @@ describe('Resolver: panel loading and resolution states', () => { nodeID: 'origin', eventCategory: 'registry', eventID: firstRelatedEventID, + eventTimestamp: '0', + winlogRecordID: '0', }, panelView: 'eventDetail', }); @@ -129,7 +131,7 @@ describe('Resolver: panel loading and resolution states', () => { }); describe('when navigating to the event categories panel', () => { - let resumeRequest: (pausableRequest: ['entities']) => void; + let resumeRequest: (pausableRequest: ['eventsWithEntityIDAndCategory']) => void; beforeEach(() => { const { metadata: { databaseDocumentID }, @@ -140,7 +142,7 @@ describe('Resolver: panel loading and resolution states', () => { resumeRequest = resume; memoryHistory = createMemoryHistory(); - pause(['entities']); + pause(['eventsWithEntityIDAndCategory']); simulator = new Simulator({ dataAccessLayer, @@ -170,7 +172,7 @@ describe('Resolver: panel loading and resolution states', () => { }); it('should successfully load the events in category panel', async () => { - await resumeRequest(['entities']); + await resumeRequest(['eventsWithEntityIDAndCategory']); await expect( simulator.map(() => ({ resolverPanelLoading: simulator.testSubject('resolver:panel:loading').length, @@ -186,7 +188,7 @@ describe('Resolver: panel loading and resolution states', () => { }); describe('when navigating to the node detail panel', () => { - let resumeRequest: (pausableRequest: ['entities']) => void; + let resumeRequest: (pausableRequest: ['nodeData']) => void; beforeEach(() => { const { metadata: { databaseDocumentID }, @@ -197,7 +199,7 @@ describe('Resolver: panel loading and resolution states', () => { resumeRequest = resume; memoryHistory = createMemoryHistory(); - pause(['entities']); + pause(['nodeData']); simulator = new Simulator({ dataAccessLayer, @@ -226,7 +228,7 @@ describe('Resolver: panel loading and resolution states', () => { }); it('should successfully load the events in category panel', async () => { - await resumeRequest(['entities']); + await resumeRequest(['nodeData']); await expect( simulator.map(() => ({ resolverPanelLoading: simulator.testSubject('resolver:panel:loading').length, diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 7a3657fe93514..ab6083c796b3a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -9,12 +9,13 @@ import styled from 'styled-components'; import { htmlIdGenerator, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { NodeSubMenu } from './styles'; import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3, ResolverState } from '../types'; -import { SafeResolverEvent } from '../../../common/endpoint/types'; +import { ResolverNode } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; -import * as eventModel from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; import * as selectors from '../store/selectors'; import { fontSize } from './font_size'; import { useCubeAssets } from './use_cube_assets'; @@ -65,9 +66,50 @@ const StyledDescriptionText = styled.div` z-index: 45; `; -const StyledOuterGroup = styled.g` +interface StyledEuiButtonContent { + readonly isShowingIcon: boolean; +} + +const StyledEuiButtonContent = styled.span` + padding: ${(props) => (props.isShowingIcon ? '0px' : '0 12px')}; +`; + +const StyledOuterGroup = styled.g<{ isNodeLoading: boolean }>` fill: none; pointer-events: visiblePainted; + // The below will apply the loading css to the element that references the cube + // when the nodeData is loading for the current node + ${(props) => + props.isNodeLoading && + ` + & .cube { + animation-name: pulse; + /** + * his is a multiple of .6 so it can match up with the EUI button's loading spinner + * which is (0.6s). Using .6 here makes it a bit too fast. + */ + animation-duration: 1.8s; + animation-delay: 0; + animation-direction: normal; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + /** + * Animation loading state of the cube. + */ + @keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.35; + } + 100% { + opacity: 1; + } + } + `} `; /** @@ -77,9 +119,9 @@ const UnstyledProcessEventDot = React.memo( ({ className, position, - event, + node, + nodeID, projectionMatrix, - isProcessTerminated, timeAtRender, }: { /** @@ -87,21 +129,21 @@ const UnstyledProcessEventDot = React.memo( */ className?: string; /** - * The positon of the process node, in 'world' coordinates. + * The positon of the graph node, in 'world' coordinates. */ position: Vector2; /** - * An event which contains details about the process node. + * An event which contains details about the graph node. */ - event: SafeResolverEvent; + node: ResolverNode; /** - * projectionMatrix which can be used to convert `position` to screen coordinates. + * The unique identifier for the node based on a datasource id */ - projectionMatrix: Matrix3; + nodeID: string; /** - * Whether or not to show the process as terminated. + * projectionMatrix which can be used to convert `position` to screen coordinates. */ - isProcessTerminated: boolean; + projectionMatrix: Matrix3; /** * The time (unix epoch) at render. @@ -125,14 +167,7 @@ const UnstyledProcessEventDot = React.memo( const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant); const selectedNode = useSelector(selectors.selectedNode); const originID = useSelector(selectors.originID); - const nodeID: string | undefined = eventModel.entityIDSafeVersion(event); - if (nodeID === undefined) { - // NB: this component should be taking nodeID as a `string` instead of handling this logic here - throw new Error('Tried to render a node with no ID'); - } - const relatedEventStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) - ); + const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID)); // define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID. // this is used to link nodes via aria attributes @@ -218,6 +253,11 @@ const UnstyledProcessEventDot = React.memo( | null; } = React.createRef(); const colorMap = useColors(); + + const nodeState = useSelector((state: ResolverState) => + selectors.nodeDataStatus(state)(nodeID) + ); + const isNodeLoading = nodeState === 'loading'; const { backingFill, cubeSymbol, @@ -226,7 +266,7 @@ const UnstyledProcessEventDot = React.memo( labelButtonFill, strokeColor, } = useCubeAssets( - isProcessTerminated, + nodeState, /** * There is no definition for 'trigger process' yet. return false. */ false @@ -257,19 +297,29 @@ const UnstyledProcessEventDot = React.memo( if (animationTarget.current?.beginElement) { animationTarget.current.beginElement(); } - dispatch({ - type: 'userSelectedResolverNode', - payload: nodeID, - }); - processDetailNavProps.onClick(clickEvent); + + if (nodeState === 'error') { + dispatch({ + type: 'userReloadedResolverNode', + payload: nodeID, + }); + } else { + dispatch({ + type: 'userSelectedResolverNode', + payload: nodeID, + }); + processDetailNavProps.onClick(clickEvent); + } }, - [animationTarget, dispatch, nodeID, processDetailNavProps] + [animationTarget, dispatch, nodeID, processDetailNavProps, nodeState] ); const grandTotal: number | null = useSelector((state: ResolverState) => - selectors.relatedEventTotalForProcess(state)(event) + selectors.statsTotalForNode(state)(node) ); + const nodeName = nodeModel.nodeName(node); + /* eslint-disable jsx-a11y/click-events-have-key-events */ /** * Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component @@ -315,7 +365,7 @@ const UnstyledProcessEventDot = React.memo( zIndex: 30, }} > - + - + - {eventModel.processNameSafeVersion(event)} + {i18n.translate('xpack.securitySolution.resolver.node_button_name', { + defaultMessage: `{nodeState, select, error {Reload {nodeName}} other {{nodeName}}}`, + values: { + nodeState, + nodeName, + }, + })} - +
0 && ( )} diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx index d8d8de640d786..fa1686e7ea4b6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx @@ -35,12 +35,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 1, resolverGraphError: 0, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -66,12 +66,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 1, resolverGraphError: 0, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -96,12 +96,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 1, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -126,13 +126,13 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, resolverGraphNodes: simulator.testSubject('resolver:node').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 0, - resolverGraph: 1, + resolverTree: 1, resolverGraphNodes: 0, }); }); @@ -158,13 +158,13 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, resolverGraphNodes: simulator.testSubject('resolver:node').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 0, - resolverGraph: 1, + resolverTree: 1, resolverGraphNodes: 3, }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index ed969b913a72e..65b72cf4bfa77 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -19,7 +19,7 @@ import { useCamera } from './use_camera'; import { SymbolDefinitions } from './symbol_definitions'; import { useStateSyncingActions } from './use_state_syncing_actions'; import { StyledMapContainer, GraphContainer } from './styles'; -import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; import { SideEffectContext } from './side_effect_context'; import { ResolverProps, ResolverState } from '../types'; import { PanelRouter } from './panels'; @@ -54,7 +54,7 @@ export const ResolverWithoutProviders = React.memo( } = useSelector((state: ResolverState) => selectors.visibleNodesAndEdgeLines(state)(timeAtRender) ); - const terminatedProcesses = useSelector(selectors.terminatedProcesses); + const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera(); const ref = useCallback( @@ -113,15 +113,18 @@ export const ResolverWithoutProviders = React.memo( /> ) )} - {[...processNodePositions].map(([processEvent, position]) => { - const processEntityId = entityIDSafeVersion(processEvent); + {[...processNodePositions].map(([treeNode, position]) => { + const nodeID = nodeModel.nodeID(treeNode); + if (nodeID === undefined) { + throw new Error('Tried to render a node without an ID'); + } return ( ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 6312991ddb743..e24c4b5664e42 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; import { EuiI18nNumber } from '@elastic/eui'; -import { ResolverNodeStats } from '../../../common/endpoint/types'; +import { EventStats } from '../../../common/endpoint/types'; import { useRelatedEventByCategoryNavigation } from './use_related_event_by_category_navigation'; import { useColors } from './use_colors'; @@ -67,7 +67,7 @@ export const NodeSubMenuComponents = React.memo( ({ className, nodeID, - relatedEventStats, + nodeStats, }: { className?: string; // eslint-disable-next-line react/no-unused-prop-types @@ -76,18 +76,18 @@ export const NodeSubMenuComponents = React.memo( * Receive the projection matrix, so we can see when the camera position changed, so we can force the submenu to reposition itself. */ nodeID: string; - relatedEventStats: ResolverNodeStats | undefined; + nodeStats: EventStats | undefined; }) => { // The last projection matrix that was used to position the popover const relatedEventCallbacks = useRelatedEventByCategoryNavigation({ nodeID, - categories: relatedEventStats?.events?.byCategory, + categories: nodeStats?.byCategory, }); const relatedEventOptions = useMemo(() => { - if (relatedEventStats === undefined) { + if (nodeStats === undefined) { return []; } else { - return Object.entries(relatedEventStats.events.byCategory).map(([category, total]) => { + return Object.entries(nodeStats.byCategory).map(([category, total]) => { const [mantissa, scale, hasRemainder] = compactNotationParts(total || 0); const prefix = ( { diff --git a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx index edf551c6cbeb9..b06cce11661e8 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx @@ -8,10 +8,59 @@ import React, { memo } from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; import { useSymbolIDs } from './use_symbol_ids'; import { usePaintServerIDs } from './use_paint_server_ids'; +const loadingProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.loadingProcess', + { + defaultMessage: 'Loading Process', + } +); + +const errorProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.errorProcess', + { + defaultMessage: 'Error Process', + } +); + +const runningProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.runningProcess', + { + defaultMessage: 'Running Process', + } +); + +const triggerProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.triggerProcess', + { + defaultMessage: 'Trigger Process', + } +); + +const terminatedProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.terminatedProcess', + { + defaultMessage: 'Terminated Process', + } +); + +const terminatedTriggerProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.terminatedTriggerProcess', + { + defaultMessage: 'Terminated Trigger Process', + } +); + +const hoveredProcessBackgroundTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.hoveredProcessBackground', + { + defaultMessage: 'Hovered Process Background', + } +); /** * PaintServers: Where color palettes, gradients, patterns and other similar concerns * are exposed to the component @@ -20,6 +69,17 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => { const paintServerIDs = usePaintServerIDs(); return ( <> + + + + { paintOrder="normal" /> + + {loadingProcessTitle} + + + + {errorProcessTitle} + + + + + + + - {'Running Process'} + {runningProcessTitle} { /> - {'resolver_dark process running'} + {triggerProcessTitle} { /> - {'Terminated Process'} + {terminatedProcessTitle} { - {'Terminated Trigger Process'} + {terminatedTriggerProcessTitle} {isDarkMode && ( { - {'resolver active backing'} + {hoveredProcessBackgroundTitle} { /** Enzyme full DOM wrapper for the element the camera is attached to. */ @@ -247,43 +248,48 @@ describe('useCamera on an unpainted element', () => { expect(simulator.mock.requestAnimationFrame).not.toHaveBeenCalled(); }); describe('when the camera begins animation', () => { - let process: SafeResolverEvent; + let node: ResolverNode; beforeEach(async () => { - const events: SafeResolverEvent[] = []; - const numberOfEvents: number = 10; + const nodes: ResolverNode[] = []; + const numberOfNodes: number = 10; - for (let index = 0; index < numberOfEvents; index++) { - const uniquePpid = index === 0 ? undefined : index - 1; - events.push( - mockProcessEvent({ - endgame: { - unique_pid: index, - unique_ppid: uniquePpid, - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - }, + for (let index = 0; index < numberOfNodes; index++) { + const parentID = index === 0 ? undefined : String(index - 1); + nodes.push( + mockResolverNode({ + id: String(index), + name: '', + parentID, + timestamp: 0, + stats: { total: 0, byCategory: {} }, }) ); } - const tree = mockResolverTree({ events }); + const tree = mockResolverTree({ nodes }); if (tree !== null) { + const { schema, dataSource } = endpointSourceSchema(); const serverResponseAction: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: tree, parameters: mockTreeFetcherParameters() }, + payload: { + result: tree, + dataSource, + schema, + parameters: mockTreeFetcherParameters(), + }, }; store.dispatch(serverResponseAction); } else { throw new Error('failed to create tree'); } - const processes: SafeResolverEvent[] = [ + const resolverNodes: ResolverNode[] = [ ...selectors.layout(store.getState()).processNodePositions.keys(), ]; - process = processes[processes.length - 1]; + node = resolverNodes[resolverNodes.length - 1]; if (!process) { throw new Error('missing the process to bring into view'); } simulator.controls.time = 0; - const nodeID = entityIDSafeVersion(process); + const nodeID = nodeModel.nodeID(node); if (!nodeID) { throw new Error('could not find nodeID for process'); } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts index 7daf181a7b2bb..90ce5dc22d177 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts @@ -15,6 +15,7 @@ type ResolverColorNames = | 'full' | 'graphControls' | 'graphControlsBackground' + | 'graphControlsBorderColor' | 'linkColor' | 'resolverBackground' | 'resolverEdge' @@ -38,6 +39,7 @@ export function useColors(): ColorMap { full: theme.euiColorFullShade, graphControls: theme.euiColorDarkestShade, graphControlsBackground: theme.euiColorEmptyShade, + graphControlsBorderColor: theme.euiColorLightShade, processBackingFill: `${theme.euiColorPrimary}${isDarkMode ? '1F' : '0F'}`, // Add opacity 0F = 6% , 1F = 12% resolverBackground: theme.euiColorEmptyShade, resolverEdge: isDarkMode ? theme.euiColorLightShade : theme.euiColorLightestShade, diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts b/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts index c743ebc43f2be..94f08c5f3fee3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts @@ -10,7 +10,7 @@ import { ButtonColor } from '@elastic/eui'; import euiThemeAmsterdamDark from '@elastic/eui/dist/eui_theme_amsterdam_dark.json'; import euiThemeAmsterdamLight from '@elastic/eui/dist/eui_theme_amsterdam_light.json'; import { useMemo } from 'react'; -import { ResolverProcessType } from '../types'; +import { ResolverProcessType, NodeDataStatus } from '../types'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; import { useSymbolIDs } from './use_symbol_ids'; import { useColors } from './use_colors'; @@ -19,7 +19,7 @@ import { useColors } from './use_colors'; * Provides colors and HTML IDs used to render the 'cube' graphic that accompanies nodes. */ export function useCubeAssets( - isProcessTerminated: boolean, + cubeType: NodeDataStatus, isProcessTrigger: boolean ): NodeStyleConfig { const SymbolIds = useSymbolIDs(); @@ -40,6 +40,28 @@ export function useCubeAssets( labelButtonFill: 'primary', strokeColor: theme.euiColorPrimary, }, + loadingCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.loadingCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.loadingProcess', { + defaultMessage: 'Loading Process', + }), + isLabelFilled: false, + labelButtonFill: 'primary', + strokeColor: theme.euiColorPrimary, + }, + errorCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.errorCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.errorProcess', { + defaultMessage: 'Error Process', + }), + isLabelFilled: false, + labelButtonFill: 'primary', + strokeColor: theme.euiColorPrimary, + }, runningTriggerCube: { backingFill: colorMap.triggerBackingFill, cubeSymbol: `#${SymbolIds.runningTriggerCube}`, @@ -83,16 +105,22 @@ export function useCubeAssets( [SymbolIds, colorMap, theme] ); - if (isProcessTerminated) { + if (cubeType === 'terminated') { if (isProcessTrigger) { return nodeAssets.terminatedTriggerCube; } else { return nodeAssets[processTypeToCube.processTerminated]; } - } else if (isProcessTrigger) { - return nodeAssets[processTypeToCube.processCausedAlert]; + } else if (cubeType === 'running') { + if (isProcessTrigger) { + return nodeAssets[processTypeToCube.processCausedAlert]; + } else { + return nodeAssets[processTypeToCube.processRan]; + } + } else if (cubeType === 'error') { + return nodeAssets[processTypeToCube.processError]; } else { - return nodeAssets[processTypeToCube.processRan]; + return nodeAssets[processTypeToCube.processLoading]; } } @@ -102,6 +130,8 @@ const processTypeToCube: Record = { processTerminated: 'terminatedProcessCube', unknownProcessEvent: 'runningProcessCube', processCausedAlert: 'runningTriggerCube', + processLoading: 'loadingCube', + processError: 'errorCube', unknownEvent: 'runningProcessCube', }; interface NodeStyleMap { @@ -109,6 +139,8 @@ interface NodeStyleMap { runningTriggerCube: NodeStyleConfig; terminatedProcessCube: NodeStyleConfig; terminatedTriggerCube: NodeStyleConfig; + loadingCube: NodeStyleConfig; + errorCube: NodeStyleConfig; } interface NodeStyleConfig { backingFill: string; diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts b/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts index 0336a29bb0721..10fbd58a9deb3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts @@ -23,6 +23,8 @@ export function usePaintServerIDs() { runningTriggerCube: `${prefix}-psRunningTriggerCube`, terminatedProcessCube: `${prefix}-psTerminatedProcessCube`, terminatedTriggerCube: `${prefix}-psTerminatedTriggerCube`, + loadingCube: `${prefix}-psLoadingCube`, + errorCube: `${prefix}-psErrorCube`, }; }, [resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts b/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts index 0e1fd5737a3ce..da00d4c0dbf43 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts @@ -25,6 +25,8 @@ export function useSymbolIDs() { terminatedProcessCube: `${prefix}-terminatedCube`, terminatedTriggerCube: `${prefix}-terminatedTriggerCube`, processCubeActiveBacking: `${prefix}-activeBacking`, + loadingCube: `${prefix}-loadingCube`, + errorCube: `${prefix}-errorCube`, }; }, [resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 5b558df8388e4..b53c11868998f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -72,7 +72,7 @@ const NavigationComponent: React.FC = ({ timelineFullScreen, toggleFullScreen, }) => ( - + {i18n.CLOSE_ANALYZER} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index 1d4cea700d003..0dae9a97b6e5b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -117,7 +117,9 @@ export const getEventType = (event: Ecs): Omit => { }; export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => - get(['agent', 'type', 0], ecsData) === 'endpoint' && + (get(['agent', 'type', 0], ecsData) === 'endpoint' || + (get(['agent', 'type', 0], ecsData) === 'winlogbeat' && + get(['event', 'module', 0], ecsData) === 'sysmon')) && get(['process', 'entity_id'], ecsData)?.length === 1 && get(['process', 'entity_id', 0], ecsData) !== ''; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index c731692e6fb89..6d4168d744fca 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -19,7 +19,7 @@ interface SupportedSchema { /** * A constraint to search for in the documented returned by Elasticsearch */ - constraint: { field: string; value: string }; + constraints: Array<{ field: string; value: string }>; /** * Schema to return to the frontend so that it can be passed in to call to the /tree API @@ -34,10 +34,12 @@ interface SupportedSchema { const supportedSchemas: SupportedSchema[] = [ { name: 'endpoint', - constraint: { - field: 'agent.type', - value: 'endpoint', - }, + constraints: [ + { + field: 'agent.type', + value: 'endpoint', + }, + ], schema: { id: 'process.entity_id', parent: 'process.parent.entity_id', @@ -47,10 +49,16 @@ const supportedSchemas: SupportedSchema[] = [ }, { name: 'winlogbeat', - constraint: { - field: 'agent.type', - value: 'winlogbeat', - }, + constraints: [ + { + field: 'agent.type', + value: 'winlogbeat', + }, + { + field: 'event.module', + value: 'sysmon', + }, + ], schema: { id: 'process.entity_id', parent: 'process.parent.entity_id', @@ -104,14 +112,17 @@ export function handleEntities(): RequestHandler { - const kqlQuery: JsonObject[] = []; - if (kql) { - kqlQuery.push(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kql))); - } + async search( + client: IScopedClusterClient, + filter: string | undefined + ): Promise { + const parsedFilters = EventsQuery.buildFilters(filter); const response: ApiResponse< SearchResponse - > = await client.asCurrentUser.search(this.buildSearch(kqlQuery)); + > = await client.asCurrentUser.search(this.buildSearch(parsedFilters)); return response.body.hits.hits.map((hit) => hit._source); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts index 3baf3a8667529..63cd3b5d694af 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts @@ -8,12 +8,12 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Timerange, docValueFields } from '../utils/index'; +import { NodeID, TimeRange, docValueFields } from '../utils/index'; interface DescendantsParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -22,13 +22,13 @@ interface DescendantsParams { export class DescendantsQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; + private readonly timeRange: TimeRange; private readonly docValueFields: JsonValue[]; - constructor({ schema, indexPatterns, timerange }: DescendantsParams) { + constructor({ schema, indexPatterns, timeRange }: DescendantsParams) { this.docValueFields = docValueFields(schema); this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[], size: number): JsonObject { @@ -46,8 +46,8 @@ export class DescendantsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, @@ -126,8 +126,8 @@ export class DescendantsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts index 5253806be66ba..150b07c63ce2f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts @@ -8,12 +8,12 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Timerange, docValueFields } from '../utils/index'; +import { NodeID, TimeRange, docValueFields } from '../utils/index'; interface LifecycleParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -22,13 +22,13 @@ interface LifecycleParams { export class LifecycleQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; + private readonly timeRange: TimeRange; private readonly docValueFields: JsonValue[]; - constructor({ schema, indexPatterns, timerange }: LifecycleParams) { + constructor({ schema, indexPatterns, timeRange }: LifecycleParams) { this.docValueFields = docValueFields(schema); this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[]): JsonObject { @@ -46,8 +46,8 @@ export class LifecycleQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts index 117cc3647dd0e..22d2c600feb01 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts @@ -8,7 +8,7 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { JsonObject } from '../../../../../../../../../src/plugins/kibana_utils/common'; import { EventStats, ResolverSchema } from '../../../../../../common/endpoint/types'; -import { NodeID, Timerange } from '../utils/index'; +import { NodeID, TimeRange } from '../utils/index'; interface AggBucket { key: string; @@ -28,7 +28,7 @@ interface CategoriesAgg extends AggBucket { interface StatsParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -37,11 +37,11 @@ interface StatsParams { export class StatsQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; - constructor({ schema, indexPatterns, timerange }: StatsParams) { + private readonly timeRange: TimeRange; + constructor({ schema, indexPatterns, timeRange }: StatsParams) { this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[]): JsonObject { @@ -53,8 +53,8 @@ export class StatsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts index d5e0af9dea239..796ed60ddbbc3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts @@ -80,7 +80,7 @@ describe('fetcher test', () => { descendantLevels: 1, descendants: 5, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -100,7 +100,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -163,7 +163,7 @@ describe('fetcher test', () => { descendantLevels: 2, descendants: 5, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -188,7 +188,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 5, - timerange: { + timeRange: { from: '', to: '', }, @@ -211,7 +211,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -249,7 +249,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 2, - timerange: { + timeRange: { from: '', to: '', }, @@ -292,7 +292,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 2, - timerange: { + timeRange: { from: '', to: '', }, @@ -342,7 +342,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 3, - timerange: { + timeRange: { from: '', to: '', }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts index 356357082d6ee..2ff231892a593 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts @@ -27,7 +27,7 @@ export interface TreeOptions { descendantLevels: number; descendants: number; ancestors: number; - timerange: { + timeRange: { from: string; to: string; }; @@ -76,7 +76,7 @@ export class Fetcher { const query = new StatsQuery({ indexPatterns: options.indexPatterns, schema: options.schema, - timerange: options.timerange, + timeRange: options.timeRange, }); const eventStats = await query.search(this.client, statsIDs); @@ -136,7 +136,7 @@ export class Fetcher { const query = new LifecycleQuery({ schema: options.schema, indexPatterns: options.indexPatterns, - timerange: options.timerange, + timeRange: options.timeRange, }); let nodes = options.nodes; @@ -182,7 +182,7 @@ export class Fetcher { const query = new DescendantsQuery({ schema: options.schema, indexPatterns: options.indexPatterns, - timerange: options.timerange, + timeRange: options.timeRange, }); let nodes: NodeID[] = options.nodes; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts index be08b4390a69c..c00e90a386fb6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts @@ -9,7 +9,7 @@ import { ResolverSchema } from '../../../../../../common/endpoint/types'; /** * Represents a time range filter */ -export interface Timerange { +export interface TimeRange { from: string; to: string; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts index 5bc911fb075b5..00aab683bf010 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts @@ -49,18 +49,18 @@ export class AncestryQueryHandler implements QueryHandler private toMapOfNodes(results: SafeResolverEvent[]) { return results.reduce( (nodes: Map, event: SafeResolverEvent) => { - const nodeId = entityIDSafeVersion(event); - if (!nodeId) { + const nodeID = entityIDSafeVersion(event); + if (!nodeID) { return nodes; } - let node = nodes.get(nodeId); + let node = nodes.get(nodeID); if (!node) { - node = createLifecycle(nodeId, []); + node = createLifecycle(nodeID, []); } node.lifecycle.push(event); - return nodes.set(nodeId, node); + return nodes.set(nodeID, node); }, new Map() ); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1ca0f7ec1f69c..eb1fd694114ce 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17914,7 +17914,6 @@ "xpack.securitySolution.resolver.eventDescription.networkEventLabel": "{ networkDirection } { forwardedIP }", "xpack.securitySolution.resolver.eventDescription.registryKeyLabel": "{ registryKey }", "xpack.securitySolution.resolver.eventDescription.registryPathLabel": "{ registryPath }", - "xpack.securitySolution.resolver.node_icon": "{running, select, true {実行中のプロセス} false {終了したプロセス}}", "xpack.securitySolution.resolver.panel.copyToClipboard": "クリップボードにコピー", "xpack.securitySolution.resolver.panel.eventDetail.requestError": "イベント詳細を取得できませんでした", "xpack.securitySolution.resolver.panel.nodeList.title": "すべてのプロセスイベント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 65e24a89350c3..8ad261449854e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17932,7 +17932,6 @@ "xpack.securitySolution.resolver.eventDescription.networkEventLabel": "{ networkDirection } { forwardedIP }", "xpack.securitySolution.resolver.eventDescription.registryKeyLabel": "{ registryKey }", "xpack.securitySolution.resolver.eventDescription.registryPathLabel": "{ registryPath }", - "xpack.securitySolution.resolver.node_icon": "{running, select, true {正在运行的进程} false {已终止的进程}}", "xpack.securitySolution.resolver.panel.copyToClipboard": "复制到剪贴板", "xpack.securitySolution.resolver.panel.eventDetail.requestError": "无法检索事件详情", "xpack.securitySolution.resolver.panel.nodeList.title": "所有进程事件", diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz b/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz index e1b9c01101f6e2716cc62b9ea776b152ce8abbae..0000bc249b4761298f0ef9af3695898aed480b62 100644 GIT binary patch delta 16 XcmbO!HB*XRzMF&NKvVoib|GE>DW(LV delta 16 XcmbO!HB*XRzMF%C@2metb|GE>Cn^L{ diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/data.json.gz b/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..529ee42991f90f439b3dbf371a52eb495b411dc3 GIT binary patch literal 2798 zcmVQOZ*BnXTI+AzHWL4yze3ng$syYEq4y!cdA)Yy zyQB$Xw?%sj3`(Nbx@x7Zq;+f*{@-t?$BLA+n|0#$dO#i9@RFP%InUn=^X%Dg&opgT z-Ea8J(`Hc~&R&XvTb6Fch?s?sNhD^=`*NLUI=|&vRWB1WS;X(eQz9(FKsWF7dOAHJrxNPCeg;sa3(&+If=lQhR>qSTB74 z+dN;T-1ooVL~zLNbMtA=@3^0dm&MY5^tiH1(Q$Us(U z%8U9W(Kf7hQ|%7fX};dD?0z@?=FO>}y(^MclCe~5)`)0)5FlK$EP9hh-%17gs;| z{^d`XS7&ei>`lJPv!Rx-Wf+Z?xZRmq`}NS02pyq2dS?6C6))D}`j?=(q@4lXWffl) zNp=4Q3_JC-%lbfm_L`M9y!5oUOY;?4oSx8gY%P~2u@?q5k!3h%r}&Iorwc+(US7UF zA*S_eVWBgZAY?45Yb?Kf;UYo1YT(&hPLadwWe zxv*YdynX-rY!T|7nu}A%G%bXd1|}F8$bunIJQGsgW4$03+Q37%ziPH-;qa0Oc_9C+}DOJUvgz8N^Y2$;*W%YR+Hd#@W8ctPktC0?~cj#$Qu@FuCi zo3};16avDvWymY?Qqe1nY*JRmd9iL&g)k8H7Pn3C6Z06&2Um>QI!W*M(spNVElIJ1t(FxAY^YFNrpbUIuBJFR19ox&e2#} z4TMptYOr%%>lI6qs#y8U7=38Z3jiOU){ZuVFBI3=B&`+KjKy}+YzRqvTk+!h2qz40 zSk@Z!+axUVGLI|0bpZXcMrz-@+wg)3oai~{u3RW0a~Rsrl&mq<9X@q`X(s>_B*v1i#MJe8saTlLxRD7V#F6=c=Xrv?Y= zDhcaKci5p_b#aID#Y%lT9FIb3Z9|~QBu?5GsAJvKu```dMiKIupe&VB0?$e(8^@@+ zl2YQ$8uXmtQVH zboEw*Td|$Y-K7xK1J638r6PPRiUr zi@z?i`=hf+&-T_<2H3Bb)a^Etf(q)9B``j{1yA z*Y{_C*Xl41!xyV8FZd(){K$$7W}kr5MPI`_0kBbN~7MY}axbrZK2CFWXuz)&|>$7YH3Fq@i|FF%<4GVd5d=^pLiWztvBXEbv#=2{oj*bCG zS{8B`4jqCm&0Tk3U@mE-w!)ff$tvdch)GKD6F_*1 zmx4KKO%79o6nGWQ?kQVZO|`I<5~btWnm{{wj13Ih#Zz6~zUIAt+*#XdcT{KXg7$U9 zE0(0=dR1){OYnLW_mw==2%$l>*ZuBFDi{-VNPRZU88c<&!^`82IE;Z$aKbJ_4zkn1 zt8|IsPk4zzYW#n_#zbEJ9j?;vx69lIcWHC+c+Jzv@H>Pw zIKK4hqfEc!zV{4$NUgK=p^ny}r|biDQg?(sorJ09Sy6J|N`2cPCOE_t>7na3{STdE z&UJ!=*rlNh}$7G6#ySMVgO_9Sdu`&53s2bj$=f3^o(*`eo%FNVzBf&bg zK!_kWFjZm@VmP*K>$YJS$Q5H)^zEiG(xG?%-bj?U|B)*_<@=ZNaB@r$bm)cX>uMmy z>XNE`bLopawJ9_GZ1)wrPxgq!J`m%#brTeiMEV8)P35n?`gC9YzZPG4SIjH`09Oxt AyZ`_I literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/mappings.json b/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/mappings.json new file mode 100644 index 0000000000000..a8673d85c3061 --- /dev/null +++ b/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/mappings.json @@ -0,0 +1,2934 @@ +{ + "type": "index", + "value": { + "aliases": { + "winlogbeat-7.11.0-default": { + "is_write_index": true + } + }, + "index": "winlogbeat-7.11.0-2020.12.03-000001", + "mappings": { + "dynamic": "false", + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "doc_values": false, + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "routing": { + "allocation": { + "include": { + "_tier": "data_hot" + } + } + } + } + } + } +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/package.ts b/x-pack/test/security_solution_endpoint_api_int/apis/package.ts index 8dc78ed71d0b6..76361bd459890 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/package.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/package.ts @@ -106,10 +106,21 @@ export default function ({ getService }: FtrProviderContext) { // this id comes from the es archive file endpoint/pipeline/dns const id = 'LrLSOVHVsFY94TAi++++++eF'; const { body }: { body: ResolverPaginatedEvents } = await supertest - .post(`/api/endpoint/resolver/events?limit=1`) + .post(`/api/endpoint/resolver/events`) + .query({ limit: 1 }) .set('kbn-xsrf', 'xxx') .send({ - filter: `event.id:"${id}"`, + filter: JSON.stringify({ + bool: { + filter: [{ term: { 'event.id': id } }], + }, + }), + indexPatterns: [eventsIndexPattern], + // these times are taken from the es archiver data endpoint/pipeline/dns for this specific event + timeRange: { + from: '2020-10-01T13:50:15.14364600Z', + to: '2020-10-01T13:50:15.14364600Z', + }, }) .expect(200); expect(body.events.length).to.eql(1); @@ -121,10 +132,21 @@ export default function ({ getService }: FtrProviderContext) { // this id comes from the es archive file endpoint/pipeline/dns const id = 'LrLSOVHVsFY94TAi++++++eP'; const { body }: { body: ResolverPaginatedEvents } = await supertest - .post(`/api/endpoint/resolver/events?limit=1`) + .post(`/api/endpoint/resolver/events`) + .query({ limit: 1 }) .set('kbn-xsrf', 'xxx') .send({ - filter: `event.id:"${id}"`, + filter: JSON.stringify({ + bool: { + filter: [{ term: { 'event.id': id } }], + }, + }), + indexPatterns: [eventsIndexPattern], + // these times are taken from the es archiver data endpoint/pipeline/dns for this specific event + timeRange: { + from: '2020-10-01T13:50:15.44516300Z', + to: '2020-10-01T13:50:15.44516300Z', + }, }) .expect(200); expect(body.events.length).to.eql(1); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts index 2607b934e7df2..f26e2410b6c55 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts @@ -13,52 +13,93 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('Resolver tests for the entity route', () => { - before(async () => { - await esArchiver.load('endpoint/resolver/signals'); - }); + describe('winlogbeat tests', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/winlogbeat'); + }); - after(async () => { - await esArchiver.unload('endpoint/resolver/signals'); - }); + after(async () => { + await esArchiver.unload('endpoint/resolver/winlogbeat'); + }); - it('returns an event even if it does not have a mapping for entity_id', async () => { - // this id is from the es archive - const _id = 'fa7eb1546f44fd47d8868be8d74e0082e19f22df493c67a7725457978eb648ab'; - const { body }: { body: ResolverEntityIndex } = await supertest.get( - `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` - ); - expect(body).eql([ - { - name: 'endpoint', - schema: { - id: 'process.entity_id', - parent: 'process.parent.entity_id', - ancestry: 'process.Ext.ancestry', - name: 'process.name', + it('returns a winlogbeat sysmon event when the event matches the schema correctly', async () => { + // this id is from the es archive + const _id = 'sysmon-event'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=winlogbeat-7.11.0-default` + ); + expect(body).eql([ + { + name: 'winlogbeat', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + name: 'process.name', + }, + // this value is from the es archive + id: '{98da333e-2060-5fc9-2e01-000000003f00}', }, - // this value is from the es archive - id: - 'MTIwNWY1NWQtODRkYS00MzkxLWIyNWQtYTNkNGJmNDBmY2E1LTc1NTItMTMyNDM1NDY1MTQuNjI0MjgxMDA=', - }, - ]); - }); + ]); + }); - it('does not return an event when it does not have the entity_id field in the document', async () => { - // this id is from the es archive - const _id = 'no-entity-id-field'; - const { body }: { body: ResolverEntityIndex } = await supertest.get( - `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` - ); - expect(body).to.be.empty(); + it('does not return a powershell event that has event.module set to powershell', async () => { + // this id is from the es archive + const _id = 'powershell-event'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=winlogbeat-7.11.0-default` + ); + expect(body).to.be.empty(); + }); }); - it('does not return an event when it does not have the process field in the document', async () => { - // this id is from the es archive - const _id = 'no-process-field'; - const { body }: { body: ResolverEntityIndex } = await supertest.get( - `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` - ); - expect(body).to.be.empty(); + describe('signals index mapping tests', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/signals'); + }); + + after(async () => { + await esArchiver.unload('endpoint/resolver/signals'); + }); + + it('returns an event even if it does not have a mapping for entity_id', async () => { + // this id is from the es archive + const _id = 'fa7eb1546f44fd47d8868be8d74e0082e19f22df493c67a7725457978eb648ab'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).eql([ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + // this value is from the es archive + id: + 'MTIwNWY1NWQtODRkYS00MzkxLWIyNWQtYTNkNGJmNDBmY2E1LTc1NTItMTMyNDM1NDY1MTQuNjI0MjgxMDA=', + }, + ]); + }); + + it('does not return an event when it does not have the entity_id field in the document', async () => { + // this id is from the es archive + const _id = 'no-entity-id-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); + + it('does not return an event when it does not have the process field in the document', async () => { + // this id is from the es archive + const _id = 'no-process-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); }); }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts index 0878c09cff500..220d932787fff 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; -import { eventIDSafeVersion } from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { JsonObject } from 'src/plugins/kibana_utils/common'; +import { eventsIndexPattern } from '../../../../plugins/security_solution/common/endpoint/constants'; +import { + eventIDSafeVersion, + parentEntityIDSafeVersion, + timestampAsDateSafeVersion, +} from '../../../../plugins/security_solution/common/endpoint/models/event'; import { ResolverPaginatedEvents } from '../../../../plugins/security_solution/common/endpoint/types'; import { FtrProviderContext } from '../../ftr_provider_context'; import { @@ -41,23 +47,42 @@ export default function ({ getService }: FtrProviderContext) { }; describe('event route', () => { + let entityIDFilterArray: JsonObject[] | undefined; let entityIDFilter: string | undefined; before(async () => { resolverTrees = await resolver.createTrees(treeOptions); // we only requested a single alert so there's only 1 tree tree = resolverTrees.trees[0]; - entityIDFilter = `process.entity_id:"${tree.origin.id}" and not event.category:"process"`; + entityIDFilterArray = [ + { term: { 'process.entity_id': tree.origin.id } }, + { bool: { must_not: { term: { 'event.category': 'process' } } } }, + ]; + entityIDFilter = JSON.stringify({ + bool: { + filter: entityIDFilterArray, + }, + }); }); after(async () => { await resolver.deleteData(resolverTrees); }); it('should filter events by event.id', async () => { + const filter = JSON.stringify({ + bool: { + filter: [{ term: { 'event.id': tree.origin.relatedEvents[0]?.event?.id } }], + }, + }); const { body }: { body: ResolverPaginatedEvents } = await supertest .post(`/api/endpoint/resolver/events`) .set('kbn-xsrf', 'xxx') .send({ - filter: `event.id:"${tree.origin.relatedEvents[0]?.event?.id}"`, + filter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(1); @@ -66,11 +91,21 @@ export default function ({ getService }: FtrProviderContext) { }); it('should not find any events when given an invalid entity id', async () => { + const filter = JSON.stringify({ + bool: { + filter: [{ term: { 'process.entity_id': '5555' } }], + }, + }); const { body }: { body: ResolverPaginatedEvents } = await supertest .post(`/api/endpoint/resolver/events`) .set('kbn-xsrf', 'xxx') .send({ - filter: 'process.entity_id:"5555"', + filter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.nextEvent).to.eql(null); @@ -83,6 +118,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(4); @@ -91,12 +131,24 @@ export default function ({ getService }: FtrProviderContext) { }); it('should allow for the events to be filtered', async () => { - const filter = `event.category:"${RelatedEventCategory.Driver}" and ${entityIDFilter}`; + const filter = JSON.stringify({ + bool: { + filter: [ + { term: { 'event.category': RelatedEventCategory.Driver } }, + ...(entityIDFilterArray ?? []), + ], + }, + }); const { body }: { body: ResolverPaginatedEvents } = await supertest .post(`/api/endpoint/resolver/events`) .set('kbn-xsrf', 'xxx') .send({ filter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(2); @@ -113,6 +165,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(2); @@ -124,6 +181,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200)); expect(body.events.length).to.eql(2); @@ -135,6 +197,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200)); expect(body.events).to.be.empty(); @@ -147,6 +214,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(4); @@ -160,6 +232,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(4); @@ -171,5 +248,122 @@ export default function ({ getService }: FtrProviderContext) { expect(eventIDSafeVersion(body.events[i])).to.equal(relatedEvents[i].event?.id); } }); + + it('should only return data within the specified timeRange', async () => { + const from = + timestampAsDateSafeVersion(tree.origin.relatedEvents[0])?.toISOString() ?? + new Date(0).toISOString(); + const to = from; + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from, + to, + }, + }) + .expect(200); + expect(body.events.length).to.eql(1); + expect(tree.origin.relatedEvents[0]?.event?.id).to.eql(body.events[0].event?.id); + expect(body.nextEvent).to.eql(null); + }); + + it('should not find events when using an incorrect index pattern', async () => { + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: entityIDFilter, + indexPatterns: ['metrics-*'], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200); + expect(body.events.length).to.eql(0); + expect(body.nextEvent).to.eql(null); + }); + + it('should retrieve lifecycle events for multiple ids', async () => { + const originParentID = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParentID).to.not.be(''); + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: JSON.stringify({ + bool: { + filter: [ + { terms: { 'process.entity_id': [tree.origin.id, originParentID] } }, + { term: { 'event.category': 'process' } }, + ], + }, + }), + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200); + // 2 lifecycle events for the origin and 2 for the origin's parent + expect(body.events.length).to.eql(4); + expect(body.nextEvent).to.eql(null); + }); + + it('should paginate lifecycle events for multiple ids', async () => { + const originParentID = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParentID).to.not.be(''); + let { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .query({ limit: 2 }) + .set('kbn-xsrf', 'xxx') + .send({ + filter: JSON.stringify({ + bool: { + filter: [ + { terms: { 'process.entity_id': [tree.origin.id, originParentID] } }, + { term: { 'event.category': 'process' } }, + ], + }, + }), + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200); + expect(body.events.length).to.eql(2); + expect(body.nextEvent).not.to.eql(null); + + ({ body } = await supertest + .post(`/api/endpoint/resolver/events`) + .query({ limit: 3, afterEvent: body.nextEvent }) + .set('kbn-xsrf', 'xxx') + .send({ + filter: JSON.stringify({ + bool: { + filter: [ + { terms: { 'process.entity_id': [tree.origin.id, originParentID] } }, + { term: { 'event.category': 'process' } }, + ], + }, + }), + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200)); + + expect(body.events.length).to.eql(2); + expect(body.nextEvent).to.eql(null); + }); }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index 7a1210c6b762f..9a731f1d5aee0 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -84,7 +84,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 9, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -109,7 +109,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 9, schema: schemaWithAncestry, nodes: ['bogus id'], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -130,7 +130,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 3, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -155,7 +155,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -183,7 +183,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from, to: from, }, @@ -210,7 +210,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id, bottomMostDescendant], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -246,7 +246,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [leftNode, rightNode], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -277,7 +277,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -299,7 +299,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -329,7 +329,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -358,7 +358,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: ['bogus id'], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -381,7 +381,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [childID], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -414,7 +414,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [leftNodeID, rightNodeID], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -447,7 +447,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [tree.origin.id, originGrandparent], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -484,7 +484,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [tree.origin.id, originGrandparent], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -520,7 +520,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: end, }, @@ -549,7 +549,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithName, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -587,7 +587,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -625,7 +625,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -663,7 +663,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, From 21ea4f7a6ff34dcb6df75a9e8fcb015b91962c3f Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 9 Dec 2020 13:18:37 -0500 Subject: [PATCH 33/52] [Security Solution][Detection Engine] - Improve DE query build times for large lists (#85051) ## Summary This PR addresses the following issues: - https://github.com/elastic/kibana/issues/76979 - https://github.com/elastic/kibana/issues/82267 - removal of unused lucene exceptions logic --- x-pack/plugins/lists/common/constants.mock.ts | 1 + .../exception_list_item_schema.mock.ts | 4 + .../common/schemas/types/entry_exists.mock.ts | 5 + .../common/schemas/types/entry_match.mock.ts | 5 + .../schemas/types/entry_match_any.mock.ts | 6 + .../common/schemas/types/entry_nested.mock.ts | 19 +- .../common/schemas/types/entry_nested.test.ts | 4 +- .../build_exceptions_filter.test.ts | 1099 ++++++++++++++++ .../build_exceptions_filter.ts | 293 +++++ .../build_exceptions_query.test.ts | 788 ------------ .../build_exceptions_query.ts | 200 --- .../detection_engine/get_query_filter.test.ts | 1110 +++++++++-------- .../detection_engine/get_query_filter.ts | 106 +- .../common/detection_engine/types.ts | 18 + .../exceptions/viewer/helpers.test.tsx | 2 +- .../server/lib/machine_learning/index.ts | 8 +- 16 files changed, 2058 insertions(+), 1610 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.test.ts create mode 100644 x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.ts delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts delete mode 100644 x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index c712af83dd9b1..5385a116b29bc 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -36,6 +36,7 @@ export const TYPE = 'ip'; export const VALUE = '127.0.0.1'; export const VALUE_2 = '255.255.255'; export const NAMESPACE_TYPE = 'single'; +export const NESTED_FIELD = 'parent.field'; // Exception List specific export const ID = 'uuid_here'; diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts index 451bbaecca7e1..0b47de6cf324a 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts @@ -43,6 +43,10 @@ export const getExceptionListItemSchemaMock = (): ExceptionListItemSchema => ({ updated_by: USER, }); +export const getExceptionListItemSchemaXMock = (count = 1): ExceptionListItemSchema[] => { + return new Array(count).fill(null).map(() => getExceptionListItemSchemaMock()); +}; + /** * This is useful for end to end tests where we remove the auto generated parts for comparisons * such as created_at, updated_at, and id. diff --git a/x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts index aa93eee6374a4..77dabaeb61d2c 100644 --- a/x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts @@ -13,3 +13,8 @@ export const getEntryExistsMock = (): EntryExists => ({ operator: OPERATOR, type: EXISTS, }); + +export const getEntryExistsExcludedMock = (): EntryExists => ({ + ...getEntryExistsMock(), + operator: 'excluded', +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts index 5f3a09f17eb3b..9c845788df68e 100644 --- a/x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts @@ -14,3 +14,8 @@ export const getEntryMatchMock = (): EntryMatch => ({ type: MATCH, value: ENTRY_VALUE, }); + +export const getEntryMatchExcludeMock = (): EntryMatch => ({ + ...getEntryMatchMock(), + operator: 'excluded', +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts index ac4ef69207c8c..1ab5163028c20 100644 --- a/x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts @@ -14,3 +14,9 @@ export const getEntryMatchAnyMock = (): EntryMatchAny => ({ type: MATCH_ANY, value: [ENTRY_VALUE], }); + +export const getEntryMatchAnyExcludeMock = (): EntryMatchAny => ({ + ...getEntryMatchAnyMock(), + operator: 'excluded', + value: [ENTRY_VALUE, 'some other host name'], +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts index d0e7712301ee1..e666ada490382 100644 --- a/x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts @@ -4,14 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FIELD, NESTED } from '../../constants.mock'; +import { NESTED, NESTED_FIELD } from '../../constants.mock'; import { EntryNested } from './entry_nested'; -import { getEntryMatchMock } from './entry_match.mock'; -import { getEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEntryMatchExcludeMock, getEntryMatchMock } from './entry_match.mock'; +import { getEntryMatchAnyExcludeMock, getEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEntryExistsMock } from './entry_exists.mock'; export const getEntryNestedMock = (): EntryNested => ({ entries: [getEntryMatchMock(), getEntryMatchAnyMock()], - field: FIELD, + field: NESTED_FIELD, type: NESTED, }); + +export const getEntryNestedExcludeMock = (): EntryNested => ({ + ...getEntryNestedMock(), + entries: [getEntryMatchExcludeMock(), getEntryMatchAnyExcludeMock()], +}); + +export const getEntryNestedMixedEntries = (): EntryNested => ({ + ...getEntryNestedMock(), + entries: [getEntryMatchMock(), getEntryMatchAnyExcludeMock(), getEntryExistsMock()], +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts b/x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts index d77440b207d03..f78a3633a19ef 100644 --- a/x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts +++ b/x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts @@ -86,7 +86,7 @@ describe('entriesNested', () => { value: ['some host name'], }, ], - field: 'host.name', + field: 'parent.field', type: 'nested', }); }); @@ -105,7 +105,7 @@ describe('entriesNested', () => { type: 'exists', }, ], - field: 'host.name', + field: 'parent.field', type: 'nested', }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.test.ts new file mode 100644 index 0000000000000..15c73ed1fd12c --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.test.ts @@ -0,0 +1,1099 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getEntryMatchMock, + getEntryMatchExcludeMock, +} from '../../../lists/common/schemas/types/entry_match.mock'; +import { + getEntryMatchAnyMock, + getEntryMatchAnyExcludeMock, +} from '../../../lists/common/schemas/types/entry_match_any.mock'; +import { + getEntryExistsMock, + getEntryExistsExcludedMock, +} from '../../../lists/common/schemas/types/entry_exists.mock'; +import { + getEntryNestedMock, + getEntryNestedExcludeMock, + getEntryNestedMixedEntries, +} from '../../../lists/common/schemas/types/entry_nested.mock'; +import { + getExceptionListItemSchemaMock, + getExceptionListItemSchemaXMock, +} from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; + +import { + buildExceptionItemFilter, + buildExclusionClause, + buildExistsClause, + buildMatchAnyClause, + buildMatchClause, + buildNestedClause, + createOrClauses, + chunkExceptions, + buildExceptionFilter, + ExceptionItemSansLargeValueLists, +} from './build_exceptions_filter'; +import { EntryMatchAny, ExceptionListItemSchema } from '../../common/shared_imports'; +import { hasLargeValueList } from './utils'; + +const modifiedGetEntryMatchAnyMock = (): EntryMatchAny => ({ + ...getEntryMatchAnyMock(), + operator: 'included', + value: ['some "host" name', 'some other host name'], +}); + +const getExceptionListItemsWoValueLists = (num: number): ExceptionItemSansLargeValueLists[] => { + const items = getExceptionListItemSchemaXMock(num); + return items.filter( + ({ entries }) => !hasLargeValueList(entries) + ) as ExceptionItemSansLargeValueLists[]; +}; + +describe('build_exceptions_filter', () => { + describe('buildExceptionFilter', () => { + test('it should return undefined if no exception items', () => { + const booleanFilter = buildExceptionFilter({ + lists: [], + excludeExceptions: false, + chunkSize: 1, + }); + expect(booleanFilter).toBeUndefined(); + }); + + test('it should build a filter given an exception list', () => { + const booleanFilter = buildExceptionFilter({ + lists: [getExceptionListItemSchemaMock()], + excludeExceptions: false, + chunkSize: 1, + }); + + expect(booleanFilter).toEqual({ + meta: { alias: null, negate: false, disabled: false }, + query: { + bool: { + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + should: [ + { match_phrase: { 'some.parentField.nested.field': 'some value' } }, + ], + minimum_should_match: 1, + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + should: [{ match_phrase: { 'some.not.nested.field': 'some value' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + + test('it should build a filter without chunking exception items', () => { + const exceptionItem1: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'host.name', operator: 'included', type: 'match', value: 'linux' }, + { field: 'some.field', operator: 'included', type: 'match', value: 'value' }, + ], + }; + const exceptionItem2: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'user.name', operator: 'included', type: 'match', value: 'name' }], + }; + const exceptionFilter = buildExceptionFilter({ + lists: [exceptionItem1, exceptionItem2], + excludeExceptions: true, + chunkSize: 2, + }); + expect(exceptionFilter).toEqual({ + meta: { + alias: null, + negate: true, + disabled: false, + }, + query: { + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'host.name': 'linux', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.field': 'value', + }, + }, + ], + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'user.name': 'name', + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + + test('it should properly chunk exception items', () => { + const exceptionItem1: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'host.name', operator: 'included', type: 'match', value: 'linux' }, + { field: 'some.field', operator: 'included', type: 'match', value: 'value' }, + ], + }; + const exceptionItem2: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'user.name', operator: 'included', type: 'match', value: 'name' }], + }; + const exceptionItem3: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'file.path', operator: 'included', type: 'match', value: '/safe/path' }], + }; + const exceptionFilter = buildExceptionFilter({ + lists: [exceptionItem1, exceptionItem2, exceptionItem3], + excludeExceptions: true, + chunkSize: 2, + }); + + expect(exceptionFilter).toEqual({ + meta: { + alias: null, + negate: true, + disabled: false, + }, + query: { + bool: { + should: [ + { + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'host.name': 'linux', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.field': 'value', + }, + }, + ], + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'user.name': 'name', + }, + }, + ], + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'file.path': '/safe/path', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + + test('it should format all exception items and their entries as expected', () => { + const exceptions = [ + { ...getExceptionListItemSchemaMock(), entries: [getEntryNestedMixedEntries()] }, + { ...getExceptionListItemSchemaMock(), entries: [modifiedGetEntryMatchAnyMock()] }, + { + ...getExceptionListItemSchemaMock(), + entries: [getEntryExistsExcludedMock(), getEntryMatchMock()], + }, + ]; + + const booleanFilter = buildExceptionFilter({ + lists: exceptions, + excludeExceptions: true, + chunkSize: 1, + }); + + expect(booleanFilter).toEqual({ + meta: { alias: null, negate: true, disabled: false }, + query: { + bool: { + should: [ + { + bool: { + should: [ + { + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { 'parent.field.host.name': 'some host name' }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some other host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + should: [{ exists: { field: 'parent.field.host.name' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + bool: { + should: [ + { + bool: { + should: [{ match_phrase: { 'host.name': 'some "host" name' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ match_phrase: { 'host.name': 'some other host name' } }], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + must_not: { + bool: { + should: [{ exists: { field: 'host.name' } }], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + should: [{ match_phrase: { 'host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + }); + + describe('chunkExceptions', () => { + test('it should NOT split a single should clause as there is nothing to split on with chunkSize 1', () => { + const exceptions = getExceptionListItemsWoValueLists(1); + const chunks = chunkExceptions(exceptions, 1); + expect(chunks).toHaveLength(1); + }); + + test('it should NOT split a single should clause as there is nothing to split on with chunkSize 2', () => { + const exceptions = getExceptionListItemsWoValueLists(1) as ExceptionItemSansLargeValueLists[]; + const chunks = chunkExceptions(exceptions, 2); + expect(chunks).toHaveLength(1); + }); + + test('it should return an empty array if no exception items passed in', () => { + const chunks = chunkExceptions([], 2); + expect(chunks).toEqual([]); + }); + + test('it should split an array of size 2 into a length 2 array with chunks on "chunkSize: 1"', () => { + const exceptions = getExceptionListItemsWoValueLists(2); + const chunks = chunkExceptions(exceptions, 1); + expect(chunks).toHaveLength(2); + }); + + test('it should split an array of size 2 into a length 4 array with chunks on "chunkSize: 1"', () => { + const exceptions = getExceptionListItemsWoValueLists(4); + const chunks = chunkExceptions(exceptions, 1); + expect(chunks).toHaveLength(4); + }); + + test('it should split an array of size 4 into a length 2 array with chunks on "chunkSize: 2"', () => { + const exceptions = getExceptionListItemsWoValueLists(4); + const chunks = chunkExceptions(exceptions, 2); + expect(chunks).toHaveLength(2); + }); + + test('it should NOT split an array of size 4 into any groups on "chunkSize: 5"', () => { + const exceptions = getExceptionListItemsWoValueLists(4); + const chunks = chunkExceptions(exceptions, 5); + expect(chunks).toHaveLength(1); + }); + + test('it should split an array of size 4 into 2 groups on "chunkSize: 3"', () => { + const exceptions = getExceptionListItemsWoValueLists(4); + const chunks = chunkExceptions(exceptions, 3); + expect(chunks).toHaveLength(2); + }); + }); + + describe('createOrClauses', () => { + test('it should create filter with one item if only one exception item exists', () => { + const booleanFilter = createOrClauses([ + { + ...getExceptionListItemSchemaMock(), + entries: [getEntryNestedMock(), getEntryMatchMock()], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + getEntryNestedMixedEntries(), + modifiedGetEntryMatchAnyMock(), + getEntryMatchExcludeMock(), + getEntryExistsExcludedMock(), + ], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [getEntryExistsExcludedMock()], + }, + ]); + + expect(booleanFilter).toEqual([ + { + bool: { + filter: [ + { + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + should: [ + { match_phrase: { 'parent.field.host.name': 'some host name' } }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { match_phrase: { 'parent.field.host.name': 'some host name' } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + should: [{ match_phrase: { 'host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + should: [ + { match_phrase: { 'parent.field.host.name': 'some host name' } }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some other host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + should: [{ exists: { field: 'parent.field.host.name' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + should: [ + { + bool: { + should: [{ match_phrase: { 'host.name': 'some "host" name' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ match_phrase: { 'host.name': 'some other host name' } }], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [{ match_phrase: { 'host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { should: [{ exists: { field: 'host.name' } }], minimum_should_match: 1 }, + }, + }, + }, + ], + }, + }, + { + bool: { + must_not: { + bool: { should: [{ exists: { field: 'host.name' } }], minimum_should_match: 1 }, + }, + }, + }, + ]); + }); + }); + + describe('buildExceptionItemFilter', () => { + test('it should build exception item boolean filter from entries', () => { + const exceptionItemFilter = buildExceptionItemFilter({ + ...getExceptionListItemSchemaMock(), + entries: [ + getEntryNestedMixedEntries(), + modifiedGetEntryMatchAnyMock(), + getEntryMatchExcludeMock(), + getEntryExistsExcludedMock(), + ], + }); + + expect(exceptionItemFilter).toEqual({ + bool: { + filter: [ + { + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some other host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + should: [{ exists: { field: 'parent.field.host.name' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + should: [ + { + bool: { + should: [{ match_phrase: { 'host.name': 'some "host" name' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ match_phrase: { 'host.name': 'some other host name' } }], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [{ match_phrase: { 'host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { should: [{ exists: { field: 'host.name' } }], minimum_should_match: 1 }, + }, + }, + }, + ], + }, + }); + }); + }); + + describe('buildExclusionClause', () => { + test('it should build exclusion boolean filter when entry is "match"', () => { + const booleanFilter = buildMatchClause(getEntryMatchMock()); + const exclusionFilter = buildExclusionClause(booleanFilter); + + expect(exclusionFilter).toEqual({ + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some host name' } }], + }, + }, + }, + }); + }); + + test('it should build exclusion boolean filter when entry is "match_any"', () => { + const booleanFilter = buildMatchAnyClause(modifiedGetEntryMatchAnyMock()); + const exclusionFilter = buildExclusionClause(booleanFilter); + + expect(exclusionFilter).toEqual({ + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some "host" name' } }], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some other host name' } }], + }, + }, + ], + }, + }, + }, + }); + }); + + test('it should build exclusion boolean filter when entry is "exists"', () => { + const booleanFilter = buildExistsClause(getEntryExistsMock()); + const exclusionFilter = buildExclusionClause(booleanFilter); + + expect(exclusionFilter).toEqual({ + bool: { + must_not: { + bool: { minimum_should_match: 1, should: [{ exists: { field: 'host.name' } }] }, + }, + }, + }); + }); + }); + + describe('buildMatchClause', () => { + test('it should build boolean filter when operator is "included"', () => { + const booleanFilter = buildMatchClause(getEntryMatchMock()); + + expect(booleanFilter).toEqual({ + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some host name' } }], + }, + }); + }); + + test('it should build boolean filter when operator is "excluded"', () => { + const booleanFilter = buildMatchClause(getEntryMatchExcludeMock()); + + expect(booleanFilter).toEqual({ + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some host name' } }], + }, + }, + }, + }); + }); + }); + + describe('buildMatchAnyClause', () => { + test('it should build boolean filter when operator is "included"', () => { + const booleanFilter = buildMatchAnyClause(modifiedGetEntryMatchAnyMock()); + + expect(booleanFilter).toEqual({ + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some "host" name' } }], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some other host name' } }], + }, + }, + ], + }, + }); + }); + + test('it should build boolean filter when operator is "excluded"', () => { + const booleanFilter = buildMatchAnyClause(getEntryMatchAnyExcludeMock()); + + expect(booleanFilter).toEqual({ + bool: { + must_not: { + bool: { + should: [ + { + bool: { + should: [{ match_phrase: { 'host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ match_phrase: { 'host.name': 'some other host name' } }], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }); + }); + }); + + describe('buildExistsClause', () => { + test('it should build boolean filter when operator is "included"', () => { + const booleanFilter = buildExistsClause(getEntryExistsMock()); + + expect(booleanFilter).toEqual({ + bool: { minimum_should_match: 1, should: [{ exists: { field: 'host.name' } }] }, + }); + }); + + test('it should build boolean filter when operator is "excluded"', () => { + const booleanFilter = buildExistsClause(getEntryExistsExcludedMock()); + + expect(booleanFilter).toEqual({ + bool: { + must_not: { + bool: { minimum_should_match: 1, should: [{ exists: { field: 'host.name' } }] }, + }, + }, + }); + }); + }); + + describe('buildNestedClause', () => { + test('it should build nested filter when operator is "included"', () => { + const nestedFilter = buildNestedClause(getEntryNestedMock()); + + expect(nestedFilter).toEqual({ + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + should: [{ match_phrase: { 'parent.field.host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ match_phrase: { 'parent.field.host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }); + }); + + test('it should build nested filter when operator is "excluded"', () => { + const nestedFilter = buildNestedClause(getEntryNestedExcludeMock()); + + expect(nestedFilter).toEqual({ + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + must_not: { + bool: { + should: [{ match_phrase: { 'parent.field.host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + should: [ + { match_phrase: { 'parent.field.host.name': 'some host name' } }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some other host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }); + }); + + test('it should build nested filter with mixed entry types', () => { + const nestedFilter = buildNestedClause(getEntryNestedMixedEntries()); + + expect(nestedFilter).toEqual({ + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + should: [{ match_phrase: { 'parent.field.host.name': 'some host name' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + should: [ + { match_phrase: { 'parent.field.host.name': 'some host name' } }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some other host name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + should: [{ exists: { field: 'parent.field.host.name' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.ts new file mode 100644 index 0000000000000..4e0086a1b0d61 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_filter.ts @@ -0,0 +1,293 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { chunk } from 'lodash/fp'; + +import { Filter } from '../../../../../src/plugins/data/common'; +import { + ExceptionListItemSchema, + CreateExceptionListItemSchema, + EntryMatch, + EntryMatchAny, + EntryNested, + entriesMatch, + entriesMatchAny, + entriesExists, + entriesNested, + EntryExists, +} from '../../../lists/common'; +import { BooleanFilter, NestedFilter } from './types'; +import { hasLargeValueList } from './utils'; + +type NonListEntry = EntryMatch | EntryMatchAny | EntryNested | EntryExists; +interface ExceptionListItemNonLargeList extends ExceptionListItemSchema { + entries: NonListEntry[]; +} + +interface CreateExceptionListItemNonLargeList extends CreateExceptionListItemSchema { + entries: NonListEntry[]; +} + +export type ExceptionItemSansLargeValueLists = + | ExceptionListItemNonLargeList + | CreateExceptionListItemNonLargeList; + +export const chunkExceptions = ( + exceptions: ExceptionItemSansLargeValueLists[], + chunkSize: number +): ExceptionItemSansLargeValueLists[][] => { + return chunk(chunkSize, exceptions); +}; + +export const buildExceptionItemFilter = ( + exceptionItem: ExceptionItemSansLargeValueLists +): BooleanFilter | NestedFilter => { + const { entries } = exceptionItem; + + if (entries.length === 1) { + return createInnerAndClauses(entries[0]); + } else { + return { + bool: { + filter: entries.map((entry) => createInnerAndClauses(entry)), + }, + }; + } +}; + +export const createOrClauses = ( + exceptionItems: ExceptionItemSansLargeValueLists[] +): Array => { + return exceptionItems.map((exceptionItem) => buildExceptionItemFilter(exceptionItem)); +}; + +export const buildExceptionFilter = ({ + lists, + excludeExceptions, + chunkSize, +}: { + lists: Array; + excludeExceptions: boolean; + chunkSize: number; +}): Filter | undefined => { + // Remove exception items with large value lists. These are evaluated + // elsewhere for the moment being. + const exceptionsWithoutLargeValueLists = lists.filter( + (item): item is ExceptionItemSansLargeValueLists => !hasLargeValueList(item.entries) + ); + + const exceptionFilter: Filter = { + meta: { + alias: null, + negate: excludeExceptions, + disabled: false, + }, + query: { + bool: { + should: undefined, + }, + }, + }; + + if (exceptionsWithoutLargeValueLists.length === 0) { + return undefined; + } else if (exceptionsWithoutLargeValueLists.length <= chunkSize) { + const clause = createOrClauses(exceptionsWithoutLargeValueLists); + exceptionFilter.query.bool.should = clause; + return exceptionFilter; + } else { + const chunks = chunkExceptions(exceptionsWithoutLargeValueLists, chunkSize); + + const filters = chunks.map((exceptionsChunk) => { + const orClauses = createOrClauses(exceptionsChunk); + + return { + meta: { + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: orClauses, + }, + }, + }; + }); + + const clauses = filters.map(({ query }) => query); + + return { + meta: { + alias: null, + negate: excludeExceptions, + disabled: false, + }, + query: { + bool: { + should: clauses, + }, + }, + }; + } +}; + +export const buildExclusionClause = (booleanFilter: BooleanFilter): BooleanFilter => { + return { + bool: { + must_not: booleanFilter, + }, + }; +}; + +export const buildMatchClause = (entry: EntryMatch): BooleanFilter => { + const { field, operator, value } = entry; + const matchClause = { + bool: { + should: [ + { + match_phrase: { + [field]: value, + }, + }, + ], + minimum_should_match: 1, + }, + }; + + if (operator === 'excluded') { + return buildExclusionClause(matchClause); + } else { + return matchClause; + } +}; + +export const getBaseMatchAnyClause = (entry: EntryMatchAny): BooleanFilter => { + const { field, value } = entry; + + if (value.length === 1) { + return { + bool: { + should: [ + { + match_phrase: { + [field]: value[0], + }, + }, + ], + minimum_should_match: 1, + }, + }; + } + + return { + bool: { + should: value.map((val) => { + return { + bool: { + should: [ + { + match_phrase: { + [field]: val, + }, + }, + ], + minimum_should_match: 1, + }, + }; + }), + minimum_should_match: 1, + }, + }; +}; + +export const buildMatchAnyClause = (entry: EntryMatchAny): BooleanFilter => { + const { operator } = entry; + const matchAnyClause = getBaseMatchAnyClause(entry); + + if (operator === 'excluded') { + return buildExclusionClause(matchAnyClause); + } else { + return matchAnyClause; + } +}; + +export const buildExistsClause = (entry: EntryExists): BooleanFilter => { + const { field, operator } = entry; + const existsClause = { + bool: { + should: [ + { + exists: { + field, + }, + }, + ], + minimum_should_match: 1, + }, + }; + + if (operator === 'excluded') { + return buildExclusionClause(existsClause); + } else { + return existsClause; + } +}; + +const isBooleanFilter = (clause: object): clause is BooleanFilter => { + const keys = Object.keys(clause); + return keys.includes('bool') != null; +}; + +export const getBaseNestedClause = ( + entries: NonListEntry[], + parentField: string +): BooleanFilter => { + if (entries.length === 1) { + const [singleNestedEntry] = entries; + const innerClause = createInnerAndClauses(singleNestedEntry, parentField); + return isBooleanFilter(innerClause) ? innerClause : { bool: {} }; + } + + return { + bool: { + filter: entries.map((nestedEntry) => createInnerAndClauses(nestedEntry, parentField)), + }, + }; +}; + +export const buildNestedClause = (entry: EntryNested): NestedFilter => { + const { field, entries } = entry; + + const baseNestedClause = getBaseNestedClause(entries, field); + + return { + nested: { + path: field, + query: baseNestedClause, + score_mode: 'none', + }, + }; +}; + +export const createInnerAndClauses = ( + entry: NonListEntry, + parent?: string +): BooleanFilter | NestedFilter => { + if (entriesExists.is(entry)) { + const field = parent != null ? `${parent}.${entry.field}` : entry.field; + return buildExistsClause({ ...entry, field }); + } else if (entriesMatch.is(entry)) { + const field = parent != null ? `${parent}.${entry.field}` : entry.field; + return buildMatchClause({ ...entry, field }); + } else if (entriesMatchAny.is(entry)) { + const field = parent != null ? `${parent}.${entry.field}` : entry.field; + return buildMatchAnyClause({ ...entry, field }); + } else if (entriesNested.is(entry)) { + return buildNestedClause(entry); + } else { + throw new TypeError(`Unexpected exception entry: ${entry}`); + } +}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts deleted file mode 100644 index 2d37d4a345fa1..0000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts +++ /dev/null @@ -1,788 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - buildExceptionListQueries, - buildExceptionItem, - operatorBuilder, - buildExists, - buildMatch, - buildMatchAny, - buildEntry, - getLanguageBooleanOperator, - buildNested, -} from './build_exceptions_query'; -import { EntryNested, EntryMatchAny, EntriesArray } from '../../../lists/common/schemas'; -import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getEntryMatchMock } from '../../../lists/common/schemas/types/entry_match.mock'; -import { getEntryMatchAnyMock } from '../../../lists/common/schemas/types/entry_match_any.mock'; -import { getEntryExistsMock } from '../../../lists/common/schemas/types/entry_exists.mock'; - -describe('build_exceptions_query', () => { - describe('getLanguageBooleanOperator', () => { - test('it returns value as uppercase if language is "lucene"', () => { - const result = getLanguageBooleanOperator({ language: 'lucene', value: 'not' }); - - expect(result).toEqual('NOT'); - }); - - test('it returns value as is if language is "kuery"', () => { - const result = getLanguageBooleanOperator({ language: 'kuery', value: 'not' }); - - expect(result).toEqual('not'); - }); - }); - - describe('operatorBuilder', () => { - describe('and language is kuery', () => { - test('it returns empty string when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'kuery' }); - expect(operator).toEqual(''); - }); - test('it returns "not " when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' }); - expect(operator).toEqual('not '); - }); - }); - - describe('and language is lucene', () => { - test('it returns empty string when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'lucene' }); - expect(operator).toEqual(''); - }); - test('it returns "NOT " when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' }); - expect(operator).toEqual('NOT '); - }); - }); - }); - - describe('buildExists', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - entry: { ...getEntryExistsMock(), operator: 'excluded' }, - language: 'kuery', - }); - expect(query).toEqual('not host.name:*'); - }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - entry: { ...getEntryExistsMock(), operator: 'included' }, - language: 'kuery', - }); - expect(query).toEqual('host.name:*'); - }); - }); - - describe('lucene', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - entry: { ...getEntryExistsMock(), operator: 'excluded' }, - language: 'lucene', - }); - expect(query).toEqual('NOT _exists_host.name'); - }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - entry: { ...getEntryExistsMock(), operator: 'included' }, - language: 'lucene', - }); - expect(query).toEqual('_exists_host.name'); - }); - }); - }); - - describe('buildMatch', () => { - describe('kuery', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - entry: { ...getEntryMatchMock(), operator: 'included' }, - language: 'kuery', - }); - expect(query).toEqual('host.name:"some host name"'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - entry: { ...getEntryMatchMock(), operator: 'excluded' }, - language: 'kuery', - }); - expect(query).toEqual('not host.name:"some host name"'); - }); - }); - - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - entry: { ...getEntryMatchMock(), operator: 'included' }, - language: 'lucene', - }); - expect(query).toEqual('host.name:"some host name"'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - entry: { ...getEntryMatchMock(), operator: 'excluded' }, - language: 'lucene', - }); - expect(query).toEqual('NOT host.name:"some host name"'); - }); - }); - }); - - describe('buildMatchAny', () => { - const entryWithIncludedAndNoValues: EntryMatchAny = { - ...getEntryMatchAnyMock(), - field: 'host.name', - value: [], - }; - const entryWithIncludedAndOneValue: EntryMatchAny = { - ...getEntryMatchAnyMock(), - field: 'host.name', - value: ['some host name'], - }; - const entryWithExcludedAndTwoValues: EntryMatchAny = { - ...getEntryMatchAnyMock(), - field: 'host.name', - value: ['some host name', 'auditd'], - operator: 'excluded', - }; - - describe('kuery', () => { - test('it returns empty string if given an empty array for "values"', () => { - const exceptionSegment = buildMatchAny({ - entry: entryWithIncludedAndNoValues, - language: 'kuery', - }); - expect(exceptionSegment).toEqual(''); - }); - - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - entry: entryWithIncludedAndOneValue, - language: 'kuery', - }); - - expect(exceptionSegment).toEqual('host.name:("some host name")'); - }); - - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - entry: { ...getEntryMatchAnyMock(), value: ['some host name', 'auditd'] }, - language: 'kuery', - }); - - expect(exceptionSegment).toEqual('host.name:("some host name" or "auditd")'); - }); - - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - entry: entryWithExcludedAndTwoValues, - language: 'kuery', - }); - - expect(exceptionSegment).toEqual('not host.name:("some host name" or "auditd")'); - }); - }); - - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - entry: { ...getEntryMatchAnyMock(), value: ['some host name', 'auditd'] }, - language: 'lucene', - }); - - expect(exceptionSegment).toEqual('host.name:("some host name" OR "auditd")'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - entry: entryWithExcludedAndTwoValues, - language: 'lucene', - }); - - expect(exceptionSegment).toEqual('NOT host.name:("some host name" OR "auditd")'); - }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - entry: entryWithIncludedAndOneValue, - language: 'lucene', - }); - - expect(exceptionSegment).toEqual('host.name:("some host name")'); - }); - }); - }); - - describe('buildNested', () => { - // NOTE: Only KQL supports nested - describe('kuery', () => { - test('it returns formatted query when one item in nested entry', () => { - const entry: EntryNested = { - field: 'parent', - type: 'nested', - entries: [ - { - ...getEntryMatchMock(), - field: 'nestedField', - operator: 'included', - value: 'value-1', - }, - ], - }; - const result = buildNested({ entry, language: 'kuery' }); - - expect(result).toEqual('parent:{ nestedField:"value-1" }'); - }); - - test('it returns formatted query when entry item is "exists"', () => { - const entry: EntryNested = { - field: 'parent', - type: 'nested', - entries: [{ ...getEntryExistsMock(), field: 'nestedField', operator: 'included' }], - }; - const result = buildNested({ entry, language: 'kuery' }); - - expect(result).toEqual('parent:{ nestedField:* }'); - }); - - test('it returns formatted query when entry item is "exists" and operator is "excluded"', () => { - const entry: EntryNested = { - field: 'parent', - type: 'nested', - entries: [{ ...getEntryExistsMock(), field: 'nestedField', operator: 'excluded' }], - }; - const result = buildNested({ entry, language: 'kuery' }); - - expect(result).toEqual('parent:{ not nestedField:* }'); - }); - - test('it returns formatted query when entry item is "match_any"', () => { - const entry: EntryNested = { - field: 'parent', - type: 'nested', - entries: [ - { - ...getEntryMatchAnyMock(), - field: 'nestedField', - operator: 'included', - value: ['value1', 'value2'], - }, - ], - }; - const result = buildNested({ entry, language: 'kuery' }); - - expect(result).toEqual('parent:{ nestedField:("value1" or "value2") }'); - }); - - test('it returns formatted query when entry item is "match_any" and operator is "excluded"', () => { - const entry: EntryNested = { - field: 'parent', - type: 'nested', - entries: [ - { - ...getEntryMatchAnyMock(), - field: 'nestedField', - operator: 'excluded', - value: ['value1', 'value2'], - }, - ], - }; - const result = buildNested({ entry, language: 'kuery' }); - - expect(result).toEqual('parent:{ not nestedField:("value1" or "value2") }'); - }); - - test('it returns formatted query when multiple items in nested entry', () => { - const entry: EntryNested = { - field: 'parent', - type: 'nested', - entries: [ - { - ...getEntryMatchMock(), - field: 'nestedField', - operator: 'included', - value: 'value-1', - }, - { - ...getEntryMatchMock(), - field: 'nestedFieldB', - operator: 'included', - value: 'value-2', - }, - ], - }; - const result = buildNested({ entry, language: 'kuery' }); - - expect(result).toEqual('parent:{ nestedField:"value-1" and nestedFieldB:"value-2" }'); - }); - }); - }); - - describe('buildEntry', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const result = buildEntry({ - entry: { ...getEntryExistsMock(), operator: 'included' }, - language: 'kuery', - }); - expect(result).toEqual('host.name:*'); - }); - - test('it returns formatted string when "type" is "match"', () => { - const result = buildEntry({ - entry: { ...getEntryMatchMock(), operator: 'included' }, - language: 'kuery', - }); - expect(result).toEqual('host.name:"some host name"'); - }); - - test('it returns formatted string when "type" is "match_any"', () => { - const result = buildEntry({ - entry: { ...getEntryMatchAnyMock(), value: ['some host name', 'auditd'] }, - language: 'kuery', - }); - expect(result).toEqual('host.name:("some host name" or "auditd")'); - }); - }); - - describe('lucene', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const result = buildEntry({ - entry: { ...getEntryExistsMock(), operator: 'included' }, - language: 'lucene', - }); - expect(result).toEqual('_exists_host.name'); - }); - - test('it returns formatted string when "type" is "match"', () => { - const result = buildEntry({ - entry: { ...getEntryMatchMock(), operator: 'included' }, - language: 'lucene', - }); - expect(result).toEqual('host.name:"some host name"'); - }); - - test('it returns formatted string when "type" is "match_any"', () => { - const result = buildEntry({ - entry: { ...getEntryMatchAnyMock(), value: ['some host name', 'auditd'] }, - language: 'lucene', - }); - expect(result).toEqual('host.name:("some host name" OR "auditd")'); - }); - }); - }); - - describe('buildExceptionItem', () => { - test('it returns empty string if empty lists array passed in', () => { - const query = buildExceptionItem({ - language: 'kuery', - entries: [], - }); - - expect(query).toEqual(''); - }); - - test('it returns expected query when more than one item in exception item', () => { - const payload: EntriesArray = [ - { ...getEntryMatchAnyMock(), field: 'b' }, - { ...getEntryMatchMock(), field: 'c', operator: 'excluded', value: 'value-3' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries: payload, - }); - const expectedQuery = 'b:("some host name") and not c:"value-3"'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when exception item includes nested value', () => { - const entries: EntriesArray = [ - { ...getEntryMatchAnyMock(), field: 'b' }, - { - field: 'parent', - type: 'nested', - entries: [ - { - ...getEntryMatchMock(), - field: 'nestedField', - operator: 'included', - value: 'value-3', - }, - ], - }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'b:("some host name") and parent:{ nestedField:"value-3" }'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when exception item includes multiple items and nested "and" values', () => { - const entries: EntriesArray = [ - { ...getEntryMatchAnyMock(), field: 'b' }, - { - field: 'parent', - type: 'nested', - entries: [ - { - ...getEntryMatchMock(), - field: 'nestedField', - operator: 'included', - value: 'value-3', - }, - ], - }, - { ...getEntryExistsMock(), field: 'd' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'b:("some host name") and parent:{ nestedField:"value-3" } and d:*'; - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when language is "lucene"', () => { - const entries: EntriesArray = [ - { ...getEntryMatchAnyMock(), field: 'b' }, - { - field: 'parent', - type: 'nested', - entries: [ - { - ...getEntryMatchMock(), - field: 'nestedField', - operator: 'excluded', - value: 'value-3', - }, - ], - }, - { ...getEntryExistsMock(), field: 'e', operator: 'excluded' }, - ]; - const query = buildExceptionItem({ - language: 'lucene', - entries, - }); - const expectedQuery = - 'b:("some host name") AND parent:{ NOT nestedField:"value-3" } AND NOT _exists_e'; - expect(query).toEqual(expectedQuery); - }); - - describe('exists', () => { - test('it returns expected query when list includes single list item with operator of "included"', () => { - const entries: EntriesArray = [{ ...getEntryExistsMock(), field: 'b' }]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'b:*'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes single list item with operator of "excluded"', () => { - const entries: EntriesArray = [ - { ...getEntryExistsMock(), field: 'b', operator: 'excluded' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'not b:*'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when exception item includes entry item with "and" values', () => { - const entries: EntriesArray = [ - { ...getEntryExistsMock(), field: 'b', operator: 'excluded' }, - { - field: 'parent', - type: 'nested', - entries: [ - { ...getEntryMatchMock(), field: 'c', operator: 'included', value: 'value-1' }, - ], - }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'not b:* and parent:{ c:"value-1" }'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes multiple items', () => { - const entries: EntriesArray = [ - { ...getEntryExistsMock(), field: 'b' }, - { - field: 'parent', - type: 'nested', - entries: [ - { ...getEntryMatchMock(), field: 'c', operator: 'excluded', value: 'value-1' }, - { ...getEntryMatchMock(), field: 'd', value: 'value-2' }, - ], - }, - { ...getEntryExistsMock(), field: 'e' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'b:* and parent:{ not c:"value-1" and d:"value-2" } and e:*'; - - expect(query).toEqual(expectedQuery); - }); - }); - - describe('match', () => { - test('it returns expected query when list includes single list item with operator of "included"', () => { - const entries: EntriesArray = [{ ...getEntryMatchMock(), field: 'b', value: 'value' }]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'b:"value"'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes single list item with operator of "excluded"', () => { - const entries: EntriesArray = [ - { ...getEntryMatchMock(), field: 'b', operator: 'excluded', value: 'value' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'not b:"value"'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes list item with "and" values', () => { - const entries: EntriesArray = [ - { ...getEntryMatchMock(), field: 'b', operator: 'excluded', value: 'value' }, - { - field: 'parent', - type: 'nested', - entries: [ - { ...getEntryMatchMock(), field: 'c', operator: 'included', value: 'valueC' }, - ], - }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'not b:"value" and parent:{ c:"valueC" }'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes multiple items', () => { - const entries: EntriesArray = [ - { ...getEntryMatchMock(), field: 'b', value: 'value' }, - { - field: 'parent', - type: 'nested', - entries: [ - { ...getEntryMatchMock(), field: 'c', operator: 'excluded', value: 'valueC' }, - { ...getEntryMatchMock(), field: 'd', operator: 'excluded', value: 'valueD' }, - ], - }, - { ...getEntryMatchMock(), field: 'e', value: 'valueE' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = - 'b:"value" and parent:{ not c:"valueC" and not d:"valueD" } and e:"valueE"'; - - expect(query).toEqual(expectedQuery); - }); - }); - - describe('match_any', () => { - test('it returns expected query when list includes single list item with operator of "included"', () => { - const entries: EntriesArray = [{ ...getEntryMatchAnyMock(), field: 'b' }]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'b:("some host name")'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes single list item with operator of "excluded"', () => { - const entries: EntriesArray = [ - { ...getEntryMatchAnyMock(), field: 'b', operator: 'excluded' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'not b:("some host name")'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes list item with nested values', () => { - const entries: EntriesArray = [ - { ...getEntryMatchAnyMock(), field: 'b', operator: 'excluded' }, - { - field: 'parent', - type: 'nested', - entries: [ - { ...getEntryMatchMock(), field: 'c', operator: 'excluded', value: 'valueC' }, - ], - }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'not b:("some host name") and parent:{ not c:"valueC" }'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes multiple items', () => { - const entries: EntriesArray = [ - { ...getEntryMatchAnyMock(), field: 'b' }, - { ...getEntryMatchAnyMock(), field: 'c' }, - ]; - const query = buildExceptionItem({ - language: 'kuery', - entries, - }); - const expectedQuery = 'b:("some host name") and c:("some host name")'; - - expect(query).toEqual(expectedQuery); - }); - }); - }); - - describe('buildExceptionListQueries', () => { - test('it returns empty array if lists is empty array', () => { - const query = buildExceptionListQueries({ language: 'kuery', lists: [] }); - - expect(query).toEqual([]); - }); - - test('it returns empty array if lists is undefined', () => { - const query = buildExceptionListQueries({ language: 'kuery', lists: undefined }); - - expect(query).toEqual([]); - }); - - test('it returns expected query when lists exist and language is "kuery"', () => { - const payload = getExceptionListItemSchemaMock(); - const payload2 = getExceptionListItemSchemaMock(); - payload2.entries = [ - { ...getEntryMatchAnyMock(), field: 'b' }, - { - field: 'parent', - type: 'nested', - entries: [ - { ...getEntryMatchMock(), field: 'c', operator: 'included', value: 'valueC' }, - { ...getEntryMatchMock(), field: 'd', operator: 'included', value: 'valueD' }, - ], - }, - { ...getEntryMatchAnyMock(), field: 'e', operator: 'excluded' }, - ]; - const queries = buildExceptionListQueries({ - language: 'kuery', - lists: [payload, payload2], - }); - const expectedQueries = [ - { - query: - 'some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value"', - language: 'kuery', - }, - { - query: - 'b:("some host name") and parent:{ c:"valueC" and d:"valueD" } and not e:("some host name")', - language: 'kuery', - }, - ]; - - expect(queries).toEqual(expectedQueries); - }); - - test('it returns expected query when lists exist and language is "lucene"', () => { - const payload = getExceptionListItemSchemaMock(); - payload.entries = [ - { ...getEntryMatchAnyMock(), field: 'a' }, - { ...getEntryMatchAnyMock(), field: 'b' }, - ]; - const payload2 = getExceptionListItemSchemaMock(); - payload2.entries = [ - { ...getEntryMatchAnyMock(), field: 'c' }, - { ...getEntryMatchAnyMock(), field: 'd' }, - ]; - const queries = buildExceptionListQueries({ - language: 'lucene', - lists: [payload, payload2], - }); - const expectedQueries = [ - { - query: 'a:("some host name") AND b:("some host name")', - language: 'lucene', - }, - { - query: 'c:("some host name") AND d:("some host name")', - language: 'lucene', - }, - ]; - - expect(queries).toEqual(expectedQueries); - }); - - test('it builds correct queries for nested excluded fields', () => { - const payload = getExceptionListItemSchemaMock(); - const payload2 = getExceptionListItemSchemaMock(); - payload2.entries = [ - { ...getEntryMatchAnyMock(), field: 'b' }, - { - field: 'parent', - type: 'nested', - entries: [ - // TODO: these operators are not being respected. buildNested needs to be updated - { ...getEntryMatchMock(), field: 'c', operator: 'excluded', value: 'valueC' }, - { ...getEntryMatchMock(), field: 'd', operator: 'excluded', value: 'valueD' }, - ], - }, - { ...getEntryMatchAnyMock(), field: 'e' }, - ]; - const queries = buildExceptionListQueries({ - language: 'kuery', - lists: [payload, payload2], - }); - const expectedQueries = [ - { - query: - 'some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value"', - language: 'kuery', - }, - { - query: - 'b:("some host name") and parent:{ not c:"valueC" and not d:"valueD" } and e:("some host name")', - language: 'kuery', - }, - ]; - - expect(queries).toEqual(expectedQueries); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts deleted file mode 100644 index c64d0b124b67a..0000000000000 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Query as DataQuery } from '../../../../../src/plugins/data/common'; -import { - Entry, - EntryMatch, - EntryMatchAny, - EntryNested, - EntryExists, - EntriesArray, - Operator, - entriesMatchAny, - entriesExists, - entriesMatch, - entriesNested, - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '../shared_imports'; -import { Language } from './schemas/common/schemas'; -import { hasLargeValueList } from './utils'; - -type Operators = 'and' | 'or' | 'not'; -type LuceneOperators = 'AND' | 'OR' | 'NOT'; - -export const getLanguageBooleanOperator = ({ - language, - value, -}: { - language: Language; - value: Operators; -}): Operators | LuceneOperators => { - switch (language) { - case 'lucene': - const luceneValues: Record = { and: 'AND', or: 'OR', not: 'NOT' }; - - return luceneValues[value]; - case 'kuery': - return value; - default: - return value; - } -}; - -export const operatorBuilder = ({ - operator, - language, -}: { - operator: Operator; - language: Language; -}): string => { - const not = getLanguageBooleanOperator({ - language, - value: 'not', - }); - - if (operator === 'excluded') { - return `${not} `; - } else { - return ''; - } -}; - -export const buildExists = ({ - entry, - language, -}: { - entry: EntryExists; - language: Language; -}): string => { - const { operator, field } = entry; - const exceptionOperator = operatorBuilder({ operator, language }); - - switch (language) { - case 'kuery': - return `${exceptionOperator}${field}:*`; - case 'lucene': - return `${exceptionOperator}_exists_${field}`; - default: - return ''; - } -}; - -export const buildMatch = ({ - entry, - language, -}: { - entry: EntryMatch; - language: Language; -}): string => { - const { value, operator, field } = entry; - const exceptionOperator = operatorBuilder({ operator, language }); - - return `${exceptionOperator}${field}:"${value}"`; -}; - -export const buildMatchAny = ({ - entry, - language, -}: { - entry: EntryMatchAny; - language: Language; -}): string => { - const { value, operator, field } = entry; - - switch (value.length) { - case 0: - return ''; - default: - const or = getLanguageBooleanOperator({ language, value: 'or' }); - const exceptionOperator = operatorBuilder({ operator, language }); - const matchAnyValues = value.map((v) => `"${v}"`); - - return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`; - } -}; - -export const buildNested = ({ - entry, - language, -}: { - entry: EntryNested; - language: Language; -}): string => { - const { field, entries: subentries } = entry; - const and = getLanguageBooleanOperator({ language, value: 'and' }); - const values = subentries.map((subentry) => buildEntry({ entry: subentry, language })); - - return `${field}:{ ${values.join(` ${and} `)} }`; -}; - -export const buildEntry = ({ - entry, - language, -}: { - entry: Entry | EntryNested; - language: Language; -}): string => { - if (entriesExists.is(entry)) { - return buildExists({ entry, language }); - } else if (entriesMatch.is(entry)) { - return buildMatch({ entry, language }); - } else if (entriesMatchAny.is(entry)) { - return buildMatchAny({ entry, language }); - } else if (entriesNested.is(entry)) { - return buildNested({ entry, language }); - } else { - return ''; - } -}; - -export const buildExceptionItem = ({ - entries, - language, -}: { - entries: EntriesArray; - language: Language; -}): string => { - const and = getLanguageBooleanOperator({ language, value: 'and' }); - const exceptionItemEntries = entries.map((entry) => { - return buildEntry({ entry, language }); - }); - - return exceptionItemEntries.join(` ${and} `); -}; - -export const buildExceptionListQueries = ({ - language, - lists, -}: { - language: Language; - lists: Array | undefined; -}): DataQuery[] => { - if (lists == null || (lists != null && lists.length === 0)) { - return []; - } - - const exceptionItems = lists.reduce((acc, exceptionItem) => { - const { entries } = exceptionItem; - - if (entries != null && entries.length > 0 && !hasLargeValueList(entries)) { - return [...acc, buildExceptionItem({ entries, language })]; - } else { - return acc; - } - }, []); - - if (exceptionItems.length === 0) { - return []; - } else { - return exceptionItems.map((exceptionItem) => { - return { - query: exceptionItem, - language, - }; - }); - } -}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index 4fff99b09d4ad..812054bba93ce 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -4,361 +4,565 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getQueryFilter, buildExceptionFilter, buildEqlSearchRequest } from './get_query_filter'; -import { Filter, EsQueryConfig } from 'src/plugins/data/public'; +import { getQueryFilter, getAllFilters, buildEqlSearchRequest } from './get_query_filter'; +import { Filter } from 'src/plugins/data/public'; import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { ExceptionListItemSchema } from '../shared_imports'; describe('get_filter', () => { describe('getQueryFilter', () => { - test('it should work with an empty filter as kuery', () => { - const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], []); - expect(esQuery).toEqual({ - bool: { - must: [], - filter: [ - { - bool: { - should: [ - { - match: { - 'host.name': 'linux', + describe('kuery', () => { + test('it should work with an empty filter as kuery', () => { + const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], []); + expect(esQuery).toEqual({ + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match: { + 'host.name': 'linux', + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, - }, - ], - should: [], - must_not: [], - }, + ], + should: [], + must_not: [], + }, + }); }); - }); - test('it should work with an empty filter as lucene', () => { - const esQuery = getQueryFilter('host.name: linux', 'lucene', [], ['auditbeat-*'], []); - expect(esQuery).toEqual({ - bool: { - must: [ + test('it should work with a simple filter as a kuery', () => { + const esQuery = getQueryFilter( + 'host.name: windows', + 'kuery', + [ { - query_string: { - query: 'host.name: linux', - analyze_wildcard: true, - time_zone: 'Zulu', + meta: { + alias: 'custom label here', + disabled: false, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, ], - filter: [], - should: [], - must_not: [], - }, - }); - }); - - test('it should work with a simple filter as a kuery', () => { - const esQuery = getQueryFilter( - 'host.name: windows', - 'kuery', - [ - { - meta: { - alias: 'custom label here', - disabled: false, - key: 'host.name', - negate: false, - params: { - query: 'siem-windows', + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match: { + 'host.name': 'windows', + }, + }, + ], + minimum_should_match: 1, + }, }, - type: 'phrase', - }, - query: { - match_phrase: { - 'host.name': 'siem-windows', + { + match_phrase: { + 'host.name': 'siem-windows', + }, }, - }, + ], + should: [], + must_not: [], }, - ], - ['auditbeat-*'], - [] - ); - expect(esQuery).toEqual({ - bool: { - must: [], - filter: [ + }); + }); + + test('it should ignore disabled filters as a kuery', () => { + const esQuery = getQueryFilter( + 'host.name: windows', + 'kuery', + [ { - bool: { - should: [ - { - match: { - 'host.name': 'windows', - }, - }, - ], - minimum_should_match: 1, + meta: { + alias: 'custom label here', + disabled: false, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, { - match_phrase: { - 'host.name': 'siem-windows', + meta: { + alias: 'custom label here', + disabled: true, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, ], - should: [], - must_not: [], - }, + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match: { + 'host.name': 'windows', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + match_phrase: { + 'host.name': 'siem-windows', + }, + }, + ], + should: [], + must_not: [], + }, + }); }); - }); - test('it should work with a simple filter as a kuery without meta information', () => { - const esQuery = getQueryFilter( - 'host.name: windows', - 'kuery', - [ - { - query: { - match_phrase: { - 'host.name': 'siem-windows', + test('it should work with a simple filter as a kuery without meta information', () => { + const esQuery = getQueryFilter( + 'host.name: windows', + 'kuery', + [ + { + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, - }, - ], - ['auditbeat-*'], - [] - ); - expect(esQuery).toEqual({ - bool: { - must: [], - filter: [ - { - bool: { - should: [ - { - match: { - 'host.name': 'windows', + ], + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match: { + 'host.name': 'windows', + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, - }, - { - match_phrase: { - 'host.name': 'siem-windows', + { + match_phrase: { + 'host.name': 'siem-windows', + }, }, - }, - ], - should: [], - must_not: [], - }, + ], + should: [], + must_not: [], + }, + }); }); - }); - test('it should work with a simple filter as a kuery without meta information with an exists', () => { - const query: Partial = { - query: { - match_phrase: { - 'host.name': 'siem-windows', + test('it should work with a simple filter as a kuery without meta information with an exists', () => { + const query: Partial = { + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, - }, - }; + }; - const exists: Partial = { - exists: { - field: 'host.hostname', - }, - } as Partial; + const exists: Partial = { + exists: { + field: 'host.hostname', + }, + } as Partial; - const esQuery = getQueryFilter( - 'host.name: windows', - 'kuery', - [query, exists], - ['auditbeat-*'], - [] - ); - expect(esQuery).toEqual({ - bool: { - must: [], - filter: [ - { - bool: { - should: [ - { - match: { - 'host.name': 'windows', + const esQuery = getQueryFilter( + 'host.name: windows', + 'kuery', + [query, exists], + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match: { + 'host.name': 'windows', + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, - }, - { - match_phrase: { - 'host.name': 'siem-windows', + { + match_phrase: { + 'host.name': 'siem-windows', + }, }, - }, + { + exists: { + field: 'host.hostname', + }, + }, + ], + should: [], + must_not: [], + }, + }); + }); + + test('it should work with a simple filter that is disabled as a kuery', () => { + const esQuery = getQueryFilter( + 'host.name: windows', + 'kuery', + [ { - exists: { - field: 'host.hostname', + meta: { + alias: 'custom label here', + disabled: true, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, ], - should: [], - must_not: [], - }, + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match: { + 'host.name': 'windows', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + should: [], + must_not: [], + }, + }); }); }); - test('it should work with a simple filter that is disabled as a kuery', () => { - const esQuery = getQueryFilter( - 'host.name: windows', - 'kuery', - [ - { - meta: { - alias: 'custom label here', - disabled: true, - key: 'host.name', - negate: false, - params: { - query: 'siem-windows', - }, - type: 'phrase', - }, - query: { - match_phrase: { - 'host.name': 'siem-windows', + describe('lucene', () => { + test('it should work with an empty filter as lucene', () => { + const esQuery = getQueryFilter('host.name: linux', 'lucene', [], ['auditbeat-*'], []); + expect(esQuery).toEqual({ + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, }, - }, + ], + filter: [], + should: [], + must_not: [], }, - ], - ['auditbeat-*'], - [] - ); - expect(esQuery).toEqual({ - bool: { - must: [], - filter: [ + }); + }); + + test('it should work with a simple filter as a lucene', () => { + const esQuery = getQueryFilter( + 'host.name: windows', + 'lucene', + [ { - bool: { - should: [ - { - match: { - 'host.name': 'windows', - }, - }, - ], - minimum_should_match: 1, + meta: { + alias: 'custom label here', + disabled: false, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, ], - should: [], - must_not: [], - }, + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [ + { + query_string: { + query: 'host.name: windows', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [ + { + match_phrase: { + 'host.name': 'siem-windows', + }, + }, + ], + should: [], + must_not: [], + }, + }); }); - }); - test('it should work with a simple filter as a lucene', () => { - const esQuery = getQueryFilter( - 'host.name: windows', - 'lucene', - [ - { - meta: { - alias: 'custom label here', - disabled: false, - key: 'host.name', - negate: false, - params: { - query: 'siem-windows', + test('it should ignore disabled lucene filters', () => { + const esQuery = getQueryFilter( + 'host.name: windows', + 'lucene', + [ + { + meta: { + alias: 'custom label here', + disabled: false, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', }, - type: 'phrase', - }, - query: { - match_phrase: { - 'host.name': 'siem-windows', + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, - }, - ], - ['auditbeat-*'], - [] - ); - expect(esQuery).toEqual({ - bool: { - must: [ { - query_string: { - query: 'host.name: windows', - analyze_wildcard: true, - time_zone: 'Zulu', + meta: { + alias: 'custom label here', + disabled: true, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, ], - filter: [ + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [ + { + query_string: { + query: 'host.name: windows', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [ + { + match_phrase: { + 'host.name': 'siem-windows', + }, + }, + ], + should: [], + must_not: [], + }, + }); + }); + + test('it should work with a simple filter that is disabled as a lucene', () => { + const esQuery = getQueryFilter( + 'host.name: windows', + 'lucene', + [ { - match_phrase: { - 'host.name': 'siem-windows', + meta: { + alias: 'custom label here', + disabled: true, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, }, }, ], - should: [], - must_not: [], - }, + ['auditbeat-*'], + [] + ); + expect(esQuery).toEqual({ + bool: { + must: [ + { + query_string: { + query: 'host.name: windows', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }); }); - }); - test('it should work with a simple filter that is disabled as a lucene', () => { - const esQuery = getQueryFilter( - 'host.name: windows', - 'lucene', - [ - { - meta: { - alias: 'custom label here', - disabled: true, - key: 'host.name', - negate: false, - params: { - query: 'siem-windows', - }, - type: 'phrase', - }, - query: { - match_phrase: { - 'host.name': 'siem-windows', + test('it should work with a list', () => { + const esQuery = getQueryFilter( + 'host.name: linux', + 'kuery', + [], + ['auditbeat-*'], + [getExceptionListItemSchemaMock()] + ); + expect(esQuery).toEqual({ + bool: { + filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, + ], + must: [], + must_not: [ + { + bool: { + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, }, - }, + ], + should: [], }, - ], - ['auditbeat-*'], - [] - ); - expect(esQuery).toEqual({ - bool: { - must: [ - { - query_string: { - query: 'host.name: windows', - analyze_wildcard: true, - time_zone: 'Zulu', - }, - }, - ], - filter: [], - should: [], - must_not: [], - }, + }); }); }); - test('it should work with a list', () => { + test('it should work with a list with multiple items', () => { const esQuery = getQueryFilter( 'host.name: linux', 'kuery', [], ['auditbeat-*'], - [getExceptionListItemSchemaMock()] + [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()] ); expect(esQuery).toEqual({ bool: { @@ -406,33 +610,6 @@ describe('get_filter', () => { ], }, }, - ], - }, - }, - ], - should: [], - }, - }); - }); - - test('it should work with a list with multiple items', () => { - const esQuery = getQueryFilter( - 'host.name: linux', - 'kuery', - [], - ['auditbeat-*'], - [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()] - ); - expect(esQuery).toEqual({ - bool: { - filter: [ - { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, - ], - must: [], - must_not: [ - { - bool: { - should: [ { bool: { filter: [ @@ -469,6 +646,35 @@ describe('get_filter', () => { ], }, }, + ], + }, + }, + ], + should: [], + }, + }); + }); + + test('it should work with an exception list that includes a nested typ', () => { + const esQuery = getQueryFilter( + 'host.name: linux', + 'kuery', + [], + ['auditbeat-*'], + [getExceptionListItemSchemaMock()] + ); + + expect(esQuery).toEqual({ + bool: { + must: [], + filter: [ + { bool: { should: [{ match: { 'host.name': 'linux' } }], minimum_should_match: 1 } }, + ], + should: [], + must_not: [ + { + bool: { + should: [ { bool: { filter: [ @@ -477,14 +683,12 @@ describe('get_filter', () => { path: 'some.parentField', query: { bool: { - minimum_should_match: 1, should: [ { - match_phrase: { - 'some.parentField.nested.field': 'some value', - }, + match_phrase: { 'some.parentField.nested.field': 'some value' }, }, ], + minimum_should_match: 1, }, }, score_mode: 'none', @@ -492,14 +696,8 @@ describe('get_filter', () => { }, { bool: { + should: [{ match_phrase: { 'some.not.nested.field': 'some value' } }], minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.not.nested.field': 'some value', - }, - }, - ], }, }, ], @@ -509,7 +707,6 @@ describe('get_filter', () => { }, }, ], - should: [], }, }); }); @@ -912,200 +1109,6 @@ describe('get_filter', () => { }); }); - describe('buildExceptionFilter', () => { - const config: EsQueryConfig = { - allowLeadingWildcards: true, - queryStringOptions: { analyze_wildcard: true }, - ignoreFilterIfFieldNotInIndex: false, - dateFormatTZ: 'Zulu', - }; - test('it should build a filter without chunking exception items', () => { - const exceptionItem1: ExceptionListItemSchema = { - ...getExceptionListItemSchemaMock(), - entries: [ - { field: 'host.name', operator: 'included', type: 'match', value: 'linux' }, - { field: 'some.field', operator: 'included', type: 'match', value: 'value' }, - ], - }; - const exceptionItem2: ExceptionListItemSchema = { - ...getExceptionListItemSchemaMock(), - entries: [{ field: 'user.name', operator: 'included', type: 'match', value: 'name' }], - }; - const exceptionFilter = buildExceptionFilter({ - lists: [exceptionItem1, exceptionItem2], - config, - excludeExceptions: true, - chunkSize: 2, - indexPattern: { - fields: [], - title: 'auditbeat-*', - }, - }); - expect(exceptionFilter).toEqual({ - meta: { - alias: null, - negate: true, - disabled: false, - }, - query: { - bool: { - should: [ - { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'host.name': 'linux', - }, - }, - ], - }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.field': 'value', - }, - }, - ], - }, - }, - ], - }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'user.name': 'name', - }, - }, - ], - }, - }, - ], - }, - }, - }); - }); - - test('it should properly chunk exception items', () => { - const exceptionItem1: ExceptionListItemSchema = { - ...getExceptionListItemSchemaMock(), - entries: [ - { field: 'host.name', operator: 'included', type: 'match', value: 'linux' }, - { field: 'some.field', operator: 'included', type: 'match', value: 'value' }, - ], - }; - const exceptionItem2: ExceptionListItemSchema = { - ...getExceptionListItemSchemaMock(), - entries: [{ field: 'user.name', operator: 'included', type: 'match', value: 'name' }], - }; - const exceptionItem3: ExceptionListItemSchema = { - ...getExceptionListItemSchemaMock(), - entries: [{ field: 'file.path', operator: 'included', type: 'match', value: '/safe/path' }], - }; - const exceptionFilter = buildExceptionFilter({ - lists: [exceptionItem1, exceptionItem2, exceptionItem3], - config, - excludeExceptions: true, - chunkSize: 2, - indexPattern: { - fields: [], - title: 'auditbeat-*', - }, - }); - expect(exceptionFilter).toEqual({ - meta: { - alias: null, - negate: true, - disabled: false, - }, - query: { - bool: { - should: [ - { - bool: { - should: [ - { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'host.name': 'linux', - }, - }, - ], - }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.field': 'value', - }, - }, - ], - }, - }, - ], - }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'user.name': 'name', - }, - }, - ], - }, - }, - ], - }, - }, - { - bool: { - should: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'file.path': '/safe/path', - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - }); - }); - }); - describe('buildEqlSearchRequest', () => { test('should build a basic request with time range', () => { const request = buildEqlSearchRequest( @@ -1262,4 +1265,85 @@ describe('get_filter', () => { }); }); }); + + describe('getAllFilters', () => { + const exceptionsFilter = { + meta: { alias: null, negate: false, disabled: false }, + query: { + bool: { + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + should: [ + { match_phrase: { 'some.parentField.nested.field': 'some value' } }, + ], + minimum_should_match: 1, + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + should: [{ match_phrase: { 'some.not.nested.field': 'some value' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + }, + }, + }; + const simpleFilter = { + meta: { + alias: 'custom label here', + disabled: false, + key: 'host.name', + negate: false, + params: { + query: 'siem-windows', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, + }, + }; + + test('it returns array with exceptions filter if exceptions filter if no other filters passed in', () => { + const filters = getAllFilters([], exceptionsFilter); + + expect(filters).toEqual([exceptionsFilter]); + }); + + test('it returns empty array if no filters', () => { + const filters = getAllFilters([], undefined); + + expect(filters).toEqual([]); + }); + + test('it returns array with exceptions filter if exceptions filter is not undefined', () => { + const filters = getAllFilters([simpleFilter], exceptionsFilter); + + expect(filters[0]).toEqual(simpleFilter); + expect(filters[1]).toEqual(exceptionsFilter); + }); + + test('it returns array without exceptions filter if exceptions filter is undefined', () => { + const filters = getAllFilters([simpleFilter], undefined); + + expect(filters[0]).toEqual(simpleFilter); + expect(filters[1]).toBeUndefined(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index fcea90402d89d..0df342a55cac6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -7,7 +7,6 @@ import { Filter, IIndexPattern, - isFilterDisabled, buildEsQuery, EsQueryConfig, } from '../../../../../src/plugins/data/common'; @@ -16,7 +15,7 @@ import { CreateExceptionListItemSchema, } from '../../../lists/common/schemas'; import { ESBoolQuery } from '../typed_json'; -import { buildExceptionListQueries } from './build_exceptions_query'; +import { buildExceptionFilter } from './build_exceptions_filter'; import { Query, Language, Index, TimestampOverrideOrUndefined } from './schemas/common/schemas'; export const getQueryFilter = ( @@ -38,32 +37,27 @@ export const getQueryFilter = ( ignoreFilterIfFieldNotInIndex: false, dateFormatTZ: 'Zulu', }; - - const enabledFilters = ((filters as unknown) as Filter[]).filter((f) => !isFilterDisabled(f)); - /* - * Pinning exceptions to 'kuery' because lucene - * does not support nested queries, while our exceptions - * UI does, since we can pass both lucene and kql into - * buildEsQuery, this allows us to offer nested queries - * regardless - */ // Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value), // allowing us to make 1024-item chunks of exception list items. // Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a // very conservative value. const exceptionFilter = buildExceptionFilter({ lists, - config, excludeExceptions, chunkSize: 1024, - indexPattern, }); - if (exceptionFilter !== undefined) { - enabledFilters.push(exceptionFilter); - } const initialQuery = { query, language }; + const allFilters = getAllFilters((filters as unknown) as Filter[], exceptionFilter); + + return buildEsQuery(indexPattern, initialQuery, allFilters, config); +}; - return buildEsQuery(indexPattern, initialQuery, enabledFilters, config); +export const getAllFilters = (filters: Filter[], exceptionFilter: Filter | undefined): Filter[] => { + if (exceptionFilter != null) { + return [...filters, exceptionFilter]; + } else { + return [...filters]; + } }; interface EqlSearchRequest { @@ -84,26 +78,14 @@ export const buildEqlSearchRequest = ( eventCategoryOverride: string | undefined ): EqlSearchRequest => { const timestamp = timestampOverride ?? '@timestamp'; - const indexPattern: IIndexPattern = { - fields: [], - title: index.join(), - }; - const config: EsQueryConfig = { - allowLeadingWildcards: true, - queryStringOptions: { analyze_wildcard: true }, - ignoreFilterIfFieldNotInIndex: false, - dateFormatTZ: 'Zulu', - }; // Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value), // allowing us to make 1024-item chunks of exception list items. // Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a // very conservative value. const exceptionFilter = buildExceptionFilter({ lists: exceptionLists, - config, excludeExceptions: true, chunkSize: 1024, - indexPattern, }); const indexString = index.join(); const requestFilter: unknown[] = [ @@ -148,69 +130,3 @@ export const buildEqlSearchRequest = ( return baseRequest; } }; - -export const buildExceptionFilter = ({ - lists, - config, - excludeExceptions, - chunkSize, - indexPattern, -}: { - lists: Array; - config: EsQueryConfig; - excludeExceptions: boolean; - chunkSize: number; - indexPattern?: IIndexPattern; -}) => { - const exceptionQueries = buildExceptionListQueries({ language: 'kuery', lists }); - if (exceptionQueries.length === 0) { - return undefined; - } - const exceptionFilter: Filter = { - meta: { - alias: null, - negate: excludeExceptions, - disabled: false, - }, - query: { - bool: { - should: undefined, - }, - }, - }; - if (exceptionQueries.length <= chunkSize) { - const query = buildEsQuery(indexPattern, exceptionQueries, [], config); - exceptionFilter.query.bool.should = query.bool.filter; - } else { - const chunkedFilters: Filter[] = []; - for (let index = 0; index < exceptionQueries.length; index += chunkSize) { - const exceptionQueriesChunk = exceptionQueries.slice(index, index + chunkSize); - const esQueryChunk = buildEsQuery(indexPattern, exceptionQueriesChunk, [], config); - const filterChunk: Filter = { - meta: { - alias: null, - negate: false, - disabled: false, - }, - query: { - bool: { - should: esQueryChunk.bool.filter, - }, - }, - }; - chunkedFilters.push(filterChunk); - } - // Here we build a query with only the exceptions: it will put them all in the `filter` array - // of the resulting object, which would AND the exceptions together. When creating exceptionFilter, - // we move the `filter` array to `should` so they are OR'd together instead. - // This gets around the problem with buildEsQuery not allowing callers to specify whether queries passed in - // should be ANDed or ORed together. - exceptionFilter.query.bool.should = buildEsQuery( - indexPattern, - [], - chunkedFilters, - config - ).bool.filter; - } - return exceptionFilter; -}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/types.ts b/x-pack/plugins/security_solution/common/detection_engine/types.ts index 9e4c71d5eb116..9aac3fc9e614a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/types.ts @@ -55,3 +55,21 @@ export interface EqlSearchResponse { events?: Array>; }; } + +export interface BooleanFilter { + bool: { + must?: unknown | unknown[]; + must_not?: unknown | unknown[]; + should?: unknown[]; + filter?: unknown | unknown[]; + minimum_should_match?: number; + }; +} + +export interface NestedFilter { + nested: { + path: string; + query: unknown | unknown[]; + score_mode: string; + }; +} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx index dbd4c805aa950..e65c9f51f5ccd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx @@ -100,7 +100,7 @@ describe('Exception viewer helpers', () => { value: undefined, }, { - fieldName: 'host.name', + fieldName: 'parent.field', isNested: false, operator: undefined, value: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts index ec801f6c49ae7..c8bf6790ae9b2 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/index.ts @@ -6,8 +6,8 @@ import { RequestParams } from '@elastic/elasticsearch'; +import { buildExceptionFilter } from '../../../common/detection_engine/build_exceptions_filter'; import { ExceptionListItemSchema } from '../../../../lists/common'; -import { buildExceptionFilter } from '../../../common/detection_engine/get_query_filter'; import { AnomalyRecordDoc as Anomaly } from '../../../../ml/server'; import { SearchResponse } from '../types'; @@ -54,12 +54,6 @@ export const getAnomalies = async ( ], must_not: buildExceptionFilter({ lists: params.exceptionItems, - config: { - allowLeadingWildcards: true, - queryStringOptions: { analyze_wildcard: true }, - ignoreFilterIfFieldNotInIndex: false, - dateFormatTZ: 'Zulu', - }, excludeExceptions: true, chunkSize: 1024, })?.query, From e3f150513c359b44e9bd9406de24126288dd5962 Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Wed, 9 Dec 2020 10:25:23 -0800 Subject: [PATCH 34/52] [APM] Add log_level config option to the Node.js Agent (#85346) --- .../agent_configuration/setting_definitions/general_settings.ts | 2 +- .../agent_configuration/setting_definitions/index.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index 43b3748231290..e5961ac6cf6ef 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -110,7 +110,7 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'critical', value: 'critical' }, { text: 'off', value: 'off' }, ], - includeAgents: ['dotnet', 'ruby', 'java', 'python'], + includeAgents: ['dotnet', 'ruby', 'java', 'python', 'nodejs'], }, // Recording diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index c9637f20a51bc..dc5ce6cef97bc 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -100,6 +100,7 @@ describe('filterByAgent', () => { it('nodejs', () => { expect(getSettingKeysForAgent('nodejs')).toEqual([ 'capture_body', + 'log_level', 'transaction_max_spans', 'transaction_sample_rate', ]); From 159eab7f30bc06d1747b7af6f49471eded1f4ca7 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Wed, 9 Dec 2020 11:15:26 -0800 Subject: [PATCH 35/52] Smoke functional UI tests for APM and ML (#85336) * fixes https://github.com/elastic/kibana/issues/74449 * apm smoke test for stack integration tests * minor modifications * ml smoke tests in stack integration Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/apm/apm_smoke_test.js | 31 ++++++++++++++++ .../apps/apm/index.js | 11 ++++++ .../apps/ml/index.js | 11 ++++++ .../apps/ml/ml_smoke_test.js | 37 +++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 x-pack/test/stack_functional_integration/apps/apm/apm_smoke_test.js create mode 100644 x-pack/test/stack_functional_integration/apps/apm/index.js create mode 100644 x-pack/test/stack_functional_integration/apps/ml/index.js create mode 100644 x-pack/test/stack_functional_integration/apps/ml/ml_smoke_test.js diff --git a/x-pack/test/stack_functional_integration/apps/apm/apm_smoke_test.js b/x-pack/test/stack_functional_integration/apps/apm/apm_smoke_test.js new file mode 100644 index 0000000000000..2e0d6f26d190b --- /dev/null +++ b/x-pack/test/stack_functional_integration/apps/apm/apm_smoke_test.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ getService, getPageObjects }) { + describe('APM smoke test', function ampsmokeTest() { + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'timePicker']); + const find = getService('find'); + const log = getService('log'); + + before(async () => { + await browser.setWindowSize(1200, 800); + await PageObjects.common.navigateToApp('apm'); + await PageObjects.timePicker.setCommonlyUsedTime('Last_1 year'); + }); + + it('can navigate to APM app', async () => { + await testSubjects.existOrFail('apmMainContainer', { + timeout: 10000, + }); + await find.clickByLinkText('apm-a-rum-test-e2e-general-usecase'); + log.debug('### apm smoke test passed'); + await find.clickByLinkText('general-usecase-initial-p-load'); + log.debug('### general use case smoke test passed'); + }); + }); +} diff --git a/x-pack/test/stack_functional_integration/apps/apm/index.js b/x-pack/test/stack_functional_integration/apps/apm/index.js new file mode 100644 index 0000000000000..3cf11decd3ebe --- /dev/null +++ b/x-pack/test/stack_functional_integration/apps/apm/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default ({ loadTestFile }) => { + describe('APM smoke test', function () { + loadTestFile(require.resolve('./apm_smoke_test')); + }); +}; diff --git a/x-pack/test/stack_functional_integration/apps/ml/index.js b/x-pack/test/stack_functional_integration/apps/ml/index.js new file mode 100644 index 0000000000000..257b5838f369f --- /dev/null +++ b/x-pack/test/stack_functional_integration/apps/ml/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default ({ loadTestFile }) => { + describe('Machine Learning', function () { + loadTestFile(require.resolve('./ml_smoke_test')); + }); +}; diff --git a/x-pack/test/stack_functional_integration/apps/ml/ml_smoke_test.js b/x-pack/test/stack_functional_integration/apps/ml/ml_smoke_test.js new file mode 100644 index 0000000000000..feaaefeea2e15 --- /dev/null +++ b/x-pack/test/stack_functional_integration/apps/ml/ml_smoke_test.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ getService }) { + describe('ML smoke test should check all the tabs', function mlSmokeTest() { + const browser = getService('browser'); + const ml = getService('ml'); + + before(async () => { + await browser.setWindowSize(1200, 800); + await ml.navigation.navigateToMl(); + }); + + it('should display tabs in the ML app correctly', async () => { + await ml.testExecution.logTestStep('should load the ML app'); + await ml.navigation.navigateToMl(); + + await ml.testExecution.logTestStep('should display the enabled "Overview" tab'); + await ml.navigation.assertOverviewTabEnabled(true); + + await ml.testExecution.logTestStep('should display the enabled "Anomaly Detection" tab'); + await ml.navigation.assertAnomalyDetectionTabEnabled(true); + + await ml.testExecution.logTestStep('should display the enabled "Data Frame Analytics" tab'); + await ml.navigation.assertDataFrameAnalyticsTabEnabled(true); + + await ml.testExecution.logTestStep('should display the enabled "Data Visualizer" tab'); + await ml.navigation.assertDataVisualizerTabEnabled(true); + + await ml.testExecution.logTestStep('should display the enabled "Settings" tab'); + await ml.navigation.assertSettingsTabEnabled(true); + }); + }); +} From 075810bcd315e4a8727c26fabf5c1b3d348add0e Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 9 Dec 2020 12:23:38 -0700 Subject: [PATCH 36/52] [ci/docker] stop removing containers to avoid disrupting chrome (#85425) Co-authored-by: spalger --- .../lib/docker_servers/docker_servers_service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/kbn-test/src/functional_test_runner/lib/docker_servers/docker_servers_service.ts b/packages/kbn-test/src/functional_test_runner/lib/docker_servers/docker_servers_service.ts index 606902228e1b7..e5bad88e5e7bf 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/docker_servers/docker_servers_service.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/docker_servers/docker_servers_service.ts @@ -124,7 +124,11 @@ export class DockerServersService { lifecycle.cleanup.add(() => { try { execa.sync('docker', ['kill', containerId]); - execa.sync('docker', ['rm', containerId]); + // we don't remove the containers on CI because removing them causes the + // network list to be updated and aborts all in-flight requests in Chrome + if (!process.env.CI) { + execa.sync('docker', ['rm', containerId]); + } } catch (error) { if ( error.message.includes(`Container ${containerId} is not running`) || From 30d9078974327834771c1179590c3deb8ba0d9c7 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 9 Dec 2020 12:24:01 -0700 Subject: [PATCH 37/52] [dev/build] investigate flaky copyAll() test (#85435) Co-authored-by: spalger --- src/dev/build/lib/integration_tests/fs.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/dev/build/lib/integration_tests/fs.test.ts b/src/dev/build/lib/integration_tests/fs.test.ts index e9ce09554159b..34d5a15261b6d 100644 --- a/src/dev/build/lib/integration_tests/fs.test.ts +++ b/src/dev/build/lib/integration_tests/fs.test.ts @@ -177,6 +177,16 @@ describe('copyAll()', () => { }); it('copies files and directories from source to dest, creating dest if necessary, respecting mode', async () => { + const path777 = resolve(FIXTURES, 'bin/world_executable'); + const path644 = resolve(FIXTURES, 'foo_dir/bar.txt'); + + // we're seeing flaky failures because the resulting files sometimes have + // 755 permissions. Unless there's a bug in vinyl-fs I can't figure out + // where the issue might be, so trying to validate the mode first to narrow + // down where the issue might be + expect(getCommonMode(path777)).toBe(isWindows ? '666' : '777'); + expect(getCommonMode(path644)).toBe(isWindows ? '666' : '644'); + const destination = resolve(TMP, 'a/b/c'); await copyAll(FIXTURES, destination); @@ -185,10 +195,8 @@ describe('copyAll()', () => { resolve(destination, 'foo_dir/foo'), ]); - expect(getCommonMode(resolve(destination, 'bin/world_executable'))).toBe( - isWindows ? '666' : '777' - ); - expect(getCommonMode(resolve(destination, 'foo_dir/bar.txt'))).toBe(isWindows ? '666' : '644'); + expect(getCommonMode(path777)).toBe(isWindows ? '666' : '777'); + expect(getCommonMode(path644)).toBe(isWindows ? '666' : '644'); }); it('applies select globs if specified, ignores dot files', async () => { From 90893f94873d9e8fd3b298e1022b72df11387ea4 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 9 Dec 2020 19:26:43 +0000 Subject: [PATCH 38/52] chore(NA): add resolution for chokidar to prevent fsevents@1.x (#85171) * chore(NA): add fsevents resolution to 2.x * chore(NA): add fsevents as an optionalDepedency to allow resolutions to be set for it * chore(NA): remove fsevents resolution from package.json * chore(NA): remove optionalDependencies from being declared * chore(NA): update kbn pm dist Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 3 +- packages/kbn-pm/dist/index.js | 926 +++++++++++++++++++--------------- yarn.lock | 135 +---- 3 files changed, 537 insertions(+), 527 deletions(-) diff --git a/package.json b/package.json index f2786871fb629..3687e6d590ce7 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "**/@types/hapi__hapi": "^18.2.6", "**/@types/hapi__mimos": "4.1.0", "**/@types/node": "14.14.7", + "**/chokidar": "^3.4.3", "**/cross-fetch/node-fetch": "^2.6.1", "**/deepmerge": "^4.2.2", "**/fast-deep-equal": "^3.1.1", @@ -174,7 +175,7 @@ "chalk": "^4.1.0", "check-disk-space": "^2.1.0", "cheerio": "0.22.0", - "chokidar": "^3.4.2", + "chokidar": "^3.4.3", "chroma-js": "^1.4.1", "classnames": "2.2.6", "color": "1.0.3", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index eb9b7a4a35dc7..922159ab555c8 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -59762,11 +59762,11 @@ const os = __webpack_require__(121); const pMap = __webpack_require__(514); const arrify = __webpack_require__(509); const globby = __webpack_require__(515); -const hasGlob = __webpack_require__(711); -const cpFile = __webpack_require__(713); -const junk = __webpack_require__(723); -const pFilter = __webpack_require__(724); -const CpyError = __webpack_require__(726); +const hasGlob = __webpack_require__(715); +const cpFile = __webpack_require__(717); +const junk = __webpack_require__(727); +const pFilter = __webpack_require__(728); +const CpyError = __webpack_require__(730); const defaultOptions = { ignoreJunk: true @@ -60014,8 +60014,8 @@ const fs = __webpack_require__(134); const arrayUnion = __webpack_require__(516); const glob = __webpack_require__(147); const fastGlob = __webpack_require__(518); -const dirGlob = __webpack_require__(704); -const gitignore = __webpack_require__(707); +const dirGlob = __webpack_require__(708); +const gitignore = __webpack_require__(711); const DEFAULT_FILTER = () => false; @@ -60266,11 +60266,11 @@ module.exports.generateTasks = pkg.generateTasks; Object.defineProperty(exports, "__esModule", { value: true }); var optionsManager = __webpack_require__(520); var taskManager = __webpack_require__(521); -var reader_async_1 = __webpack_require__(675); -var reader_stream_1 = __webpack_require__(699); -var reader_sync_1 = __webpack_require__(700); -var arrayUtils = __webpack_require__(702); -var streamUtils = __webpack_require__(703); +var reader_async_1 = __webpack_require__(679); +var reader_stream_1 = __webpack_require__(703); +var reader_sync_1 = __webpack_require__(704); +var arrayUtils = __webpack_require__(706); +var streamUtils = __webpack_require__(707); /** * Synchronous API. */ @@ -60851,16 +60851,16 @@ module.exports.win32 = win32; var util = __webpack_require__(112); var braces = __webpack_require__(527); var toRegex = __webpack_require__(528); -var extend = __webpack_require__(641); +var extend = __webpack_require__(645); /** * Local dependencies */ -var compilers = __webpack_require__(643); -var parsers = __webpack_require__(670); -var cache = __webpack_require__(671); -var utils = __webpack_require__(672); +var compilers = __webpack_require__(647); +var parsers = __webpack_require__(674); +var cache = __webpack_require__(675); +var utils = __webpack_require__(676); var MAX_LENGTH = 1024 * 64; /** @@ -61741,8 +61741,8 @@ var extend = __webpack_require__(551); */ var compilers = __webpack_require__(553); -var parsers = __webpack_require__(566); -var Braces = __webpack_require__(570); +var parsers = __webpack_require__(568); +var Braces = __webpack_require__(572); var utils = __webpack_require__(554); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -64182,7 +64182,7 @@ utils.extend = __webpack_require__(551); utils.flatten = __webpack_require__(558); utils.isObject = __webpack_require__(536); utils.fillRange = __webpack_require__(559); -utils.repeat = __webpack_require__(565); +utils.repeat = __webpack_require__(567); utils.unique = __webpack_require__(550); utils.define = function(obj, key, val) { @@ -64825,9 +64825,9 @@ function flat(arr, res) { var util = __webpack_require__(112); var isNumber = __webpack_require__(560); -var extend = __webpack_require__(551); -var repeat = __webpack_require__(563); -var toRegex = __webpack_require__(564); +var extend = __webpack_require__(563); +var repeat = __webpack_require__(565); +var toRegex = __webpack_require__(566); /** * Return a range of numbers or letters. @@ -65206,6 +65206,66 @@ function isSlowBuffer (obj) { /* 563 */ /***/ (function(module, exports, __webpack_require__) { +"use strict"; + + +var isObject = __webpack_require__(564); + +module.exports = function extend(o/*, objects*/) { + if (!isObject(o)) { o = {}; } + + var len = arguments.length; + for (var i = 1; i < len; i++) { + var obj = arguments[i]; + + if (isObject(obj)) { + assign(o, obj); + } + } + return o; +}; + +function assign(a, b) { + for (var key in b) { + if (hasOwn(b, key)) { + a[key] = b[key]; + } + } +} + +/** + * Returns true if the given `key` is an own property of `obj`. + */ + +function hasOwn(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + + +/***/ }), +/* 564 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/*! + * is-extendable + * + * Copyright (c) 2015, Jon Schlinkert. + * Licensed under the MIT License. + */ + + + +module.exports = function isExtendable(val) { + return typeof val !== 'undefined' && val !== null + && (typeof val === 'object' || typeof val === 'function'); +}; + + +/***/ }), +/* 565 */ +/***/ (function(module, exports, __webpack_require__) { + "use strict"; /*! * repeat-string @@ -65280,7 +65340,7 @@ function repeat(str, num) { /***/ }), -/* 564 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65293,7 +65353,7 @@ function repeat(str, num) { -var repeat = __webpack_require__(563); +var repeat = __webpack_require__(565); var isNumber = __webpack_require__(560); var cache = {}; @@ -65581,7 +65641,7 @@ module.exports = toRegexRange; /***/ }), -/* 565 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65606,13 +65666,13 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 566 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(567); +var Node = __webpack_require__(569); var utils = __webpack_require__(554); /** @@ -65973,15 +66033,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 567 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var isObject = __webpack_require__(536); -var define = __webpack_require__(568); -var utils = __webpack_require__(569); +var define = __webpack_require__(570); +var utils = __webpack_require__(571); var ownNames; /** @@ -66472,7 +66532,7 @@ exports = module.exports = Node; /***/ }), -/* 568 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66510,7 +66570,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 569 */ +/* 571 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67536,16 +67596,16 @@ function assert(val, message) { /***/ }), -/* 570 */ +/* 572 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var extend = __webpack_require__(551); -var Snapdragon = __webpack_require__(571); +var Snapdragon = __webpack_require__(573); var compilers = __webpack_require__(553); -var parsers = __webpack_require__(566); +var parsers = __webpack_require__(568); var utils = __webpack_require__(554); /** @@ -67647,17 +67707,17 @@ module.exports = Braces; /***/ }), -/* 571 */ +/* 573 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(572); -var define = __webpack_require__(599); -var Compiler = __webpack_require__(609); -var Parser = __webpack_require__(638); -var utils = __webpack_require__(618); +var Base = __webpack_require__(574); +var define = __webpack_require__(603); +var Compiler = __webpack_require__(613); +var Parser = __webpack_require__(642); +var utils = __webpack_require__(622); var regexCache = {}; var cache = {}; @@ -67828,20 +67888,20 @@ module.exports.Parser = Parser; /***/ }), -/* 572 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var define = __webpack_require__(573); -var CacheBase = __webpack_require__(574); -var Emitter = __webpack_require__(575); +var define = __webpack_require__(575); +var CacheBase = __webpack_require__(576); +var Emitter = __webpack_require__(577); var isObject = __webpack_require__(536); -var merge = __webpack_require__(593); -var pascal = __webpack_require__(596); -var cu = __webpack_require__(597); +var merge = __webpack_require__(597); +var pascal = __webpack_require__(600); +var cu = __webpack_require__(601); /** * Optionally define a custom `cache` namespace to use. @@ -68270,7 +68330,7 @@ module.exports.namespace = namespace; /***/ }), -/* 573 */ +/* 575 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68308,21 +68368,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 574 */ +/* 576 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var isObject = __webpack_require__(536); -var Emitter = __webpack_require__(575); -var visit = __webpack_require__(576); -var toPath = __webpack_require__(579); -var union = __webpack_require__(580); -var del = __webpack_require__(584); -var get = __webpack_require__(582); -var has = __webpack_require__(589); -var set = __webpack_require__(592); +var Emitter = __webpack_require__(577); +var visit = __webpack_require__(578); +var toPath = __webpack_require__(581); +var union = __webpack_require__(582); +var del = __webpack_require__(588); +var get = __webpack_require__(585); +var has = __webpack_require__(593); +var set = __webpack_require__(596); /** * Create a `Cache` constructor that when instantiated will @@ -68576,7 +68636,7 @@ module.exports.namespace = namespace; /***/ }), -/* 575 */ +/* 577 */ /***/ (function(module, exports, __webpack_require__) { @@ -68745,7 +68805,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 576 */ +/* 578 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68758,8 +68818,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(577); -var mapVisit = __webpack_require__(578); +var visit = __webpack_require__(579); +var mapVisit = __webpack_require__(580); module.exports = function(collection, method, val) { var result; @@ -68782,7 +68842,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 577 */ +/* 579 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68822,14 +68882,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 578 */ +/* 580 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var visit = __webpack_require__(577); +var visit = __webpack_require__(579); /** * Map `visit` over an array of objects. @@ -68866,7 +68926,7 @@ function isObject(val) { /***/ }), -/* 579 */ +/* 581 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68906,16 +68966,16 @@ function filter(arr) { /***/ }), -/* 580 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(552); -var union = __webpack_require__(581); -var get = __webpack_require__(582); -var set = __webpack_require__(583); +var isObject = __webpack_require__(583); +var union = __webpack_require__(584); +var get = __webpack_require__(585); +var set = __webpack_require__(586); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -68943,7 +69003,27 @@ function arrayify(val) { /***/ }), -/* 581 */ +/* 583 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/*! + * is-extendable + * + * Copyright (c) 2015, Jon Schlinkert. + * Licensed under the MIT License. + */ + + + +module.exports = function isExtendable(val) { + return typeof val !== 'undefined' && val !== null + && (typeof val === 'object' || typeof val === 'function'); +}; + + +/***/ }), +/* 584 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68979,7 +69059,7 @@ module.exports = function union(init) { /***/ }), -/* 582 */ +/* 585 */ /***/ (function(module, exports) { /*! @@ -69035,7 +69115,7 @@ function toString(val) { /***/ }), -/* 583 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69049,9 +69129,9 @@ function toString(val) { var split = __webpack_require__(555); -var extend = __webpack_require__(551); +var extend = __webpack_require__(587); var isPlainObject = __webpack_require__(545); -var isObject = __webpack_require__(552); +var isObject = __webpack_require__(583); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69097,7 +69177,47 @@ function isValidKey(key) { /***/ }), -/* 584 */ +/* 587 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var isObject = __webpack_require__(583); + +module.exports = function extend(o/*, objects*/) { + if (!isObject(o)) { o = {}; } + + var len = arguments.length; + for (var i = 1; i < len; i++) { + var obj = arguments[i]; + + if (isObject(obj)) { + assign(o, obj); + } + } + return o; +}; + +function assign(a, b) { + for (var key in b) { + if (hasOwn(b, key)) { + a[key] = b[key]; + } + } +} + +/** + * Returns true if the given `key` is an own property of `obj`. + */ + +function hasOwn(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + + +/***/ }), +/* 588 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69111,7 +69231,7 @@ function isValidKey(key) { var isObject = __webpack_require__(536); -var has = __webpack_require__(585); +var has = __webpack_require__(589); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -69136,7 +69256,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 585 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69149,9 +69269,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(586); -var hasValues = __webpack_require__(588); -var get = __webpack_require__(582); +var isObject = __webpack_require__(590); +var hasValues = __webpack_require__(592); +var get = __webpack_require__(585); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -69162,7 +69282,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 586 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69175,7 +69295,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(587); +var isArray = __webpack_require__(591); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -69183,7 +69303,7 @@ module.exports = function isObject(val) { /***/ }), -/* 587 */ +/* 591 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -69194,7 +69314,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 588 */ +/* 592 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69237,7 +69357,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 589 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69251,8 +69371,8 @@ module.exports = function hasValue(o, noZero) { var isObject = __webpack_require__(536); -var hasValues = __webpack_require__(590); -var get = __webpack_require__(582); +var hasValues = __webpack_require__(594); +var get = __webpack_require__(585); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -69260,7 +69380,7 @@ module.exports = function(val, prop) { /***/ }), -/* 590 */ +/* 594 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69273,7 +69393,7 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(591); +var typeOf = __webpack_require__(595); var isNumber = __webpack_require__(560); module.exports = function hasValue(val) { @@ -69327,7 +69447,7 @@ module.exports = function hasValue(val) { /***/ }), -/* 591 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { var isBuffer = __webpack_require__(562); @@ -69452,7 +69572,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 592 */ +/* 596 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69466,9 +69586,9 @@ module.exports = function kindOf(val) { var split = __webpack_require__(555); -var extend = __webpack_require__(551); +var extend = __webpack_require__(587); var isPlainObject = __webpack_require__(545); -var isObject = __webpack_require__(552); +var isObject = __webpack_require__(583); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69514,14 +69634,14 @@ function isValidKey(key) { /***/ }), -/* 593 */ +/* 597 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(594); -var forIn = __webpack_require__(595); +var isExtendable = __webpack_require__(598); +var forIn = __webpack_require__(599); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -69585,7 +69705,7 @@ module.exports = mixinDeep; /***/ }), -/* 594 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69606,7 +69726,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 595 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69629,7 +69749,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 596 */ +/* 600 */ /***/ (function(module, exports) { /*! @@ -69656,14 +69776,14 @@ module.exports = pascalcase; /***/ }), -/* 597 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var utils = __webpack_require__(598); +var utils = __webpack_require__(602); /** * Expose class utils @@ -70028,7 +70148,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 598 */ +/* 602 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70042,10 +70162,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(581); -utils.define = __webpack_require__(599); +utils.union = __webpack_require__(584); +utils.define = __webpack_require__(603); utils.isObj = __webpack_require__(536); -utils.staticExtend = __webpack_require__(606); +utils.staticExtend = __webpack_require__(610); /** @@ -70056,7 +70176,7 @@ module.exports = utils; /***/ }), -/* 599 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70069,7 +70189,7 @@ module.exports = utils; -var isDescriptor = __webpack_require__(600); +var isDescriptor = __webpack_require__(604); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -70094,7 +70214,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 600 */ +/* 604 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70107,9 +70227,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(601); -var isAccessor = __webpack_require__(602); -var isData = __webpack_require__(604); +var typeOf = __webpack_require__(605); +var isAccessor = __webpack_require__(606); +var isData = __webpack_require__(608); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -70123,7 +70243,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 601 */ +/* 605 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -70276,7 +70396,7 @@ function isBuffer(val) { /***/ }), -/* 602 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70289,7 +70409,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(603); +var typeOf = __webpack_require__(607); // accessor descriptor properties var accessor = { @@ -70352,7 +70472,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 603 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { var isBuffer = __webpack_require__(562); @@ -70474,7 +70594,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 604 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70487,7 +70607,7 @@ module.exports = function kindOf(val) { -var typeOf = __webpack_require__(605); +var typeOf = __webpack_require__(609); // data descriptor properties var data = { @@ -70536,7 +70656,7 @@ module.exports = isDataDescriptor; /***/ }), -/* 605 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { var isBuffer = __webpack_require__(562); @@ -70658,7 +70778,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 606 */ +/* 610 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70671,8 +70791,8 @@ module.exports = function kindOf(val) { -var copy = __webpack_require__(607); -var define = __webpack_require__(599); +var copy = __webpack_require__(611); +var define = __webpack_require__(603); var util = __webpack_require__(112); /** @@ -70755,15 +70875,15 @@ module.exports = extend; /***/ }), -/* 607 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var typeOf = __webpack_require__(561); -var copyDescriptor = __webpack_require__(608); -var define = __webpack_require__(599); +var copyDescriptor = __webpack_require__(612); +var define = __webpack_require__(603); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -70936,7 +71056,7 @@ module.exports.has = has; /***/ }), -/* 608 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71024,16 +71144,16 @@ function isObject(val) { /***/ }), -/* 609 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(610); -var define = __webpack_require__(599); -var debug = __webpack_require__(612)('snapdragon:compiler'); -var utils = __webpack_require__(618); +var use = __webpack_require__(614); +var define = __webpack_require__(603); +var debug = __webpack_require__(616)('snapdragon:compiler'); +var utils = __webpack_require__(622); /** * Create a new `Compiler` with the given `options`. @@ -71187,7 +71307,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(637); + var sourcemaps = __webpack_require__(641); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -71208,7 +71328,7 @@ module.exports = Compiler; /***/ }), -/* 610 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71221,7 +71341,7 @@ module.exports = Compiler; -var utils = __webpack_require__(611); +var utils = __webpack_require__(615); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -71336,7 +71456,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 611 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71350,7 +71470,7 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(599); +utils.define = __webpack_require__(603); utils.isObject = __webpack_require__(536); @@ -71366,7 +71486,7 @@ module.exports = utils; /***/ }), -/* 612 */ +/* 616 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71375,14 +71495,14 @@ module.exports = utils; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(613); + module.exports = __webpack_require__(617); } else { - module.exports = __webpack_require__(616); + module.exports = __webpack_require__(620); } /***/ }), -/* 613 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71391,7 +71511,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(614); +exports = module.exports = __webpack_require__(618); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -71573,7 +71693,7 @@ function localstorage() { /***/ }), -/* 614 */ +/* 618 */ /***/ (function(module, exports, __webpack_require__) { @@ -71589,7 +71709,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(615); +exports.humanize = __webpack_require__(619); /** * The currently active debug mode names, and names to skip. @@ -71781,7 +71901,7 @@ function coerce(val) { /***/ }), -/* 615 */ +/* 619 */ /***/ (function(module, exports) { /** @@ -71939,7 +72059,7 @@ function plural(ms, n, name) { /***/ }), -/* 616 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71955,7 +72075,7 @@ var util = __webpack_require__(112); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(614); +exports = module.exports = __webpack_require__(618); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -72134,7 +72254,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(617); + var net = __webpack_require__(621); stream = new net.Socket({ fd: fd, readable: false, @@ -72193,13 +72313,13 @@ exports.enable(load()); /***/ }), -/* 617 */ +/* 621 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 618 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72209,9 +72329,9 @@ module.exports = require("net"); * Module dependencies */ -exports.extend = __webpack_require__(551); -exports.SourceMap = __webpack_require__(619); -exports.sourceMapResolve = __webpack_require__(630); +exports.extend = __webpack_require__(587); +exports.SourceMap = __webpack_require__(623); +exports.sourceMapResolve = __webpack_require__(634); /** * Convert backslash in the given string to forward slashes @@ -72254,7 +72374,7 @@ exports.last = function(arr, n) { /***/ }), -/* 619 */ +/* 623 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -72262,13 +72382,13 @@ exports.last = function(arr, n) { * Licensed under the New BSD license. See LICENSE.txt or: * http://opensource.org/licenses/BSD-3-Clause */ -exports.SourceMapGenerator = __webpack_require__(620).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(626).SourceMapConsumer; -exports.SourceNode = __webpack_require__(629).SourceNode; +exports.SourceMapGenerator = __webpack_require__(624).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(630).SourceMapConsumer; +exports.SourceNode = __webpack_require__(633).SourceNode; /***/ }), -/* 620 */ +/* 624 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72278,10 +72398,10 @@ exports.SourceNode = __webpack_require__(629).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(621); -var util = __webpack_require__(623); -var ArraySet = __webpack_require__(624).ArraySet; -var MappingList = __webpack_require__(625).MappingList; +var base64VLQ = __webpack_require__(625); +var util = __webpack_require__(627); +var ArraySet = __webpack_require__(628).ArraySet; +var MappingList = __webpack_require__(629).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -72690,7 +72810,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 621 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72730,7 +72850,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(622); +var base64 = __webpack_require__(626); // A single base 64 digit can contain 6 bits of data. For the base 64 variable // length quantities we use in the source map spec, the first bit is the sign, @@ -72836,7 +72956,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 622 */ +/* 626 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72909,7 +73029,7 @@ exports.decode = function (charCode) { /***/ }), -/* 623 */ +/* 627 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73332,7 +73452,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 624 */ +/* 628 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73342,7 +73462,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(623); +var util = __webpack_require__(627); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -73459,7 +73579,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 625 */ +/* 629 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73469,7 +73589,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(623); +var util = __webpack_require__(627); /** * Determine whether mappingB is after mappingA with respect to generated @@ -73544,7 +73664,7 @@ exports.MappingList = MappingList; /***/ }), -/* 626 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73554,11 +73674,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(623); -var binarySearch = __webpack_require__(627); -var ArraySet = __webpack_require__(624).ArraySet; -var base64VLQ = __webpack_require__(621); -var quickSort = __webpack_require__(628).quickSort; +var util = __webpack_require__(627); +var binarySearch = __webpack_require__(631); +var ArraySet = __webpack_require__(628).ArraySet; +var base64VLQ = __webpack_require__(625); +var quickSort = __webpack_require__(632).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -74632,7 +74752,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 627 */ +/* 631 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74749,7 +74869,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 628 */ +/* 632 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74869,7 +74989,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 629 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74879,8 +74999,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(620).SourceMapGenerator; -var util = __webpack_require__(623); +var SourceMapGenerator = __webpack_require__(624).SourceMapGenerator; +var util = __webpack_require__(627); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -75288,17 +75408,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 630 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(631) -var resolveUrl = __webpack_require__(632) -var decodeUriComponent = __webpack_require__(633) -var urix = __webpack_require__(635) -var atob = __webpack_require__(636) +var sourceMappingURL = __webpack_require__(635) +var resolveUrl = __webpack_require__(636) +var decodeUriComponent = __webpack_require__(637) +var urix = __webpack_require__(639) +var atob = __webpack_require__(640) @@ -75596,7 +75716,7 @@ module.exports = { /***/ }), -/* 631 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -75659,7 +75779,7 @@ void (function(root, factory) { /***/ }), -/* 632 */ +/* 636 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75677,13 +75797,13 @@ module.exports = resolveUrl /***/ }), -/* 633 */ +/* 637 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(634) +var decodeUriComponent = __webpack_require__(638) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -75694,7 +75814,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 634 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75795,7 +75915,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 635 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75818,7 +75938,7 @@ module.exports = urix /***/ }), -/* 636 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75832,7 +75952,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 637 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75840,8 +75960,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(134); var path = __webpack_require__(4); -var define = __webpack_require__(599); -var utils = __webpack_require__(618); +var define = __webpack_require__(603); +var utils = __webpack_require__(622); /** * Expose `mixin()`. @@ -75984,19 +76104,19 @@ exports.comment = function(node) { /***/ }), -/* 638 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(610); +var use = __webpack_require__(614); var util = __webpack_require__(112); -var Cache = __webpack_require__(639); -var define = __webpack_require__(599); -var debug = __webpack_require__(612)('snapdragon:parser'); -var Position = __webpack_require__(640); -var utils = __webpack_require__(618); +var Cache = __webpack_require__(643); +var define = __webpack_require__(603); +var debug = __webpack_require__(616)('snapdragon:parser'); +var Position = __webpack_require__(644); +var utils = __webpack_require__(622); /** * Create a new `Parser` with the given `input` and `options`. @@ -76524,7 +76644,7 @@ module.exports = Parser; /***/ }), -/* 639 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76631,13 +76751,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 640 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(599); +var define = __webpack_require__(603); /** * Store position for a node @@ -76652,13 +76772,13 @@ module.exports = function Position(start, parser) { /***/ }), -/* 641 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(642); +var isExtendable = __webpack_require__(646); var assignSymbols = __webpack_require__(546); module.exports = Object.assign || function(obj/*, objects*/) { @@ -76719,7 +76839,7 @@ function isEnum(obj, key) { /***/ }), -/* 642 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76740,14 +76860,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 643 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(644); -var extglob = __webpack_require__(659); +var nanomatch = __webpack_require__(648); +var extglob = __webpack_require__(663); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -76824,7 +76944,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 644 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76836,16 +76956,16 @@ function escapeExtglobs(compiler) { var util = __webpack_require__(112); var toRegex = __webpack_require__(528); -var extend = __webpack_require__(645); +var extend = __webpack_require__(649); /** * Local dependencies */ -var compilers = __webpack_require__(647); -var parsers = __webpack_require__(648); -var cache = __webpack_require__(651); -var utils = __webpack_require__(653); +var compilers = __webpack_require__(651); +var parsers = __webpack_require__(652); +var cache = __webpack_require__(655); +var utils = __webpack_require__(657); var MAX_LENGTH = 1024 * 64; /** @@ -77669,13 +77789,13 @@ module.exports = nanomatch; /***/ }), -/* 645 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(646); +var isExtendable = __webpack_require__(650); var assignSymbols = __webpack_require__(546); module.exports = Object.assign || function(obj/*, objects*/) { @@ -77736,7 +77856,7 @@ function isEnum(obj, key) { /***/ }), -/* 646 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77757,7 +77877,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 647 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78103,7 +78223,7 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 648 */ +/* 652 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78111,7 +78231,7 @@ module.exports = function(nanomatch, options) { var regexNot = __webpack_require__(547); var toRegex = __webpack_require__(528); -var isOdd = __webpack_require__(649); +var isOdd = __webpack_require__(653); /** * Characters to use in negation regex (we want to "not" match @@ -78497,7 +78617,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 649 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78510,7 +78630,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(650); +var isNumber = __webpack_require__(654); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -78524,7 +78644,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 650 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78552,14 +78672,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 651 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(652))(); +module.exports = new (__webpack_require__(656))(); /***/ }), -/* 652 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78572,7 +78692,7 @@ module.exports = new (__webpack_require__(652))(); -var MapCache = __webpack_require__(639); +var MapCache = __webpack_require__(643); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -78694,7 +78814,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 653 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78707,13 +78827,13 @@ var path = __webpack_require__(4); * Module dependencies */ -var isWindows = __webpack_require__(654)(); -var Snapdragon = __webpack_require__(571); -utils.define = __webpack_require__(655); -utils.diff = __webpack_require__(656); -utils.extend = __webpack_require__(645); -utils.pick = __webpack_require__(657); -utils.typeOf = __webpack_require__(658); +var isWindows = __webpack_require__(658)(); +var Snapdragon = __webpack_require__(573); +utils.define = __webpack_require__(659); +utils.diff = __webpack_require__(660); +utils.extend = __webpack_require__(649); +utils.pick = __webpack_require__(661); +utils.typeOf = __webpack_require__(662); utils.unique = __webpack_require__(550); /** @@ -79080,7 +79200,7 @@ utils.unixify = function(options) { /***/ }), -/* 654 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -79108,7 +79228,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 655 */ +/* 659 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79153,7 +79273,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 656 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79207,7 +79327,7 @@ function diffArray(one, two) { /***/ }), -/* 657 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79249,7 +79369,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 658 */ +/* 662 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -79384,7 +79504,7 @@ function isBuffer(val) { /***/ }), -/* 659 */ +/* 663 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79394,7 +79514,7 @@ function isBuffer(val) { * Module dependencies */ -var extend = __webpack_require__(551); +var extend = __webpack_require__(587); var unique = __webpack_require__(550); var toRegex = __webpack_require__(528); @@ -79402,10 +79522,10 @@ var toRegex = __webpack_require__(528); * Local dependencies */ -var compilers = __webpack_require__(660); -var parsers = __webpack_require__(666); -var Extglob = __webpack_require__(669); -var utils = __webpack_require__(668); +var compilers = __webpack_require__(664); +var parsers = __webpack_require__(670); +var Extglob = __webpack_require__(673); +var utils = __webpack_require__(672); var MAX_LENGTH = 1024 * 64; /** @@ -79722,13 +79842,13 @@ module.exports = extglob; /***/ }), -/* 660 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(661); +var brackets = __webpack_require__(665); /** * Extglob compilers @@ -79898,7 +80018,7 @@ module.exports = function(extglob) { /***/ }), -/* 661 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79908,16 +80028,16 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(662); -var parsers = __webpack_require__(664); +var compilers = __webpack_require__(666); +var parsers = __webpack_require__(668); /** * Module dependencies */ -var debug = __webpack_require__(612)('expand-brackets'); -var extend = __webpack_require__(551); -var Snapdragon = __webpack_require__(571); +var debug = __webpack_require__(616)('expand-brackets'); +var extend = __webpack_require__(587); +var Snapdragon = __webpack_require__(573); var toRegex = __webpack_require__(528); /** @@ -80116,13 +80236,13 @@ module.exports = brackets; /***/ }), -/* 662 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(663); +var posix = __webpack_require__(667); module.exports = function(brackets) { brackets.compiler @@ -80210,7 +80330,7 @@ module.exports = function(brackets) { /***/ }), -/* 663 */ +/* 667 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80239,14 +80359,14 @@ module.exports = { /***/ }), -/* 664 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(665); -var define = __webpack_require__(599); +var utils = __webpack_require__(669); +var define = __webpack_require__(603); /** * Text regex @@ -80465,7 +80585,7 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 665 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80506,15 +80626,15 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 666 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(661); -var define = __webpack_require__(667); -var utils = __webpack_require__(668); +var brackets = __webpack_require__(665); +var define = __webpack_require__(671); +var utils = __webpack_require__(672); /** * Characters to use in text regex (we want to "not" match @@ -80669,7 +80789,7 @@ module.exports = parsers; /***/ }), -/* 667 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80707,14 +80827,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 668 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var regex = __webpack_require__(547); -var Cache = __webpack_require__(652); +var Cache = __webpack_require__(656); /** * Utils @@ -80783,7 +80903,7 @@ utils.createRegex = function(str) { /***/ }), -/* 669 */ +/* 673 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80793,16 +80913,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(571); -var define = __webpack_require__(667); -var extend = __webpack_require__(551); +var Snapdragon = __webpack_require__(573); +var define = __webpack_require__(671); +var extend = __webpack_require__(587); /** * Local dependencies */ -var compilers = __webpack_require__(660); -var parsers = __webpack_require__(666); +var compilers = __webpack_require__(664); +var parsers = __webpack_require__(670); /** * Customize Snapdragon parser and renderer @@ -80868,14 +80988,14 @@ module.exports = Extglob; /***/ }), -/* 670 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(659); -var nanomatch = __webpack_require__(644); +var extglob = __webpack_require__(663); +var nanomatch = __webpack_require__(648); var regexNot = __webpack_require__(547); var toRegex = __webpack_require__(528); var not; @@ -80958,14 +81078,14 @@ function textRegex(pattern) { /***/ }), -/* 671 */ +/* 675 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(652))(); +module.exports = new (__webpack_require__(656))(); /***/ }), -/* 672 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80978,12 +81098,12 @@ var path = __webpack_require__(4); * Module dependencies */ -var Snapdragon = __webpack_require__(571); -utils.define = __webpack_require__(673); -utils.diff = __webpack_require__(656); -utils.extend = __webpack_require__(641); -utils.pick = __webpack_require__(657); -utils.typeOf = __webpack_require__(674); +var Snapdragon = __webpack_require__(573); +utils.define = __webpack_require__(677); +utils.diff = __webpack_require__(660); +utils.extend = __webpack_require__(645); +utils.pick = __webpack_require__(661); +utils.typeOf = __webpack_require__(678); utils.unique = __webpack_require__(550); /** @@ -81281,7 +81401,7 @@ utils.unixify = function(options) { /***/ }), -/* 673 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81326,7 +81446,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 674 */ +/* 678 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -81461,7 +81581,7 @@ function isBuffer(val) { /***/ }), -/* 675 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81480,9 +81600,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(676); -var reader_1 = __webpack_require__(689); -var fs_stream_1 = __webpack_require__(693); +var readdir = __webpack_require__(680); +var reader_1 = __webpack_require__(693); +var fs_stream_1 = __webpack_require__(697); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -81543,15 +81663,15 @@ exports.default = ReaderAsync; /***/ }), -/* 676 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(677); -const readdirAsync = __webpack_require__(685); -const readdirStream = __webpack_require__(688); +const readdirSync = __webpack_require__(681); +const readdirAsync = __webpack_require__(689); +const readdirStream = __webpack_require__(692); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -81635,7 +81755,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 677 */ +/* 681 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81643,11 +81763,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(678); +const DirectoryReader = __webpack_require__(682); let syncFacade = { - fs: __webpack_require__(683), - forEach: __webpack_require__(684), + fs: __webpack_require__(687), + forEach: __webpack_require__(688), sync: true }; @@ -81676,7 +81796,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 678 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81685,9 +81805,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(138).Readable; const EventEmitter = __webpack_require__(156).EventEmitter; const path = __webpack_require__(4); -const normalizeOptions = __webpack_require__(679); -const stat = __webpack_require__(681); -const call = __webpack_require__(682); +const normalizeOptions = __webpack_require__(683); +const stat = __webpack_require__(685); +const call = __webpack_require__(686); /** * Asynchronously reads the contents of a directory and streams the results @@ -82063,14 +82183,14 @@ module.exports = DirectoryReader; /***/ }), -/* 679 */ +/* 683 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const globToRegExp = __webpack_require__(680); +const globToRegExp = __webpack_require__(684); module.exports = normalizeOptions; @@ -82247,7 +82367,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 680 */ +/* 684 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -82384,13 +82504,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 681 */ +/* 685 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(682); +const call = __webpack_require__(686); module.exports = stat; @@ -82465,7 +82585,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 682 */ +/* 686 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82526,14 +82646,14 @@ function callOnce (fn) { /***/ }), -/* 683 */ +/* 687 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const call = __webpack_require__(682); +const call = __webpack_require__(686); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -82597,7 +82717,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 684 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82626,7 +82746,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 685 */ +/* 689 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82634,12 +82754,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(686); -const DirectoryReader = __webpack_require__(678); +const maybe = __webpack_require__(690); +const DirectoryReader = __webpack_require__(682); let asyncFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(687), + forEach: __webpack_require__(691), async: true }; @@ -82681,7 +82801,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 686 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82708,7 +82828,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 687 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82744,7 +82864,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 688 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82752,11 +82872,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(678); +const DirectoryReader = __webpack_require__(682); let streamFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(687), + forEach: __webpack_require__(691), async: true }; @@ -82776,16 +82896,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 689 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var deep_1 = __webpack_require__(690); -var entry_1 = __webpack_require__(692); -var pathUtil = __webpack_require__(691); +var deep_1 = __webpack_require__(694); +var entry_1 = __webpack_require__(696); +var pathUtil = __webpack_require__(695); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -82851,13 +82971,13 @@ exports.default = Reader; /***/ }), -/* 690 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(691); +var pathUtils = __webpack_require__(695); var patternUtils = __webpack_require__(522); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { @@ -82941,7 +83061,7 @@ exports.default = DeepFilter; /***/ }), -/* 691 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82972,13 +83092,13 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 692 */ +/* 696 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(691); +var pathUtils = __webpack_require__(695); var patternUtils = __webpack_require__(522); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { @@ -83064,7 +83184,7 @@ exports.default = EntryFilter; /***/ }), -/* 693 */ +/* 697 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83084,8 +83204,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var fsStat = __webpack_require__(694); -var fs_1 = __webpack_require__(698); +var fsStat = __webpack_require__(698); +var fs_1 = __webpack_require__(702); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -83135,14 +83255,14 @@ exports.default = FileSystemStream; /***/ }), -/* 694 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(695); -const statProvider = __webpack_require__(697); +const optionsManager = __webpack_require__(699); +const statProvider = __webpack_require__(701); /** * Asynchronous API. */ @@ -83173,13 +83293,13 @@ exports.statSync = statSync; /***/ }), -/* 695 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(696); +const fsAdapter = __webpack_require__(700); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -83192,7 +83312,7 @@ exports.prepare = prepare; /***/ }), -/* 696 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83215,7 +83335,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 697 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83267,7 +83387,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 698 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83298,7 +83418,7 @@ exports.default = FileSystem; /***/ }), -/* 699 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83318,9 +83438,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var readdir = __webpack_require__(676); -var reader_1 = __webpack_require__(689); -var fs_stream_1 = __webpack_require__(693); +var readdir = __webpack_require__(680); +var reader_1 = __webpack_require__(693); +var fs_stream_1 = __webpack_require__(697); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -83388,7 +83508,7 @@ exports.default = ReaderStream; /***/ }), -/* 700 */ +/* 704 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83407,9 +83527,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(676); -var reader_1 = __webpack_require__(689); -var fs_sync_1 = __webpack_require__(701); +var readdir = __webpack_require__(680); +var reader_1 = __webpack_require__(693); +var fs_sync_1 = __webpack_require__(705); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -83469,7 +83589,7 @@ exports.default = ReaderSync; /***/ }), -/* 701 */ +/* 705 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83488,8 +83608,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(694); -var fs_1 = __webpack_require__(698); +var fsStat = __webpack_require__(698); +var fs_1 = __webpack_require__(702); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -83535,7 +83655,7 @@ exports.default = FileSystemSync; /***/ }), -/* 702 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83551,7 +83671,7 @@ exports.flatten = flatten; /***/ }), -/* 703 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83572,13 +83692,13 @@ exports.merge = merge; /***/ }), -/* 704 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const pathType = __webpack_require__(705); +const pathType = __webpack_require__(709); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -83644,13 +83764,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 705 */ +/* 709 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const pify = __webpack_require__(706); +const pify = __webpack_require__(710); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -83693,7 +83813,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 706 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83784,7 +83904,7 @@ module.exports = (obj, opts) => { /***/ }), -/* 707 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83792,9 +83912,9 @@ module.exports = (obj, opts) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); const fastGlob = __webpack_require__(518); -const gitIgnore = __webpack_require__(708); -const pify = __webpack_require__(709); -const slash = __webpack_require__(710); +const gitIgnore = __webpack_require__(712); +const pify = __webpack_require__(713); +const slash = __webpack_require__(714); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -83892,7 +84012,7 @@ module.exports.sync = options => { /***/ }), -/* 708 */ +/* 712 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -84361,7 +84481,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 709 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84436,7 +84556,7 @@ module.exports = (input, options) => { /***/ }), -/* 710 */ +/* 714 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84454,7 +84574,7 @@ module.exports = input => { /***/ }), -/* 711 */ +/* 715 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84467,7 +84587,7 @@ module.exports = input => { -var isGlob = __webpack_require__(712); +var isGlob = __webpack_require__(716); module.exports = function hasGlob(val) { if (val == null) return false; @@ -84487,7 +84607,7 @@ module.exports = function hasGlob(val) { /***/ }), -/* 712 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -84518,17 +84638,17 @@ module.exports = function isGlob(str) { /***/ }), -/* 713 */ +/* 717 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); const {constants: fsConstants} = __webpack_require__(134); -const pEvent = __webpack_require__(714); -const CpFileError = __webpack_require__(717); -const fs = __webpack_require__(719); -const ProgressEmitter = __webpack_require__(722); +const pEvent = __webpack_require__(718); +const CpFileError = __webpack_require__(721); +const fs = __webpack_require__(723); +const ProgressEmitter = __webpack_require__(726); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -84642,12 +84762,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 714 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(715); +const pTimeout = __webpack_require__(719); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -84938,12 +85058,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 715 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(716); +const pFinally = __webpack_require__(720); class TimeoutError extends Error { constructor(message) { @@ -84989,7 +85109,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 716 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85011,12 +85131,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 717 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(718); +const NestedError = __webpack_require__(722); class CpFileError extends NestedError { constructor(message, nested) { @@ -85030,7 +85150,7 @@ module.exports = CpFileError; /***/ }), -/* 718 */ +/* 722 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(112).inherits; @@ -85086,16 +85206,16 @@ module.exports = NestedError; /***/ }), -/* 719 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(112); const fs = __webpack_require__(133); -const makeDir = __webpack_require__(720); -const pEvent = __webpack_require__(714); -const CpFileError = __webpack_require__(717); +const makeDir = __webpack_require__(724); +const pEvent = __webpack_require__(718); +const CpFileError = __webpack_require__(721); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -85192,7 +85312,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 720 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85200,7 +85320,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); const {promisify} = __webpack_require__(112); -const semver = __webpack_require__(721); +const semver = __webpack_require__(725); const useNativeRecursiveOption = semver.satisfies(process.version, '>=10.12.0'); @@ -85355,7 +85475,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 721 */ +/* 725 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -86957,7 +87077,7 @@ function coerce (version, options) { /***/ }), -/* 722 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86998,7 +87118,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 723 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87044,12 +87164,12 @@ exports.default = module.exports; /***/ }), -/* 724 */ +/* 728 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(725); +const pMap = __webpack_require__(729); const pFilter = async (iterable, filterer, options) => { const values = await pMap( @@ -87066,7 +87186,7 @@ module.exports.default = pFilter; /***/ }), -/* 725 */ +/* 729 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87145,12 +87265,12 @@ module.exports.default = pMap; /***/ }), -/* 726 */ +/* 730 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(718); +const NestedError = __webpack_require__(722); class CpyError extends NestedError { constructor(message, nested) { diff --git a/yarn.lock b/yarn.lock index 2f6bb14d79e3a..34abc2cd39593 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7433,11 +7433,6 @@ async-done@^1.2.0, async-done@^1.2.2: process-nextick-args "^2.0.0" stream-exhaust "^1.0.1" -async-each@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" - integrity sha1-GdOGodntxufByF04iu28xW0zYC0= - async-foreach@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" @@ -8258,11 +8253,6 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== -binary-extensions@^1.0.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" - integrity sha1-RqoXUftqL5PuXmibsQh9SxTGwgU= - binary-extensions@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" @@ -8273,13 +8263,6 @@ binary-search@^1.3.3: resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.6.tgz#e32426016a0c5092f0f3598836a1c7da3560565c" integrity sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA== -bindings@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" - integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== - dependencies: - file-uri-to-path "1.0.0" - bl@^4.0.1, bl@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" @@ -8426,7 +8409,7 @@ brace@0.11.1, brace@^0.11.0, brace@^0.11.1: resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58" integrity sha1-SJb8ydVE7vRfS7dmDbMg07N5/lg= -braces@^2.3.1, braces@^2.3.2: +braces@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== @@ -9341,63 +9324,10 @@ cheerio@^1.0.0-rc.3: lodash "^4.15.0" parse5 "^3.0.1" -chokidar@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.2.tgz#9c23ea40b01638439e0513864d362aeacc5ad058" - integrity sha512-IwXUx0FXc5ibYmPC2XeEj5mpXoV66sR+t3jqu2NS2GYwCktt3KF1/Qqjws/NkegajBA4RbZ5+DDwlOiJsxDHEg== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.1" - braces "^2.3.2" - glob-parent "^3.1.0" - inherits "^2.0.3" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - normalize-path "^3.0.0" - path-is-absolute "^1.0.0" - readdirp "^2.2.1" - upath "^1.1.0" - optionalDependencies: - fsevents "^1.2.7" - -chokidar@3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" - integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== - dependencies: - anymatch "~3.1.1" - braces "~3.0.2" - glob-parent "~5.1.0" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.2.0" - optionalDependencies: - fsevents "~2.1.1" - -chokidar@^2.0.0, chokidar@^2.0.4, chokidar@^2.1.1, chokidar@^2.1.2, chokidar@^2.1.8: - version "2.1.8" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" - integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.1" - braces "^2.3.2" - glob-parent "^3.1.0" - inherits "^2.0.3" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - normalize-path "^3.0.0" - path-is-absolute "^1.0.0" - readdirp "^2.2.1" - upath "^1.1.1" - optionalDependencies: - fsevents "^1.2.7" - -chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.4.1, chokidar@^3.4.2: - version "3.4.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d" - integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A== +chokidar@2.1.2, chokidar@3.3.0, chokidar@^2.0.0, chokidar@^2.0.4, chokidar@^2.1.1, chokidar@^2.1.2, chokidar@^2.1.8, chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.4.1, chokidar@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" + integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ== dependencies: anymatch "~3.1.1" braces "~3.0.2" @@ -9405,7 +9335,7 @@ chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.4.1, chokidar@^3.4.2: is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.4.0" + readdirp "~3.5.0" optionalDependencies: fsevents "~2.1.2" @@ -13636,11 +13566,6 @@ file-type@^9.0.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18" integrity sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw== -file-uri-to-path@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" - integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== - filelist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.1.tgz#f10d1a3ae86c1694808e8f20906f43d4c9132dbb" @@ -14208,15 +14133,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@^1.2.7: - version "1.2.12" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.12.tgz#db7e0d8ec3b0b45724fd4d83d43554a8f1f0de5c" - integrity sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q== - dependencies: - bindings "^1.5.0" - nan "^2.12.1" - -fsevents@^2.1.2, fsevents@~2.1.1, fsevents@~2.1.2: +fsevents@^2.1.2, fsevents@~2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== @@ -16552,13 +16469,6 @@ is-bigint@^1.0.0: resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.0.tgz#73da8c33208d00f130e9b5e15d23eac9215601c4" integrity sha512-t5mGUXC/xRheCK431ylNiSkGGpBp8bHENBcENTkDT6ppwPzEVxNGZRvgvmOEfbWkFhA7D2GEuE2mmQTr78sl2g== -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= - dependencies: - binary-extensions "^1.0.0" - is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -20387,7 +20297,7 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.12.1, nan@^2.13.2, nan@^2.14.0, nan@^2.14.1: +nan@^2.13.2, nan@^2.14.0, nan@^2.14.1: version "2.14.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -23753,26 +23663,10 @@ readdir-scoped-modules@^1.0.0: graceful-fs "^4.1.2" once "^1.3.0" -readdirp@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" - integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== - dependencies: - graceful-fs "^4.1.11" - micromatch "^3.1.10" - readable-stream "^2.0.2" - -readdirp@~3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" - integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== - dependencies: - picomatch "^2.0.4" - -readdirp@~3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" - integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== dependencies: picomatch "^2.2.1" @@ -28001,11 +27895,6 @@ unzip-response@^1.0.0: resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe" integrity sha1-uYTwh3/AqJwsdzzB73tbIytbBv4= -upath@^1.1.0, upath@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" - integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== - update-notifier@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-0.5.0.tgz#07b5dc2066b3627ab3b4f530130f7eddda07a4cc" From 8b5c68ab633973c0e75aba904e3ed3911fbb679f Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 9 Dec 2020 21:38:41 +0200 Subject: [PATCH 39/52] [Alerts] Hide case connector (#85398) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../action_type_menu.tsx | 14 +++++++++++- .../components/actions_connectors_list.tsx | 22 ++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 7cd95c92b22a3..3264f22bb928f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -12,6 +12,7 @@ import { loadActionTypes } from '../../lib/action_connector_api'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { useKibana } from '../../../common/lib/kibana'; +import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../..'; interface Props { onActionTypeChange: (actionType: ActionType) => void; @@ -35,7 +36,18 @@ export const ActionTypeMenu = ({ useEffect(() => { (async () => { try { - const availableActionTypes = actionTypes ?? (await loadActionTypes({ http })); + /** + * Hidden action types will be hidden only on Alerts & Actions. + * actionTypes prop is not filtered. Thus, any consumer that provides it's own actionTypes + * can use the hidden action types. For example, Cases or Detections of Security Solution. + * + * TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. + * */ + const availableActionTypes = + actionTypes ?? + (await loadActionTypes({ http })).filter( + (actionType) => !DEFAULT_HIDDEN_ACTION_TYPES.includes(actionType.id) + ); const index: ActionTypeIndex = {}; for (const actionTypeItem of availableActionTypes) { index[actionTypeItem.id] = actionTypeItem; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index fed888b40ad86..2df75436f5f96 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -39,6 +39,7 @@ import './actions_connectors_list.scss'; import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt'; import { useKibana } from '../../../../common/lib/kibana'; +import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../'; export const ActionsConnectorsList: React.FunctionComponent = () => { const { @@ -94,18 +95,23 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { }, []); const actionConnectorTableItems: ActionConnectorTableItem[] = actionTypesIndex - ? actions.map((action) => { - return { - ...action, - actionType: actionTypesIndex[action.actionTypeId] - ? actionTypesIndex[action.actionTypeId].name - : action.actionTypeId, - }; - }) + ? actions + // TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. + .filter((action) => !DEFAULT_HIDDEN_ACTION_TYPES.includes(action.actionTypeId)) + .map((action) => { + return { + ...action, + actionType: actionTypesIndex[action.actionTypeId] + ? actionTypesIndex[action.actionTypeId].name + : action.actionTypeId, + }; + }) : []; const actionTypesList: Array<{ value: string; name: string }> = actionTypesIndex ? Object.values(actionTypesIndex) + // TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. + .filter((actionType) => !DEFAULT_HIDDEN_ACTION_TYPES.includes(actionType.id)) .map((actionType) => ({ value: actionType.id, name: `${actionType.name} (${getActionsCountByActionType(actions, actionType.id)})`, From 88e61a6651a7ead317936277c8058a8826a1df98 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 9 Dec 2020 20:43:24 +0100 Subject: [PATCH 40/52] Migrate API keys functionality to a new Elasticsearch client. (#85029) --- .../server/alerts_client_factory.test.ts | 16 +- .../alerts/server/alerts_client_factory.ts | 22 +- .../invalidate_pending_api_keys/task.ts | 40 +- x-pack/plugins/alerts/server/plugin.ts | 7 +- .../plugins/fleet/server/errors/handlers.ts | 5 + x-pack/plugins/fleet/server/errors/index.ts | 1 + x-pack/plugins/fleet/server/mocks.ts | 2 +- x-pack/plugins/fleet/server/plugin.ts | 24 +- .../fleet/server/routes/setup/handlers.ts | 4 +- .../server/services/api_keys/security.ts | 10 +- .../fleet/server/services/app_context.ts | 4 +- .../plugins/lists/server/get_space_id.test.ts | 4 +- x-pack/plugins/lists/server/get_space_id.ts | 4 +- x-pack/plugins/lists/server/get_user.test.ts | 12 +- x-pack/plugins/lists/server/get_user.ts | 4 +- x-pack/plugins/lists/server/plugin.ts | 26 +- .../lists/server/routes/init_routes.ts | 9 +- .../server/routes/read_privileges_route.ts | 8 +- x-pack/plugins/lists/server/types.ts | 10 +- .../authentication/api_keys/api_keys.mock.ts | 18 + .../{ => api_keys}/api_keys.test.ts | 237 +++++----- .../authentication/{ => api_keys}/api_keys.ts | 69 +-- .../server/authentication/api_keys/index.ts | 14 + .../authentication_service.mock.ts | 26 ++ .../authentication_service.test.ts | 366 +++++++++++++++ .../authentication/authentication_service.ts | 205 ++++++++ .../authentication/authenticator.test.ts | 39 +- .../server/authentication/authenticator.ts | 8 - .../server/authentication/index.mock.ts | 23 - .../server/authentication/index.test.ts | 442 ------------------ .../security/server/authentication/index.ts | 166 +------ .../elasticsearch_client_plugin.ts | 57 --- x-pack/plugins/security/server/index.ts | 26 +- x-pack/plugins/security/server/mocks.ts | 37 +- x-pack/plugins/security/server/plugin.test.ts | 80 +++- x-pack/plugins/security/server/plugin.ts | 108 +++-- .../server/routes/api_keys/enabled.test.ts | 15 +- .../server/routes/api_keys/enabled.ts | 7 +- .../server/routes/api_keys/privileges.test.ts | 5 +- .../server/routes/api_keys/privileges.ts | 7 +- .../routes/authentication/common.test.ts | 11 +- .../server/routes/authentication/common.ts | 10 +- .../server/routes/authentication/index.ts | 4 +- .../server/routes/authentication/oidc.ts | 4 +- .../server/routes/authentication/saml.test.ts | 11 +- .../server/routes/authentication/saml.ts | 8 +- .../security/server/routes/index.mock.ts | 8 +- .../plugins/security/server/routes/index.ts | 18 +- .../routes/users/change_password.test.ts | 8 +- .../server/routes/users/change_password.ts | 10 +- .../server/routes/views/index.test.ts | 24 +- .../security/server/routes/views/index.ts | 3 +- .../routes/__mocks__/request_responses.ts | 3 +- .../privileges/read_privileges_route.test.ts | 54 +-- .../privileges/read_privileges_route.ts | 9 +- .../security_solution/server/routes/index.ts | 2 +- .../fixtures/plugins/alerts/server/plugin.ts | 15 +- .../fixtures/plugins/alerts/server/routes.ts | 14 +- 58 files changed, 1225 insertions(+), 1158 deletions(-) create mode 100644 x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts rename x-pack/plugins/security/server/authentication/{ => api_keys}/api_keys.test.ts (58%) rename x-pack/plugins/security/server/authentication/{ => api_keys}/api_keys.ts (84%) create mode 100644 x-pack/plugins/security/server/authentication/api_keys/index.ts create mode 100644 x-pack/plugins/security/server/authentication/authentication_service.mock.ts create mode 100644 x-pack/plugins/security/server/authentication/authentication_service.test.ts create mode 100644 x-pack/plugins/security/server/authentication/authentication_service.ts delete mode 100644 x-pack/plugins/security/server/authentication/index.mock.ts delete mode 100644 x-pack/plugins/security/server/authentication/index.test.ts diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts index 49a90c62bc581..93a479eeef487 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts @@ -33,6 +33,7 @@ const savedObjectsService = savedObjectsServiceMock.createInternalStartContract( const features = featuresPluginMock.createStart(); const securityPluginSetup = securityMock.createSetup(); +const securityPluginStart = securityMock.createStart(); const alertsClientFactoryParams: jest.Mocked = { logger: loggingSystemMock.create().get(), taskManager: taskManagerMock.createStart(), @@ -77,7 +78,7 @@ beforeEach(() => { test('creates an alerts client with proper constructor arguments when security is enabled', async () => { const factory = new AlertsClientFactory(); - factory.initialize({ securityPluginSetup, ...alertsClientFactoryParams }); + factory.initialize({ securityPluginSetup, securityPluginStart, ...alertsClientFactoryParams }); const request = KibanaRequest.from(fakeRequest); const { AlertsAuthorizationAuditLogger } = jest.requireMock('./authorization/audit_logger'); @@ -98,7 +99,7 @@ test('creates an alerts client with proper constructor arguments when security i const { AlertsAuthorization } = jest.requireMock('./authorization/alerts_authorization'); expect(AlertsAuthorization).toHaveBeenCalledWith({ request, - authorization: securityPluginSetup.authz, + authorization: securityPluginStart.authz, alertTypeRegistry: alertsClientFactoryParams.alertTypeRegistry, features: alertsClientFactoryParams.features, auditLogger: expect.any(AlertsAuthorizationAuditLogger), @@ -188,11 +189,12 @@ test('getUserName() returns a name when security is enabled', async () => { factory.initialize({ ...alertsClientFactoryParams, securityPluginSetup, + securityPluginStart, }); factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; - securityPluginSetup.authc.getCurrentUser.mockReturnValueOnce(({ + securityPluginStart.authc.getCurrentUser.mockReturnValueOnce(({ username: 'bob', } as unknown) as AuthenticatedUser); const userNameResult = await constructorCall.getUserName(); @@ -225,7 +227,7 @@ test('createAPIKey() returns { apiKeysEnabled: false } when security is enabled factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; - securityPluginSetup.authc.grantAPIKeyAsInternalUser.mockResolvedValueOnce(null); + securityPluginStart.authc.apiKeys.grantAsInternalUser.mockResolvedValueOnce(null); const createAPIKeyResult = await constructorCall.createAPIKey(); expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); }); @@ -235,11 +237,12 @@ test('createAPIKey() returns an API key when security is enabled', async () => { factory.initialize({ ...alertsClientFactoryParams, securityPluginSetup, + securityPluginStart, }); factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; - securityPluginSetup.authc.grantAPIKeyAsInternalUser.mockResolvedValueOnce({ + securityPluginStart.authc.apiKeys.grantAsInternalUser.mockResolvedValueOnce({ api_key: '123', id: 'abc', name: '', @@ -256,11 +259,12 @@ test('createAPIKey() throws when security plugin createAPIKey throws an error', factory.initialize({ ...alertsClientFactoryParams, securityPluginSetup, + securityPluginStart, }); factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; - securityPluginSetup.authc.grantAPIKeyAsInternalUser.mockRejectedValueOnce( + securityPluginStart.authc.apiKeys.grantAsInternalUser.mockRejectedValueOnce( new Error('TLS disabled') ); await expect(constructorCall.createAPIKey()).rejects.toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts index 9d71b5f817b2c..86091c89b6031 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts @@ -14,7 +14,7 @@ import { PluginStartContract as ActionsPluginStartContract } from '../../actions import { AlertsClient } from './alerts_client'; import { ALERTS_FEATURE_ID } from '../common'; import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types'; -import { SecurityPluginSetup } from '../../security/server'; +import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; import { EncryptedSavedObjectsClient } from '../../encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../task_manager/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; @@ -28,6 +28,7 @@ export interface AlertsClientFactoryOpts { taskManager: TaskManagerStartContract; alertTypeRegistry: AlertTypeRegistry; securityPluginSetup?: SecurityPluginSetup; + securityPluginStart?: SecurityPluginStart; getSpaceId: (request: KibanaRequest) => string | undefined; getSpace: (request: KibanaRequest) => Promise; spaceIdToNamespace: SpaceIdToNamespaceFunction; @@ -44,6 +45,7 @@ export class AlertsClientFactory { private taskManager!: TaskManagerStartContract; private alertTypeRegistry!: AlertTypeRegistry; private securityPluginSetup?: SecurityPluginSetup; + private securityPluginStart?: SecurityPluginStart; private getSpaceId!: (request: KibanaRequest) => string | undefined; private getSpace!: (request: KibanaRequest) => Promise; private spaceIdToNamespace!: SpaceIdToNamespaceFunction; @@ -64,6 +66,7 @@ export class AlertsClientFactory { this.taskManager = options.taskManager; this.alertTypeRegistry = options.alertTypeRegistry; this.securityPluginSetup = options.securityPluginSetup; + this.securityPluginStart = options.securityPluginStart; this.spaceIdToNamespace = options.spaceIdToNamespace; this.encryptedSavedObjectsClient = options.encryptedSavedObjectsClient; this.actions = options.actions; @@ -73,10 +76,10 @@ export class AlertsClientFactory { } public create(request: KibanaRequest, savedObjects: SavedObjectsServiceStart): AlertsClient { - const { securityPluginSetup, actions, eventLog, features } = this; + const { securityPluginSetup, securityPluginStart, actions, eventLog, features } = this; const spaceId = this.getSpaceId(request); const authorization = new AlertsAuthorization({ - authorization: securityPluginSetup?.authz, + authorization: securityPluginStart?.authz, request, getSpace: this.getSpace, alertTypeRegistry: this.alertTypeRegistry, @@ -102,25 +105,22 @@ export class AlertsClientFactory { encryptedSavedObjectsClient: this.encryptedSavedObjectsClient, auditLogger: securityPluginSetup?.audit.asScoped(request), async getUserName() { - if (!securityPluginSetup) { + if (!securityPluginStart) { return null; } - const user = await securityPluginSetup.authc.getCurrentUser(request); + const user = await securityPluginStart.authc.getCurrentUser(request); return user ? user.username : null; }, async createAPIKey(name: string) { - if (!securityPluginSetup) { + if (!securityPluginStart) { return { apiKeysEnabled: false }; } // Create an API key using the new grant API - in this case the Kibana system user is creating the // API key for the user, instead of having the user create it themselves, which requires api_key // privileges - const createAPIKeyResult = await securityPluginSetup.authc.grantAPIKeyAsInternalUser( + const createAPIKeyResult = await securityPluginStart.authc.apiKeys.grantAsInternalUser( request, - { - name, - role_descriptors: {}, - } + { name, role_descriptors: {} } ); if (!createAPIKeyResult) { return { apiKeysEnabled: false }; diff --git a/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts b/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts index 119c3b697fd2e..91c3f5954d6d0 100644 --- a/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts +++ b/x-pack/plugins/alerts/server/invalidate_pending_api_keys/task.ts @@ -12,7 +12,7 @@ import { SavedObjectsClientContract, } from 'kibana/server'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; -import { InvalidateAPIKeyParams, SecurityPluginSetup } from '../../../security/server'; +import { InvalidateAPIKeyParams, SecurityPluginStart } from '../../../security/server'; import { RunContext, TaskManagerSetupContract, @@ -29,12 +29,12 @@ export const TASK_ID = `Alerts-${TASK_TYPE}`; const invalidateAPIKey = async ( params: InvalidateAPIKeyParams, - securityPluginSetup?: SecurityPluginSetup + securityPluginStart?: SecurityPluginStart ): Promise => { - if (!securityPluginSetup) { + if (!securityPluginStart) { return { apiKeysEnabled: false }; } - const invalidateAPIKeyResult = await securityPluginSetup.authc.invalidateAPIKeyAsInternalUser( + const invalidateAPIKeyResult = await securityPluginStart.authc.apiKeys.invalidateAsInternalUser( params ); // Null when Elasticsearch security is disabled @@ -51,16 +51,9 @@ export function initializeApiKeyInvalidator( logger: Logger, coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>, taskManager: TaskManagerSetupContract, - config: Promise, - securityPluginSetup?: SecurityPluginSetup + config: Promise ) { - registerApiKeyInvalitorTaskDefinition( - logger, - coreStartServices, - taskManager, - config, - securityPluginSetup - ); + registerApiKeyInvalidatorTaskDefinition(logger, coreStartServices, taskManager, config); } export async function scheduleApiKeyInvalidatorTask( @@ -84,17 +77,16 @@ export async function scheduleApiKeyInvalidatorTask( } } -function registerApiKeyInvalitorTaskDefinition( +function registerApiKeyInvalidatorTaskDefinition( logger: Logger, coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>, taskManager: TaskManagerSetupContract, - config: Promise, - securityPluginSetup?: SecurityPluginSetup + config: Promise ) { taskManager.registerTaskDefinitions({ [TASK_TYPE]: { title: 'Invalidate alert API Keys', - createTaskRunner: taskRunner(logger, coreStartServices, config, securityPluginSetup), + createTaskRunner: taskRunner(logger, coreStartServices, config), }, }); } @@ -120,8 +112,7 @@ function getFakeKibanaRequest(basePath: string) { function taskRunner( logger: Logger, coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>, - config: Promise, - securityPluginSetup?: SecurityPluginSetup + config: Promise ) { return ({ taskInstance }: RunContext) => { const { state } = taskInstance; @@ -130,7 +121,10 @@ function taskRunner( let totalInvalidated = 0; const configResult = await config; try { - const [{ savedObjects, http }, { encryptedSavedObjects }] = await coreStartServices; + const [ + { savedObjects, http }, + { encryptedSavedObjects, security }, + ] = await coreStartServices; const savedObjectsClient = savedObjects.getScopedClient( getFakeKibanaRequest(http.basePath.serverBasePath), { @@ -160,7 +154,7 @@ function taskRunner( savedObjectsClient, apiKeysToInvalidate, encryptedSavedObjectsClient, - securityPluginSetup + security ); hasApiKeysPendingInvalidation = apiKeysToInvalidate.total > PAGE_SIZE; @@ -197,7 +191,7 @@ async function invalidateApiKeys( savedObjectsClient: SavedObjectsClientContract, apiKeysToInvalidate: SavedObjectsFindResponse, encryptedSavedObjectsClient: EncryptedSavedObjectsClient, - securityPluginSetup?: SecurityPluginSetup + securityPluginStart?: SecurityPluginStart ) { let totalInvalidated = 0; await Promise.all( @@ -207,7 +201,7 @@ async function invalidateApiKeys( apiKeyObj.id ); const apiKeyId = decryptedApiKey.attributes.apiKeyId; - const response = await invalidateAPIKey({ id: apiKeyId }, securityPluginSetup); + const response = await invalidateAPIKey({ id: apiKeyId }, securityPluginStart); if (response.apiKeysEnabled === true && response.result.error_count > 0) { logger.error(`Failed to invalidate API Key [id="${apiKeyObj.attributes.apiKeyId}"]`); } else { diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index bafb89c64076b..e526c65b90102 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -8,7 +8,7 @@ import { first, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { combineLatest } from 'rxjs'; -import { SecurityPluginSetup } from '../../security/server'; +import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; import { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, @@ -115,6 +115,7 @@ export interface AlertingPluginsStart { features: FeaturesPluginStart; eventLog: IEventLogClientService; spaces?: SpacesPluginStart; + security?: SecurityPluginStart; } export class AlertingPlugin { @@ -203,8 +204,7 @@ export class AlertingPlugin { this.logger, core.getStartServices(), plugins.taskManager, - this.config, - this.security + this.config ); core.getStartServices().then(async ([, startPlugins]) => { @@ -279,6 +279,7 @@ export class AlertingPlugin { logger, taskManager: plugins.taskManager, securityPluginSetup: security, + securityPluginStart: plugins.security, encryptedSavedObjectsClient, spaceIdToNamespace, getSpaceId(request: KibanaRequest) { diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index 08372571240ff..222554e97eb91 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -12,6 +12,7 @@ import { KibanaResponseFactory, } from 'src/core/server'; import { errors as LegacyESErrors } from 'elasticsearch'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { appContextService } from '../services'; import { IngestManagerError, @@ -51,6 +52,10 @@ export const isLegacyESClientError = (error: any): error is LegacyESClientError return error instanceof LegacyESErrors._Abstract; }; +export function isESClientError(error: unknown): error is ResponseError { + return error instanceof ResponseError; +} + const getHTTPResponseCode = (error: IngestManagerError): number => { if (error instanceof RegistryError) { return 502; // Bad Gateway diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index d6fa79a2baeba..fad4eef66215d 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -9,6 +9,7 @@ export { defaultIngestErrorHandler, ingestErrorToResponseOptions, isLegacyESClientError, + isESClientError, } from './handlers'; export class IngestManagerError extends Error { diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index bc3e89ef6d3ce..9e2c71ead5b74 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -15,7 +15,7 @@ export const createAppContextStartContractMock = (): FleetAppContext => { return { encryptedSavedObjectsStart: encryptedSavedObjectsMock.createStart(), savedObjects: savedObjectsServiceMock.createStartContract(), - security: securityMock.createSetup(), + security: securityMock.createStart(), logger: loggingSystemMock.create().get(), isProductionMode: true, kibanaVersion: '8.0.0', diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 7ddd6e3ba3fe0..0b58c4aab9d0b 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -24,7 +24,7 @@ import { EncryptedSavedObjectsPluginStart, EncryptedSavedObjectsPluginSetup, } from '../../encrypted_saved_objects/server'; -import { SecurityPluginSetup } from '../../security/server'; +import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { PLUGIN_ID, @@ -85,12 +85,15 @@ export interface FleetSetupDeps { usageCollection?: UsageCollectionSetup; } -export type FleetStartDeps = object; +export interface FleetStartDeps { + encryptedSavedObjects: EncryptedSavedObjectsPluginStart; + security?: SecurityPluginStart; +} export interface FleetAppContext { encryptedSavedObjectsStart: EncryptedSavedObjectsPluginStart; encryptedSavedObjectsSetup?: EncryptedSavedObjectsPluginSetup; - security?: SecurityPluginSetup; + security?: SecurityPluginStart; config$?: Observable; savedObjects: SavedObjectsServiceStart; isProductionMode: PluginInitializerContext['env']['mode']['prod']; @@ -150,7 +153,6 @@ export class FleetPlugin implements Plugin { private licensing$!: Observable; private config$: Observable; - private security: SecurityPluginSetup | undefined; private cloud: CloudSetup | undefined; private logger: Logger | undefined; @@ -171,9 +173,6 @@ export class FleetPlugin public async setup(core: CoreSetup, deps: FleetSetupDeps) { this.httpSetup = core.http; this.licensing$ = deps.licensing.license$; - if (deps.security) { - this.security = deps.security; - } this.encryptedSavedObjectsSetup = deps.encryptedSavedObjects; this.cloud = deps.cloud; @@ -226,7 +225,7 @@ export class FleetPlugin // For all the routes we enforce the user to have role superuser const routerSuperuserOnly = makeRouterEnforcingSuperuser(router); // Register rest of routes only if security is enabled - if (this.security) { + if (deps.security) { registerSetupRoutes(routerSuperuserOnly, config); registerAgentPolicyRoutes(routerSuperuserOnly); registerPackagePolicyRoutes(routerSuperuserOnly); @@ -262,16 +261,11 @@ export class FleetPlugin } } - public async start( - core: CoreStart, - plugins: { - encryptedSavedObjects: EncryptedSavedObjectsPluginStart; - } - ): Promise { + public async start(core: CoreStart, plugins: FleetStartDeps): Promise { await appContextService.start({ encryptedSavedObjectsStart: plugins.encryptedSavedObjects, encryptedSavedObjectsSetup: this.encryptedSavedObjectsSetup, - security: this.security, + security: plugins.security, config$: this.config$, savedObjects: core.savedObjects, isProductionMode: this.isProductionMode, diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index b2ad9591bc2ee..f87cf8026c560 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -15,7 +15,9 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re const soClient = context.core.savedObjects.client; try { const isAdminUserSetup = (await outputService.getAdminUser(soClient)) !== null; - const isApiKeysEnabled = await appContextService.getSecurity().authc.areAPIKeysEnabled(); + const isApiKeysEnabled = await appContextService + .getSecurity() + .authc.apiKeys.areAPIKeysEnabled(); const isTLSEnabled = appContextService.getHttpSetup().getServerInfo().protocol === 'https'; const isProductionMode = appContextService.getIsProductionMode(); const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false; diff --git a/x-pack/plugins/fleet/server/services/api_keys/security.ts b/x-pack/plugins/fleet/server/services/api_keys/security.ts index 5fdf8626a9fb2..9a32da3cff46f 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/security.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/security.ts @@ -6,7 +6,7 @@ import type { Request } from '@hapi/hapi'; import { KibanaRequest, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { FleetAdminUserInvalidError, isLegacyESClientError } from '../../errors'; +import { FleetAdminUserInvalidError, isESClientError } from '../../errors'; import { CallESAsCurrentUser } from '../../types'; import { appContextService } from '../app_context'; import { outputService } from '../output'; @@ -37,14 +37,14 @@ export async function createAPIKey( } try { - const key = await security.authc.createAPIKey(request, { + const key = await security.authc.apiKeys.create(request, { name, role_descriptors: roleDescriptors, }); return key; } catch (err) { - if (isLegacyESClientError(err) && err.statusCode === 401) { + if (isESClientError(err) && err.statusCode === 401) { // Clear Fleet admin user cache as the user is probably not valid anymore outputService.invalidateCache(); throw new FleetAdminUserInvalidError(`Fleet Admin user is invalid: ${err.message}`); @@ -87,13 +87,13 @@ export async function invalidateAPIKey(soClient: SavedObjectsClientContract, id: } try { - const res = await security.authc.invalidateAPIKey(request, { + const res = await security.authc.apiKeys.invalidate(request, { id, }); return res; } catch (err) { - if (isLegacyESClientError(err) && err.statusCode === 401) { + if (isESClientError(err) && err.statusCode === 401) { // Clear Fleet admin user cache as the user is probably not valid anymore outputService.invalidateCache(); throw new FleetAdminUserInvalidError(`Fleet Admin user is invalid: ${err.message}`); diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index 5c4e33d50b480..bcf056c9482cb 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -11,7 +11,7 @@ import { EncryptedSavedObjectsPluginSetup, } from '../../../encrypted_saved_objects/server'; import packageJSON from '../../../../../package.json'; -import { SecurityPluginSetup } from '../../../security/server'; +import { SecurityPluginStart } from '../../../security/server'; import { FleetConfigType } from '../../common'; import { ExternalCallback, ExternalCallbacksStorage, FleetAppContext } from '../plugin'; import { CloudSetup } from '../../../cloud/server'; @@ -19,7 +19,7 @@ import { CloudSetup } from '../../../cloud/server'; class AppContextService { private encryptedSavedObjects: EncryptedSavedObjectsClient | undefined; private encryptedSavedObjectsSetup: EncryptedSavedObjectsPluginSetup | undefined; - private security: SecurityPluginSetup | undefined; + private security: SecurityPluginStart | undefined; private config$?: Observable; private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; diff --git a/x-pack/plugins/lists/server/get_space_id.test.ts b/x-pack/plugins/lists/server/get_space_id.test.ts index 9c1d11b71984d..c74154f9edc17 100644 --- a/x-pack/plugins/lists/server/get_space_id.test.ts +++ b/x-pack/plugins/lists/server/get_space_id.test.ts @@ -23,13 +23,13 @@ describe('get_space_id', () => { }); test('it returns "default" as the space id given a space id of "default"', () => { - const spaces = spacesServiceMock.createSetupContract(); + const spaces = spacesServiceMock.createStartContract(); const space = getSpaceId({ request, spaces }); expect(space).toEqual('default'); }); test('it returns "another-space" as the space id given a space id of "another-space"', () => { - const spaces = spacesServiceMock.createSetupContract('another-space'); + const spaces = spacesServiceMock.createStartContract('another-space'); const space = getSpaceId({ request, spaces }); expect(space).toEqual('another-space'); }); diff --git a/x-pack/plugins/lists/server/get_space_id.ts b/x-pack/plugins/lists/server/get_space_id.ts index f224e37e04467..24965a4fabd2a 100644 --- a/x-pack/plugins/lists/server/get_space_id.ts +++ b/x-pack/plugins/lists/server/get_space_id.ts @@ -6,12 +6,12 @@ import { KibanaRequest } from 'kibana/server'; -import { SpacesServiceSetup } from '../../spaces/server'; +import { SpacesServiceStart } from '../../spaces/server'; export const getSpaceId = ({ spaces, request, }: { - spaces: SpacesServiceSetup | undefined | null; + spaces: SpacesServiceStart | undefined | null; request: KibanaRequest; }): string => spaces?.getSpaceId(request) ?? 'default'; diff --git a/x-pack/plugins/lists/server/get_user.test.ts b/x-pack/plugins/lists/server/get_user.test.ts index a1c78f5ea4684..e098f7017ebec 100644 --- a/x-pack/plugins/lists/server/get_user.test.ts +++ b/x-pack/plugins/lists/server/get_user.test.ts @@ -23,42 +23,42 @@ describe('get_user', () => { }); test('it returns "bob" as the user given a security request with "bob"', () => { - const security = securityMock.createSetup(); + const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'bob' }); const user = getUser({ request, security }); expect(user).toEqual('bob'); }); test('it returns "alice" as the user given a security request with "alice"', () => { - const security = securityMock.createSetup(); + const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'alice' }); const user = getUser({ request, security }); expect(user).toEqual('alice'); }); test('it returns "elastic" as the user given null as the current user', () => { - const security = securityMock.createSetup(); + const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(null); const user = getUser({ request, security }); expect(user).toEqual('elastic'); }); test('it returns "elastic" as the user given undefined as the current user', () => { - const security = securityMock.createSetup(); + const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined); const user = getUser({ request, security }); expect(user).toEqual('elastic'); }); test('it returns "elastic" as the user given undefined as the plugin', () => { - const security = securityMock.createSetup(); + const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined); const user = getUser({ request, security: undefined }); expect(user).toEqual('elastic'); }); test('it returns "elastic" as the user given null as the plugin', () => { - const security = securityMock.createSetup(); + const security = securityMock.createStart(); security.authc.getCurrentUser = jest.fn().mockReturnValue(undefined); const user = getUser({ request, security: null }); expect(user).toEqual('elastic'); diff --git a/x-pack/plugins/lists/server/get_user.ts b/x-pack/plugins/lists/server/get_user.ts index 3b59853d0ab62..648a8d2804ee1 100644 --- a/x-pack/plugins/lists/server/get_user.ts +++ b/x-pack/plugins/lists/server/get_user.ts @@ -6,10 +6,10 @@ import { KibanaRequest } from 'kibana/server'; -import { SecurityPluginSetup } from '../../security/server'; +import { SecurityPluginStart } from '../../security/server'; export interface GetUserOptions { - security: SecurityPluginSetup | null | undefined; + security: SecurityPluginStart | null | undefined; request: KibanaRequest; } diff --git a/x-pack/plugins/lists/server/plugin.ts b/x-pack/plugins/lists/server/plugin.ts index 118bb2f927a64..670f0fe684cc2 100644 --- a/x-pack/plugins/lists/server/plugin.ts +++ b/x-pack/plugins/lists/server/plugin.ts @@ -6,20 +6,20 @@ import { first } from 'rxjs/operators'; import { Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { CoreSetup } from 'src/core/server'; +import type { CoreSetup, CoreStart } from 'src/core/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { SpacesServiceSetup } from '../../spaces/server'; +import type { SecurityPluginStart } from '../../security/server'; +import type { SpacesServiceStart } from '../../spaces/server'; import { ConfigType } from './config'; import { initRoutes } from './routes/init_routes'; import { ListClient } from './services/lists/list_client'; -import { +import type { ContextProvider, ContextProviderReturn, ListPluginSetup, ListsPluginStart, - PluginsSetup, + PluginsStart, } from './types'; import { createConfig$ } from './create_config'; import { getSpaceId } from './get_space_id'; @@ -28,27 +28,25 @@ import { initSavedObjects } from './saved_objects'; import { ExceptionListClient } from './services/exception_lists/exception_list_client'; export class ListPlugin - implements Plugin, ListsPluginStart, PluginsSetup> { + implements Plugin, ListsPluginStart, {}, PluginsStart> { private readonly logger: Logger; - private spaces: SpacesServiceSetup | undefined | null; + private spaces: SpacesServiceStart | undefined | null; private config: ConfigType | undefined | null; - private security: SecurityPluginSetup | undefined | null; + private security: SecurityPluginStart | undefined | null; constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } - public async setup(core: CoreSetup, plugins: PluginsSetup): Promise { + public async setup(core: CoreSetup): Promise { const config = await createConfig$(this.initializerContext).pipe(first()).toPromise(); - this.spaces = plugins.spaces?.spacesService; this.config = config; - this.security = plugins.security; initSavedObjects(core.savedObjects); core.http.registerRouteHandlerContext('lists', this.createRouteHandlerContext()); const router = core.http.createRouter(); - initRoutes(router, config, plugins.security); + initRoutes(router, config); return { getExceptionListClient: (savedObjectsClient, user): ExceptionListClient => { @@ -68,8 +66,10 @@ export class ListPlugin }; } - public start(): void { + public start(core: CoreStart, plugins: PluginsStart): void { this.logger.debug('Starting plugin'); + this.security = plugins.security; + this.spaces = plugins.spaces?.spacesService; } public stop(): void { diff --git a/x-pack/plugins/lists/server/routes/init_routes.ts b/x-pack/plugins/lists/server/routes/init_routes.ts index 7e9e956ebf094..163126f1277c1 100644 --- a/x-pack/plugins/lists/server/routes/init_routes.ts +++ b/x-pack/plugins/lists/server/routes/init_routes.ts @@ -6,7 +6,6 @@ import { IRouter } from 'kibana/server'; -import { SecurityPluginSetup } from '../../../security/server'; import { ConfigType } from '../config'; import { @@ -46,11 +45,7 @@ import { updateListRoute, } from '.'; -export const initRoutes = ( - router: IRouter, - config: ConfigType, - security: SecurityPluginSetup | null | undefined -): void => { +export const initRoutes = (router: IRouter, config: ConfigType): void => { // lists createListRoute(router); readListRoute(router); @@ -58,7 +53,7 @@ export const initRoutes = ( deleteListRoute(router); patchListRoute(router); findListRoute(router); - readPrivilegesRoute(router, security); + readPrivilegesRoute(router); // list items createListItemRoute(router); diff --git a/x-pack/plugins/lists/server/routes/read_privileges_route.ts b/x-pack/plugins/lists/server/routes/read_privileges_route.ts index 9d695b348b422..4a82f4c5e9cb2 100644 --- a/x-pack/plugins/lists/server/routes/read_privileges_route.ts +++ b/x-pack/plugins/lists/server/routes/read_privileges_route.ts @@ -7,16 +7,12 @@ import { IRouter } from 'kibana/server'; import { merge } from 'lodash/fp'; -import { SecurityPluginSetup } from '../../../security/server'; import { LIST_PRIVILEGES_URL } from '../../common/constants'; import { buildSiemResponse, readPrivileges, transformError } from '../siem_server_deps'; import { getListClient } from './utils'; -export const readPrivilegesRoute = ( - router: IRouter, - security: SecurityPluginSetup | null | undefined -): void => { +export const readPrivilegesRoute = (router: IRouter): void => { router.get( { options: { @@ -44,7 +40,7 @@ export const readPrivilegesRoute = ( lists: clusterPrivilegesLists, }, { - is_authenticated: security?.authc.isAuthenticated(request) ?? false, + is_authenticated: request.auth.isAuthenticated ?? false, } ); return response.ok({ body: privileges }); diff --git a/x-pack/plugins/lists/server/types.ts b/x-pack/plugins/lists/server/types.ts index 9ea7e7a2bc001..7d0a24ccddfd0 100644 --- a/x-pack/plugins/lists/server/types.ts +++ b/x-pack/plugins/lists/server/types.ts @@ -11,17 +11,17 @@ import { SavedObjectsClientContract, } from 'kibana/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { SpacesPluginSetup } from '../../spaces/server'; +import type { SecurityPluginStart } from '../../security/server'; +import type { SpacesPluginStart } from '../../spaces/server'; import { ListClient } from './services/lists/list_client'; import { ExceptionListClient } from './services/exception_lists/exception_list_client'; export type ContextProvider = IContextProvider, 'lists'>; export type ListsPluginStart = void; -export interface PluginsSetup { - security: SecurityPluginSetup | undefined | null; - spaces: SpacesPluginSetup | undefined | null; +export interface PluginsStart { + security: SecurityPluginStart | undefined | null; + spaces: SpacesPluginStart | undefined | null; } export type GetListClientType = ( diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts new file mode 100644 index 0000000000000..c6e3b7e38f8b5 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { APIKeys } from '.'; + +export const apiKeysMock = { + create: (): jest.Mocked> => ({ + areAPIKeysEnabled: jest.fn(), + create: jest.fn(), + grantAsInternalUser: jest.fn(), + invalidate: jest.fn(), + invalidateAsInternalUser: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts similarity index 58% rename from x-pack/plugins/security/server/authentication/api_keys.test.ts rename to x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts index 5164099f9ff67..1e5d3baab83e6 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts @@ -4,31 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyClusterClient, ILegacyScopedClusterClient } from '../../../../../src/core/server'; -import { SecurityLicense } from '../../common/licensing'; +import type { SecurityLicense } from '../../../common/licensing'; import { APIKeys } from './api_keys'; import { httpServerMock, loggingSystemMock, elasticsearchServiceMock, -} from '../../../../../src/core/server/mocks'; -import { licenseMock } from '../../common/licensing/index.mock'; +} from '../../../../../../src/core/server/mocks'; +import { licenseMock } from '../../../common/licensing/index.mock'; +import { securityMock } from '../../mocks'; const encodeToBase64 = (str: string) => Buffer.from(str).toString('base64'); describe('API Keys', () => { let apiKeys: APIKeys; - let mockClusterClient: jest.Mocked; - let mockScopedClusterClient: jest.Mocked; + let mockClusterClient: ReturnType; + let mockScopedClusterClient: ReturnType< + typeof elasticsearchServiceMock.createScopedClusterClient + >; let mockLicense: jest.Mocked; beforeEach(() => { - mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockClusterClient.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); mockLicense = licenseMock.create(); mockLicense.isEnabled.mockReturnValue(true); @@ -46,9 +46,10 @@ describe('API Keys', () => { const result = await apiKeys.areAPIKeysEnabled(); expect(result).toEqual(false); - expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); - expect(mockScopedClusterClient.callAsInternalUser).not.toHaveBeenCalled(); - expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).not.toHaveBeenCalled(); + expect( + mockScopedClusterClient.asCurrentUser.security.invalidateApiKey + ).not.toHaveBeenCalled(); }); it('returns false when the exception metadata indicates api keys are disabled', async () => { @@ -57,17 +58,19 @@ describe('API Keys', () => { (error as any).body = { error: { 'disabled.feature': 'api_keys' }, }; - mockClusterClient.callAsInternalUser.mockRejectedValue(error); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockRejectedValue(error); const result = await apiKeys.areAPIKeysEnabled(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledTimes(1); expect(result).toEqual(false); }); it('returns true when the operation completes without error', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockClusterClient.callAsInternalUser.mockResolvedValue({}); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockResolvedValue( + securityMock.createApiResponse({ body: {} }) + ); const result = await apiKeys.areAPIKeysEnabled(); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledTimes(1); expect(result).toEqual(true); }); @@ -78,9 +81,9 @@ describe('API Keys', () => { error: { 'disabled.feature': 'something_else' }, }; - mockClusterClient.callAsInternalUser.mockRejectedValue(error); - expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockRejectedValue(error); + await expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error); + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledTimes(1); }); it('throws the original error when exception metadata does not contain `disabled.feature`', async () => { @@ -88,27 +91,29 @@ describe('API Keys', () => { const error = new Error(); (error as any).body = {}; - mockClusterClient.callAsInternalUser.mockRejectedValue(error); - expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockRejectedValue(error); + await expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error); + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledTimes(1); }); it('throws the original error when exception contains no metadata', async () => { mockLicense.isEnabled.mockReturnValue(true); const error = new Error(); - mockClusterClient.callAsInternalUser.mockRejectedValue(error); - expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockRejectedValue(error); + await expect(apiKeys.areAPIKeysEnabled()).rejects.toThrowError(error); + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledTimes(1); }); - it('calls callCluster with proper parameters', async () => { + it('calls `invalidateApiKey` with proper parameters', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockClusterClient.callAsInternalUser.mockResolvedValueOnce({}); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ body: {} }) + ); const result = await apiKeys.areAPIKeysEnabled(); expect(result).toEqual(true); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledWith({ body: { id: 'kibana-api-key-service-test', }, @@ -124,17 +129,22 @@ describe('API Keys', () => { role_descriptors: {}, }); expect(result).toBeNull(); - expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.asCurrentUser.security.createApiKey).not.toHaveBeenCalled(); }); - it('calls callCluster with proper parameters', async () => { + it('calls `createApiKey` with proper parameters', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ - id: '123', - name: 'key-name', - expiration: '1d', - api_key: 'abc123', - }); + + mockScopedClusterClient.asCurrentUser.security.createApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + id: '123', + name: 'key-name', + expiration: '1d', + api_key: 'abc123', + }, + }) + ); const result = await apiKeys.create(httpServerMock.createKibanaRequest(), { name: 'key-name', role_descriptors: { foo: true }, @@ -146,16 +156,13 @@ describe('API Keys', () => { id: '123', name: 'key-name', }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.createAPIKey', - { - body: { - name: 'key-name', - role_descriptors: { foo: true }, - expiration: '1d', - }, - } - ); + expect(mockScopedClusterClient.asCurrentUser.security.createApiKey).toHaveBeenCalledWith({ + body: { + name: 'key-name', + role_descriptors: { foo: true }, + expiration: '1d', + }, + }); }); }); @@ -168,17 +175,21 @@ describe('API Keys', () => { }); expect(result).toBeNull(); - expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockClusterClient.asInternalUser.security.grantApiKey).not.toHaveBeenCalled(); }); - it('calls callAsInternalUser with proper parameters for the Basic scheme', async () => { + it('calls `grantApiKey` with proper parameters for the Basic scheme', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ - id: '123', - name: 'key-name', - api_key: 'abc123', - expires: '1d', - }); + mockClusterClient.asInternalUser.security.grantApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + id: '123', + name: 'key-name', + api_key: 'abc123', + expires: '1d', + }, + }) + ); const result = await apiKeys.grantAsInternalUser( httpServerMock.createKibanaRequest({ headers: { @@ -197,7 +208,7 @@ describe('API Keys', () => { name: 'key-name', expires: '1d', }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + expect(mockClusterClient.asInternalUser.security.grantApiKey).toHaveBeenCalledWith({ body: { api_key: { name: 'test_api_key', @@ -211,13 +222,17 @@ describe('API Keys', () => { }); }); - it('calls callAsInternalUser with proper parameters for the Bearer scheme', async () => { + it('calls `grantApiKey` with proper parameters for the Bearer scheme', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ - id: '123', - name: 'key-name', - api_key: 'abc123', - }); + mockClusterClient.asInternalUser.security.grantApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + id: '123', + name: 'key-name', + api_key: 'abc123', + }, + }) + ); const result = await apiKeys.grantAsInternalUser( httpServerMock.createKibanaRequest({ headers: { @@ -235,7 +250,7 @@ describe('API Keys', () => { id: '123', name: 'key-name', }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { + expect(mockClusterClient.asInternalUser.security.grantApiKey).toHaveBeenCalledWith({ body: { api_key: { name: 'test_api_key', @@ -266,7 +281,7 @@ describe('API Keys', () => { ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unsupported scheme \\"Digest\\" for granting API Key"` ); - expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockClusterClient.asInternalUser.security.grantApiKey).not.toHaveBeenCalled(); }); }); @@ -277,16 +292,22 @@ describe('API Keys', () => { id: '123', }); expect(result).toBeNull(); - expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + expect( + mockScopedClusterClient.asCurrentUser.security.invalidateApiKey + ).not.toHaveBeenCalled(); }); it('calls callCluster with proper parameters', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ - invalidated_api_keys: ['api-key-id-1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); + mockScopedClusterClient.asCurrentUser.security.invalidateApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }, + }) + ); const result = await apiKeys.invalidate(httpServerMock.createKibanaRequest(), { id: '123', }); @@ -295,23 +316,24 @@ describe('API Keys', () => { previously_invalidated_api_keys: [], error_count: 0, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.invalidateAPIKey', - { - body: { - id: '123', - }, - } - ); + expect(mockScopedClusterClient.asCurrentUser.security.invalidateApiKey).toHaveBeenCalledWith({ + body: { + id: '123', + }, + }); }); it(`Only passes id as a parameter`, async () => { mockLicense.isEnabled.mockReturnValue(true); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ - invalidated_api_keys: ['api-key-id-1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); + mockScopedClusterClient.asCurrentUser.security.invalidateApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }, + }) + ); const result = await apiKeys.invalidate(httpServerMock.createKibanaRequest(), { id: '123', name: 'abc', @@ -321,14 +343,11 @@ describe('API Keys', () => { previously_invalidated_api_keys: [], error_count: 0, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.invalidateAPIKey', - { - body: { - id: '123', - }, - } - ); + expect(mockScopedClusterClient.asCurrentUser.security.invalidateApiKey).toHaveBeenCalledWith({ + body: { + id: '123', + }, + }); }); }); @@ -337,23 +356,27 @@ describe('API Keys', () => { mockLicense.isEnabled.mockReturnValue(false); const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); expect(result).toBeNull(); - expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).not.toHaveBeenCalled(); }); it('calls callCluster with proper parameters', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ - invalidated_api_keys: ['api-key-id-1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }, + }) + ); const result = await apiKeys.invalidateAsInternalUser({ id: '123' }); expect(result).toEqual({ invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], error_count: 0, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledWith({ body: { id: '123', }, @@ -362,11 +385,15 @@ describe('API Keys', () => { it('Only passes id as a parameter', async () => { mockLicense.isEnabled.mockReturnValue(true); - mockClusterClient.callAsInternalUser.mockResolvedValueOnce({ - invalidated_api_keys: ['api-key-id-1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); + mockClusterClient.asInternalUser.security.invalidateApiKey.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + invalidated_api_keys: ['api-key-id-1'], + previously_invalidated_api_keys: [], + error_count: 0, + }, + }) + ); const result = await apiKeys.invalidateAsInternalUser({ id: '123', name: 'abc', @@ -376,7 +403,7 @@ describe('API Keys', () => { previously_invalidated_api_keys: [], error_count: 0, }); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.invalidateAPIKey', { + expect(mockClusterClient.asInternalUser.security.invalidateApiKey).toHaveBeenCalledWith({ body: { id: '123', }, diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts similarity index 84% rename from x-pack/plugins/security/server/authentication/api_keys.ts rename to x-pack/plugins/security/server/authentication/api_keys/api_keys.ts index 19922ce3c890d..212b5755549f9 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; -import { SecurityLicense } from '../../common/licensing'; -import { HTTPAuthorizationHeader } from './http_authentication'; -import { BasicHTTPAuthorizationHeaderCredentials } from './http_authentication'; +import type { IClusterClient, KibanaRequest, Logger } from '../../../../../../src/core/server'; +import type { SecurityLicense } from '../../../common/licensing'; +import { + HTTPAuthorizationHeader, + BasicHTTPAuthorizationHeaderCredentials, +} from '../http_authentication'; /** * Represents the options to create an APIKey class instance that will be @@ -15,7 +17,7 @@ import { BasicHTTPAuthorizationHeaderCredentials } from './http_authentication'; */ export interface ConstructorOptions { logger: Logger; - clusterClient: ILegacyClusterClient; + clusterClient: IClusterClient; license: SecurityLicense; } @@ -117,7 +119,7 @@ export interface InvalidateAPIKeyResult { */ export class APIKeys { private readonly logger: Logger; - private readonly clusterClient: ILegacyClusterClient; + private readonly clusterClient: IClusterClient; private readonly license: SecurityLicense; constructor({ logger, clusterClient, license }: ConstructorOptions) { @@ -141,11 +143,7 @@ export class APIKeys { ); try { - await this.clusterClient.callAsInternalUser('shield.invalidateAPIKey', { - body: { - id, - }, - }); + await this.clusterClient.asInternalUser.security.invalidateApiKey({ body: { id } }); return true; } catch (e) { if (this.doesErrorIndicateAPIKeysAreDisabled(e)) { @@ -171,11 +169,13 @@ export class APIKeys { this.logger.debug('Trying to create an API key'); // User needs `manage_api_key` privilege to use this API - let result: CreateAPIKeyResult; + let result; try { - result = (await this.clusterClient - .asScoped(request) - .callAsCurrentUser('shield.createAPIKey', { body: params })) as CreateAPIKeyResult; + result = ( + await this.clusterClient + .asScoped(request) + .asCurrentUser.security.createApiKey({ body: params }) + ).body; this.logger.debug('API key was created successfully'); } catch (e) { this.logger.error(`Failed to create API key: ${e.message}`); @@ -188,6 +188,7 @@ export class APIKeys { /** * Tries to grant an API key for the current user. * @param request Request instance. + * @param createParams Create operation parameters. */ async grantAsInternalUser(request: KibanaRequest, createParams: CreateAPIKeyParams) { if (!this.license.isEnabled()) { @@ -204,11 +205,13 @@ export class APIKeys { const params = this.getGrantParams(createParams, authorizationHeader); // User needs `manage_api_key` or `grant_api_key` privilege to use this API - let result: GrantAPIKeyResult; + let result; try { - result = (await this.clusterClient.callAsInternalUser('shield.grantAPIKey', { - body: params, - })) as GrantAPIKeyResult; + result = ( + await this.clusterClient.asInternalUser.security.grantApiKey({ + body: params, + }) + ).body; this.logger.debug('API key was granted successfully'); } catch (e) { this.logger.error(`Failed to grant API key: ${e.message}`); @@ -230,16 +233,16 @@ export class APIKeys { this.logger.debug('Trying to invalidate an API key as current user'); - let result: InvalidateAPIKeyResult; + let result; try { // User needs `manage_api_key` privilege to use this API - result = await this.clusterClient - .asScoped(request) - .callAsCurrentUser('shield.invalidateAPIKey', { - body: { - id: params.id, - }, - }); + result = ( + await this.clusterClient + .asScoped(request) + .asCurrentUser.security.invalidateApiKey({ + body: { id: params.id }, + }) + ).body; this.logger.debug('API key was invalidated successfully as current user'); } catch (e) { this.logger.error(`Failed to invalidate API key as current user: ${e.message}`); @@ -260,14 +263,14 @@ export class APIKeys { this.logger.debug('Trying to invalidate an API key'); - let result: InvalidateAPIKeyResult; + let result; try { // Internal user needs `cluster:admin/xpack/security/api_key/invalidate` privilege to use this API - result = await this.clusterClient.callAsInternalUser('shield.invalidateAPIKey', { - body: { - id: params.id, - }, - }); + result = ( + await this.clusterClient.asInternalUser.security.invalidateApiKey({ + body: { id: params.id }, + }) + ).body; this.logger.debug('API key was invalidated successfully'); } catch (e) { this.logger.error(`Failed to invalidate API key: ${e.message}`); diff --git a/x-pack/plugins/security/server/authentication/api_keys/index.ts b/x-pack/plugins/security/server/authentication/api_keys/index.ts new file mode 100644 index 0000000000000..e0b6d03ea2c42 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/api_keys/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + APIKeys, + CreateAPIKeyResult, + InvalidateAPIKeyResult, + CreateAPIKeyParams, + InvalidateAPIKeyParams, + GrantAPIKeyResult, +} from './api_keys'; diff --git a/x-pack/plugins/security/server/authentication/authentication_service.mock.ts b/x-pack/plugins/security/server/authentication/authentication_service.mock.ts new file mode 100644 index 0000000000000..06884611f3873 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authentication_service.mock.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { + AuthenticationServiceSetup, + AuthenticationServiceStart, +} from './authentication_service'; + +import { apiKeysMock } from './api_keys/api_keys.mock'; + +export const authenticationServiceMock = { + createSetup: (): jest.Mocked => ({ + getCurrentUser: jest.fn(), + }), + createStart: (): DeeplyMockedKeys => ({ + apiKeys: apiKeysMock.create(), + login: jest.fn(), + logout: jest.fn(), + getCurrentUser: jest.fn(), + acknowledgeAccessAgreement: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/authentication/authentication_service.test.ts b/x-pack/plugins/security/server/authentication/authentication_service.test.ts new file mode 100644 index 0000000000000..d81702691a3a1 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authentication_service.test.ts @@ -0,0 +1,366 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./authenticator'); + +import Boom from '@hapi/boom'; +import type { PublicMethodsOf } from '@kbn/utility-types'; + +import { + loggingSystemMock, + coreMock, + httpServerMock, + httpServiceMock, + elasticsearchServiceMock, +} from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../../common/licensing/index.mock'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { auditServiceMock, securityAuditLoggerMock } from '../audit/index.mock'; +import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { sessionMock } from '../session_management/session.mock'; + +import type { + AuthenticationHandler, + AuthToolkit, + ILegacyClusterClient, + KibanaRequest, + Logger, + LoggerFactory, + LegacyScopedClusterClient, + HttpServiceSetup, + HttpServiceStart, +} from '../../../../../src/core/server'; +import type { AuthenticatedUser } from '../../common/model'; +import type { SecurityLicense } from '../../common/licensing'; +import type { AuditServiceSetup, SecurityAuditLogger } from '../audit'; +import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import type { Session } from '../session_management'; +import { ConfigSchema, ConfigType, createConfig } from '../config'; +import { AuthenticationResult } from './authentication_result'; +import { AuthenticationService } from './authentication_service'; + +describe('AuthenticationService', () => { + let service: AuthenticationService; + let logger: jest.Mocked; + let mockSetupAuthenticationParams: { + legacyAuditLogger: jest.Mocked; + audit: jest.Mocked; + config: ConfigType; + loggers: LoggerFactory; + http: jest.Mocked; + clusterClient: jest.Mocked; + license: jest.Mocked; + getFeatureUsageService: () => jest.Mocked; + session: jest.Mocked>; + }; + let mockScopedClusterClient: jest.Mocked>; + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + + mockSetupAuthenticationParams = { + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), + http: coreMock.createSetup().http, + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingSystemMock.create().get(), + { isTLSEnabled: false } + ), + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + license: licenseMock.create(), + loggers: loggingSystemMock.create(), + getFeatureUsageService: jest + .fn() + .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), + session: sessionMock.create(), + }; + + mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue( + (mockScopedClusterClient as unknown) as jest.Mocked + ); + + service = new AuthenticationService(logger); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('#setup()', () => { + it('properly registers auth handler', () => { + service.setup(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( + expect.any(Function) + ); + }); + + describe('authentication handler', () => { + let authHandler: AuthenticationHandler; + let authenticate: jest.SpyInstance, [KibanaRequest]>; + let mockAuthToolkit: jest.Mocked; + beforeEach(() => { + mockAuthToolkit = httpServiceMock.createAuthToolkit(); + + service.setup(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( + expect.any(Function) + ); + + authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; + authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] + .authenticate; + }); + + it('replies with no credentials when security is disabled in elasticsearch', async () => { + const mockRequest = httpServerMock.createKibanaRequest(); + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); + + await authHandler(mockRequest, mockResponse, mockAuthToolkit); + + expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + expect(mockResponse.internalError).not.toHaveBeenCalled(); + + expect(authenticate).not.toHaveBeenCalled(); + }); + + it('continues request with credentials on success', async () => { + const mockRequest = httpServerMock.createKibanaRequest(); + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + const mockUser = mockAuthenticatedUser(); + const mockAuthHeaders = { authorization: 'Basic xxx' }; + + authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { authHeaders: mockAuthHeaders }) + ); + + await authHandler(mockRequest, mockResponse, mockAuthToolkit); + + expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({ + state: mockUser, + requestHeaders: mockAuthHeaders, + }); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + expect(mockResponse.internalError).not.toHaveBeenCalled(); + + expect(authenticate).toHaveBeenCalledTimes(1); + expect(authenticate).toHaveBeenCalledWith(mockRequest); + }); + + it('returns authentication response headers on success if any', async () => { + const mockRequest = httpServerMock.createKibanaRequest(); + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + const mockUser = mockAuthenticatedUser(); + const mockAuthHeaders = { authorization: 'Basic xxx' }; + const mockAuthResponseHeaders = { 'WWW-Authenticate': 'Negotiate' }; + + authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + authHeaders: mockAuthHeaders, + authResponseHeaders: mockAuthResponseHeaders, + }) + ); + + await authHandler(mockRequest, mockResponse, mockAuthToolkit); + + expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({ + state: mockUser, + requestHeaders: mockAuthHeaders, + responseHeaders: mockAuthResponseHeaders, + }); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + expect(mockResponse.internalError).not.toHaveBeenCalled(); + + expect(authenticate).toHaveBeenCalledTimes(1); + expect(authenticate).toHaveBeenCalledWith(mockRequest); + }); + + it('redirects user if redirection is requested by the authenticator preserving authentication response headers if any', async () => { + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + authenticate.mockResolvedValue( + AuthenticationResult.redirectTo('/some/url', { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); + + expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1); + expect(mockAuthToolkit.redirected).toHaveBeenCalledWith({ + location: '/some/url', + 'WWW-Authenticate': 'Negotiate', + }); + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockResponse.internalError).not.toHaveBeenCalled(); + }); + + it('rejects with `Internal Server Error` and log error when `authenticate` throws unhandled exception', async () => { + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + const failureReason = new Error('something went wrong'); + authenticate.mockRejectedValue(failureReason); + + await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); + + expect(mockResponse.internalError).toHaveBeenCalledTimes(1); + const [[error]] = mockResponse.internalError.mock.calls; + expect(error).toBeUndefined(); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(failureReason); + }); + + it('rejects with original `badRequest` error when `authenticate` fails to authenticate user', async () => { + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + const esError = Boom.badRequest('some message'); + authenticate.mockResolvedValue(AuthenticationResult.failed(esError)); + + await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + const [[response]] = mockResponse.customError.mock.calls; + expect(response.body).toBe(esError); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + }); + + it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => { + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + const originalError = Boom.unauthorized('some message'); + originalError.output.headers['WWW-Authenticate'] = [ + 'Basic realm="Access to prod", charset="UTF-8"', + 'Basic', + 'Negotiate', + ] as any; + authenticate.mockResolvedValue( + AuthenticationResult.failed(originalError, { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); + + expect(mockResponse.customError).toHaveBeenCalledTimes(1); + const [[options]] = mockResponse.customError.mock.calls; + expect(options.body).toBe(originalError); + expect(options!.headers).toEqual({ 'WWW-Authenticate': 'Negotiate' }); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + }); + + it('returns `notHandled` when authentication can not be handled', async () => { + const mockResponse = httpServerMock.createLifecycleResponseFactory(); + authenticate.mockResolvedValue(AuthenticationResult.notHandled()); + + await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); + + expect(mockAuthToolkit.notHandled).toHaveBeenCalledTimes(1); + + expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); + expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentUser()', () => { + let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null; + beforeEach(async () => { + getCurrentUser = service.setup(mockSetupAuthenticationParams).getCurrentUser; + }); + + it('returns `null` if Security is disabled', () => { + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); + + expect(getCurrentUser(httpServerMock.createKibanaRequest())).toBe(null); + }); + + it('returns user from the auth state.', () => { + const mockUser = mockAuthenticatedUser(); + + const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; + mockAuthGet.mockReturnValue({ state: mockUser }); + + const mockRequest = httpServerMock.createKibanaRequest(); + expect(getCurrentUser(mockRequest)).toBe(mockUser); + expect(mockAuthGet).toHaveBeenCalledTimes(1); + expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); + }); + + it('returns null if auth state is not available.', () => { + const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; + mockAuthGet.mockReturnValue({}); + + const mockRequest = httpServerMock.createKibanaRequest(); + expect(getCurrentUser(mockRequest)).toBeNull(); + expect(mockAuthGet).toHaveBeenCalledTimes(1); + expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); + }); + }); + }); + + describe('#start()', () => { + let mockStartAuthenticationParams: { + http: jest.Mocked; + clusterClient: ReturnType; + }; + beforeEach(() => { + const coreStart = coreMock.createStart(); + mockStartAuthenticationParams = { + http: coreStart.http, + clusterClient: elasticsearchServiceMock.createClusterClient(), + }; + service.setup(mockSetupAuthenticationParams); + }); + + describe('getCurrentUser()', () => { + let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null; + beforeEach(async () => { + getCurrentUser = (await service.start(mockStartAuthenticationParams)).getCurrentUser; + }); + + it('returns `null` if Security is disabled', () => { + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); + + expect(getCurrentUser(httpServerMock.createKibanaRequest())).toBe(null); + }); + + it('returns user from the auth state.', () => { + const mockUser = mockAuthenticatedUser(); + + const mockAuthGet = mockStartAuthenticationParams.http.auth.get as jest.Mock; + mockAuthGet.mockReturnValue({ state: mockUser }); + + const mockRequest = httpServerMock.createKibanaRequest(); + expect(getCurrentUser(mockRequest)).toBe(mockUser); + expect(mockAuthGet).toHaveBeenCalledTimes(1); + expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); + }); + + it('returns null if auth state is not available.', () => { + const mockAuthGet = mockStartAuthenticationParams.http.auth.get as jest.Mock; + mockAuthGet.mockReturnValue({}); + + const mockRequest = httpServerMock.createKibanaRequest(); + expect(getCurrentUser(mockRequest)).toBeNull(); + expect(mockAuthGet).toHaveBeenCalledTimes(1); + expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts new file mode 100644 index 0000000000000..6cd20592f21a4 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { + LoggerFactory, + KibanaRequest, + Logger, + HttpServiceSetup, + IClusterClient, + ILegacyClusterClient, + HttpServiceStart, +} from '../../../../../src/core/server'; +import type { SecurityLicense } from '../../common/licensing'; +import type { AuthenticatedUser } from '../../common/model'; +import type { AuditServiceSetup, SecurityAuditLogger } from '../audit'; +import type { ConfigType } from '../config'; +import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import type { Session } from '../session_management'; +import type { DeauthenticationResult } from './deauthentication_result'; +import type { AuthenticationResult } from './authentication_result'; +import { getErrorStatusCode } from '../errors'; +import { APIKeys } from './api_keys'; +import { Authenticator, ProviderLoginAttempt } from './authenticator'; + +interface AuthenticationServiceSetupParams { + legacyAuditLogger: SecurityAuditLogger; + audit: AuditServiceSetup; + getFeatureUsageService: () => SecurityFeatureUsageServiceStart; + http: HttpServiceSetup; + clusterClient: ILegacyClusterClient; + config: ConfigType; + license: SecurityLicense; + loggers: LoggerFactory; + session: PublicMethodsOf; +} + +interface AuthenticationServiceStartParams { + http: HttpServiceStart; + clusterClient: IClusterClient; +} + +export interface AuthenticationServiceSetup { + /** + * @deprecated use `getCurrentUser` from the start contract instead + */ + getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; +} + +export interface AuthenticationServiceStart { + apiKeys: Pick< + APIKeys, + | 'areAPIKeysEnabled' + | 'create' + | 'invalidate' + | 'grantAsInternalUser' + | 'invalidateAsInternalUser' + >; + login: (request: KibanaRequest, attempt: ProviderLoginAttempt) => Promise; + logout: (request: KibanaRequest) => Promise; + acknowledgeAccessAgreement: (request: KibanaRequest) => Promise; + getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; +} + +export class AuthenticationService { + private license!: SecurityLicense; + private authenticator!: Authenticator; + + constructor(private readonly logger: Logger) {} + + setup({ + legacyAuditLogger: auditLogger, + audit, + getFeatureUsageService, + http, + clusterClient, + config, + license, + loggers, + session, + }: AuthenticationServiceSetupParams): AuthenticationServiceSetup { + this.license = license; + + const getCurrentUser = (request: KibanaRequest) => { + if (!license.isEnabled()) { + return null; + } + + return http.auth.get(request).state ?? null; + }; + + this.authenticator = new Authenticator({ + legacyAuditLogger: auditLogger, + audit, + loggers, + clusterClient, + basePath: http.basePath, + config: { authc: config.authc }, + getCurrentUser, + getFeatureUsageService, + license, + session, + }); + + http.registerAuth(async (request, response, t) => { + // If security is disabled continue with no user credentials and delete the client cookie as well. + if (!license.isEnabled()) { + return t.authenticated(); + } + + let authenticationResult; + try { + authenticationResult = await this.authenticator.authenticate(request); + } catch (err) { + this.logger.error(err); + return response.internalError(); + } + + if (authenticationResult.succeeded()) { + return t.authenticated({ + state: authenticationResult.user, + requestHeaders: authenticationResult.authHeaders, + responseHeaders: authenticationResult.authResponseHeaders, + }); + } + + if (authenticationResult.redirected()) { + // Some authentication mechanisms may require user to be redirected to another location to + // initiate or complete authentication flow. It can be Kibana own login page for basic + // authentication (username and password) or arbitrary external page managed by 3rd party + // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who + // decides what location user should be redirected to. + return t.redirected({ + location: authenticationResult.redirectURL!, + ...(authenticationResult.authResponseHeaders || {}), + }); + } + + if (authenticationResult.failed()) { + this.logger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); + const error = authenticationResult.error!; + // proxy Elasticsearch "native" errors + const statusCode = getErrorStatusCode(error); + if (typeof statusCode === 'number') { + return response.customError({ + body: error, + statusCode, + headers: authenticationResult.authResponseHeaders, + }); + } + + return response.unauthorized({ + headers: authenticationResult.authResponseHeaders, + }); + } + + this.logger.debug('Could not handle authentication attempt'); + return t.notHandled(); + }); + + this.logger.debug('Successfully registered core authentication handler.'); + + return { + getCurrentUser, + }; + } + + start({ clusterClient, http }: AuthenticationServiceStartParams): AuthenticationServiceStart { + const apiKeys = new APIKeys({ + clusterClient, + logger: this.logger.get('api-key'), + license: this.license, + }); + + return { + apiKeys: { + areAPIKeysEnabled: apiKeys.areAPIKeysEnabled.bind(apiKeys), + create: apiKeys.create.bind(apiKeys), + grantAsInternalUser: apiKeys.grantAsInternalUser.bind(apiKeys), + invalidate: apiKeys.invalidate.bind(apiKeys), + invalidateAsInternalUser: apiKeys.invalidateAsInternalUser.bind(apiKeys), + }, + + login: this.authenticator.login.bind(this.authenticator), + logout: this.authenticator.logout.bind(this.authenticator), + acknowledgeAccessAgreement: this.authenticator.acknowledgeAccessAgreement.bind( + this.authenticator + ), + + /** + * Retrieves currently authenticated user associated with the specified request. + * @param request + */ + getCurrentUser: (request: KibanaRequest) => { + if (!this.license.isEnabled()) { + return null; + } + return http.auth.get(request).state ?? null; + }, + }; + } +} diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index ed5d05dbcf619..3d3946fde9f34 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -198,9 +198,7 @@ describe('Authenticator', () => { afterEach(() => jest.resetAllMocks()); it('enabled by default', () => { - const authenticator = new Authenticator(getMockOptions()); - expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); - expect(authenticator.isProviderTypeEnabled('http')).toBe(true); + new Authenticator(getMockOptions()); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -210,14 +208,11 @@ describe('Authenticator', () => { }); it('includes all required schemes if `autoSchemesEnabled` is enabled', () => { - const authenticator = new Authenticator( + new Authenticator( getMockOptions({ providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, }) ); - expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); - expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -227,15 +222,12 @@ describe('Authenticator', () => { }); it('does not include additional schemes if `autoSchemesEnabled` is disabled', () => { - const authenticator = new Authenticator( + new Authenticator( getMockOptions({ providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, http: { autoSchemesEnabled: false }, }) ); - expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); - expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -243,14 +235,12 @@ describe('Authenticator', () => { }); it('disabled if explicitly disabled', () => { - const authenticator = new Authenticator( + new Authenticator( getMockOptions({ providers: { basic: { basic1: { order: 0 } } }, http: { enabled: false }, }) ); - expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); - expect(authenticator.isProviderTypeEnabled('http')).toBe(false); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -1864,27 +1854,6 @@ describe('Authenticator', () => { }); }); - describe('`isProviderEnabled` method', () => { - it('returns `true` only if specified provider is enabled', () => { - let authenticator = new Authenticator( - getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }) - ); - expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); - expect(authenticator.isProviderTypeEnabled('saml')).toBe(false); - - authenticator = new Authenticator( - getMockOptions({ - providers: { - basic: { basic1: { order: 0 } }, - saml: { saml1: { order: 1, realm: 'test' } }, - }, - }) - ); - expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); - expect(authenticator.isProviderTypeEnabled('saml')).toBe(true); - }); - }); - describe('`acknowledgeAccessAgreement` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index f175f47d30351..85215ebf46fb4 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -421,14 +421,6 @@ export class Authenticator { return DeauthenticationResult.notHandled(); } - /** - * Checks whether specified provider type is currently enabled. - * @param providerType Type of the provider (`basic`, `saml`, `pki` etc.). - */ - isProviderTypeEnabled(providerType: string) { - return [...this.providers.values()].some((provider) => provider.type === providerType); - } - /** * Acknowledges access agreement on behalf of the currently authenticated user. * @param request Request instance. diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts deleted file mode 100644 index 299a75335a64c..0000000000000 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Authentication } from '.'; - -export const authenticationMock = { - create: (): jest.Mocked => ({ - login: jest.fn(), - logout: jest.fn(), - isProviderTypeEnabled: jest.fn(), - areAPIKeysEnabled: jest.fn(), - createAPIKey: jest.fn(), - getCurrentUser: jest.fn(), - grantAPIKeyAsInternalUser: jest.fn(), - invalidateAPIKey: jest.fn(), - invalidateAPIKeyAsInternalUser: jest.fn(), - isAuthenticated: jest.fn(), - acknowledgeAccessAgreement: jest.fn(), - }), -}; diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts deleted file mode 100644 index 3d2d26550215c..0000000000000 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -jest.mock('./api_keys'); -jest.mock('./authenticator'); - -import Boom from '@hapi/boom'; -import type { PublicMethodsOf } from '@kbn/utility-types'; - -import { - loggingSystemMock, - coreMock, - httpServerMock, - httpServiceMock, - elasticsearchServiceMock, -} from '../../../../../src/core/server/mocks'; -import { licenseMock } from '../../common/licensing/index.mock'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { auditServiceMock, securityAuditLoggerMock } from '../audit/index.mock'; -import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; -import { sessionMock } from '../session_management/session.mock'; - -import { - AuthenticationHandler, - AuthToolkit, - ILegacyClusterClient, - KibanaRequest, - LoggerFactory, - LegacyScopedClusterClient, - HttpServiceSetup, -} from '../../../../../src/core/server'; -import { AuthenticatedUser } from '../../common/model'; -import { ConfigSchema, ConfigType, createConfig } from '../config'; -import { AuthenticationResult } from './authentication_result'; -import { Authentication, setupAuthentication } from '.'; -import { - CreateAPIKeyResult, - CreateAPIKeyParams, - InvalidateAPIKeyResult, - InvalidateAPIKeyParams, -} from './api_keys'; -import { SecurityLicense } from '../../common/licensing'; -import { AuditServiceSetup, SecurityAuditLogger } from '../audit'; -import { SecurityFeatureUsageServiceStart } from '../feature_usage'; -import { Session } from '../session_management'; - -describe('setupAuthentication()', () => { - let mockSetupAuthenticationParams: { - legacyAuditLogger: jest.Mocked; - audit: jest.Mocked; - config: ConfigType; - loggers: LoggerFactory; - http: jest.Mocked; - clusterClient: jest.Mocked; - license: jest.Mocked; - getFeatureUsageService: () => jest.Mocked; - session: jest.Mocked>; - }; - let mockScopedClusterClient: jest.Mocked>; - beforeEach(() => { - mockSetupAuthenticationParams = { - legacyAuditLogger: securityAuditLoggerMock.create(), - audit: auditServiceMock.create(), - http: coreMock.createSetup().http, - config: createConfig( - ConfigSchema.validate({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }), - loggingSystemMock.create().get(), - { isTLSEnabled: false } - ), - clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), - license: licenseMock.create(), - loggers: loggingSystemMock.create(), - getFeatureUsageService: jest - .fn() - .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), - session: sessionMock.create(), - }; - - mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); - }); - - afterEach(() => jest.clearAllMocks()); - - it('properly registers auth handler', async () => { - await setupAuthentication(mockSetupAuthenticationParams); - - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( - expect.any(Function) - ); - }); - - describe('authentication handler', () => { - let authHandler: AuthenticationHandler; - let authenticate: jest.SpyInstance, [KibanaRequest]>; - let mockAuthToolkit: jest.Mocked; - beforeEach(async () => { - mockAuthToolkit = httpServiceMock.createAuthToolkit(); - - await setupAuthentication(mockSetupAuthenticationParams); - - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( - expect.any(Function) - ); - - authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; - authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] - .authenticate; - }); - - it('replies with no credentials when security is disabled in elasticsearch', async () => { - const mockRequest = httpServerMock.createKibanaRequest(); - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - - mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); - - await authHandler(mockRequest, mockResponse, mockAuthToolkit); - - expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); - expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith(); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - expect(mockResponse.internalError).not.toHaveBeenCalled(); - - expect(authenticate).not.toHaveBeenCalled(); - }); - - it('continues request with credentials on success', async () => { - const mockRequest = httpServerMock.createKibanaRequest(); - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - const mockUser = mockAuthenticatedUser(); - const mockAuthHeaders = { authorization: 'Basic xxx' }; - - authenticate.mockResolvedValue( - AuthenticationResult.succeeded(mockUser, { authHeaders: mockAuthHeaders }) - ); - - await authHandler(mockRequest, mockResponse, mockAuthToolkit); - - expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); - expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({ - state: mockUser, - requestHeaders: mockAuthHeaders, - }); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - expect(mockResponse.internalError).not.toHaveBeenCalled(); - - expect(authenticate).toHaveBeenCalledTimes(1); - expect(authenticate).toHaveBeenCalledWith(mockRequest); - }); - - it('returns authentication response headers on success if any', async () => { - const mockRequest = httpServerMock.createKibanaRequest(); - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - const mockUser = mockAuthenticatedUser(); - const mockAuthHeaders = { authorization: 'Basic xxx' }; - const mockAuthResponseHeaders = { 'WWW-Authenticate': 'Negotiate' }; - - authenticate.mockResolvedValue( - AuthenticationResult.succeeded(mockUser, { - authHeaders: mockAuthHeaders, - authResponseHeaders: mockAuthResponseHeaders, - }) - ); - - await authHandler(mockRequest, mockResponse, mockAuthToolkit); - - expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1); - expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({ - state: mockUser, - requestHeaders: mockAuthHeaders, - responseHeaders: mockAuthResponseHeaders, - }); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - expect(mockResponse.internalError).not.toHaveBeenCalled(); - - expect(authenticate).toHaveBeenCalledTimes(1); - expect(authenticate).toHaveBeenCalledWith(mockRequest); - }); - - it('redirects user if redirection is requested by the authenticator preserving authentication response headers if any', async () => { - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - authenticate.mockResolvedValue( - AuthenticationResult.redirectTo('/some/url', { - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - - await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); - - expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1); - expect(mockAuthToolkit.redirected).toHaveBeenCalledWith({ - location: '/some/url', - 'WWW-Authenticate': 'Negotiate', - }); - expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockResponse.internalError).not.toHaveBeenCalled(); - }); - - it('rejects with `Internal Server Error` and log error when `authenticate` throws unhandled exception', async () => { - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - authenticate.mockRejectedValue(new Error('something went wrong')); - - await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); - - expect(mockResponse.internalError).toHaveBeenCalledTimes(1); - const [[error]] = mockResponse.internalError.mock.calls; - expect(error).toBeUndefined(); - - expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - expect(loggingSystemMock.collect(mockSetupAuthenticationParams.loggers).error) - .toMatchInlineSnapshot(` - Array [ - Array [ - [Error: something went wrong], - ], - ] - `); - }); - - it('rejects with original `badRequest` error when `authenticate` fails to authenticate user', async () => { - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - const esError = Boom.badRequest('some message'); - authenticate.mockResolvedValue(AuthenticationResult.failed(esError)); - - await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); - - expect(mockResponse.customError).toHaveBeenCalledTimes(1); - const [[response]] = mockResponse.customError.mock.calls; - expect(response.body).toBe(esError); - - expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - }); - - it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => { - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - const originalError = Boom.unauthorized('some message'); - originalError.output.headers['WWW-Authenticate'] = [ - 'Basic realm="Access to prod", charset="UTF-8"', - 'Basic', - 'Negotiate', - ] as any; - authenticate.mockResolvedValue( - AuthenticationResult.failed(originalError, { - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - - await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); - - expect(mockResponse.customError).toHaveBeenCalledTimes(1); - const [[options]] = mockResponse.customError.mock.calls; - expect(options.body).toBe(originalError); - expect(options!.headers).toEqual({ 'WWW-Authenticate': 'Negotiate' }); - - expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - }); - - it('returns `notHandled` when authentication can not be handled', async () => { - const mockResponse = httpServerMock.createLifecycleResponseFactory(); - authenticate.mockResolvedValue(AuthenticationResult.notHandled()); - - await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); - - expect(mockAuthToolkit.notHandled).toHaveBeenCalledTimes(1); - - expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); - }); - }); - - describe('getCurrentUser()', () => { - let getCurrentUser: (r: KibanaRequest) => AuthenticatedUser | null; - beforeEach(async () => { - getCurrentUser = (await setupAuthentication(mockSetupAuthenticationParams)).getCurrentUser; - }); - - it('returns `null` if Security is disabled', () => { - mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); - - expect(getCurrentUser(httpServerMock.createKibanaRequest())).toBe(null); - }); - - it('returns user from the auth state.', () => { - const mockUser = mockAuthenticatedUser(); - - const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; - mockAuthGet.mockReturnValue({ state: mockUser }); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(getCurrentUser(mockRequest)).toBe(mockUser); - expect(mockAuthGet).toHaveBeenCalledTimes(1); - expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); - }); - - it('returns null if auth state is not available.', () => { - const mockAuthGet = mockSetupAuthenticationParams.http.auth.get as jest.Mock; - mockAuthGet.mockReturnValue({}); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(getCurrentUser(mockRequest)).toBeNull(); - expect(mockAuthGet).toHaveBeenCalledTimes(1); - expect(mockAuthGet).toHaveBeenCalledWith(mockRequest); - }); - }); - - describe('isAuthenticated()', () => { - let isAuthenticated: (r: KibanaRequest) => boolean; - beforeEach(async () => { - isAuthenticated = (await setupAuthentication(mockSetupAuthenticationParams)).isAuthenticated; - }); - - it('returns `true` if request is authenticated', () => { - const mockIsAuthenticated = mockSetupAuthenticationParams.http.auth - .isAuthenticated as jest.Mock; - mockIsAuthenticated.mockReturnValue(true); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(isAuthenticated(mockRequest)).toBe(true); - expect(mockIsAuthenticated).toHaveBeenCalledTimes(1); - expect(mockIsAuthenticated).toHaveBeenCalledWith(mockRequest); - }); - - it('returns `false` if request is not authenticated', () => { - const mockIsAuthenticated = mockSetupAuthenticationParams.http.auth - .isAuthenticated as jest.Mock; - mockIsAuthenticated.mockReturnValue(false); - - const mockRequest = httpServerMock.createKibanaRequest(); - expect(isAuthenticated(mockRequest)).toBe(false); - expect(mockIsAuthenticated).toHaveBeenCalledTimes(1); - expect(mockIsAuthenticated).toHaveBeenCalledWith(mockRequest); - }); - }); - - describe('createAPIKey()', () => { - let createAPIKey: ( - request: KibanaRequest, - params: CreateAPIKeyParams - ) => Promise; - beforeEach(async () => { - createAPIKey = (await setupAuthentication(mockSetupAuthenticationParams)).createAPIKey; - }); - - it('calls createAPIKey with given arguments', async () => { - const request = httpServerMock.createKibanaRequest(); - const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; - const params = { - name: 'my-key', - role_descriptors: {}, - expiration: '1d', - }; - apiKeysInstance.create.mockResolvedValueOnce({ success: true }); - await expect(createAPIKey(request, params)).resolves.toEqual({ - success: true, - }); - expect(apiKeysInstance.create).toHaveBeenCalledWith(request, params); - }); - }); - - describe('grantAPIKeyAsInternalUser()', () => { - let grantAPIKeyAsInternalUser: ( - request: KibanaRequest, - params: CreateAPIKeyParams - ) => Promise; - beforeEach(async () => { - grantAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) - .grantAPIKeyAsInternalUser; - }); - - it('calls grantAsInternalUser', async () => { - const request = httpServerMock.createKibanaRequest(); - const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; - apiKeysInstance.grantAsInternalUser.mockResolvedValueOnce({ api_key: 'foo' }); - - const createParams = { name: 'test_key', role_descriptors: {} }; - - await expect(grantAPIKeyAsInternalUser(request, createParams)).resolves.toEqual({ - api_key: 'foo', - }); - expect(apiKeysInstance.grantAsInternalUser).toHaveBeenCalledWith(request, createParams); - }); - }); - - describe('invalidateAPIKey()', () => { - let invalidateAPIKey: ( - request: KibanaRequest, - params: InvalidateAPIKeyParams - ) => Promise; - beforeEach(async () => { - invalidateAPIKey = (await setupAuthentication(mockSetupAuthenticationParams)) - .invalidateAPIKey; - }); - - it('calls invalidateAPIKey with given arguments', async () => { - const request = httpServerMock.createKibanaRequest(); - const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; - const params = { - id: '123', - }; - apiKeysInstance.invalidate.mockResolvedValueOnce({ success: true }); - await expect(invalidateAPIKey(request, params)).resolves.toEqual({ - success: true, - }); - expect(apiKeysInstance.invalidate).toHaveBeenCalledWith(request, params); - }); - }); - - describe('invalidateAPIKeyAsInternalUser()', () => { - let invalidateAPIKeyAsInternalUser: Authentication['invalidateAPIKeyAsInternalUser']; - - beforeEach(async () => { - invalidateAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) - .invalidateAPIKeyAsInternalUser; - }); - - it('calls invalidateAPIKeyAsInternalUser with given arguments', async () => { - const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; - const params = { - id: '123', - }; - apiKeysInstance.invalidateAsInternalUser.mockResolvedValueOnce({ success: true }); - await expect(invalidateAPIKeyAsInternalUser(params)).resolves.toEqual({ - success: true, - }); - expect(apiKeysInstance.invalidateAsInternalUser).toHaveBeenCalledWith(params); - }); - }); -}); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 68d3e622a6570..b43ffd86ae5ed 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -3,25 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import type { PublicMethodsOf, UnwrapPromise } from '@kbn/utility-types'; -import { - ILegacyClusterClient, - KibanaRequest, - LoggerFactory, - HttpServiceSetup, -} from '../../../../../src/core/server'; -import { SecurityLicense } from '../../common/licensing'; -import { AuthenticatedUser } from '../../common/model'; -import { SecurityAuditLogger, AuditServiceSetup } from '../audit'; -import { ConfigType } from '../config'; -import { getErrorStatusCode } from '../errors'; -import { SecurityFeatureUsageServiceStart } from '../feature_usage'; -import { Session } from '../session_management'; -import { Authenticator } from './authenticator'; -import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; export { canRedirectRequest } from './can_redirect_request'; -export { Authenticator, ProviderLoginAttempt } from './authenticator'; +export { + AuthenticationService, + AuthenticationServiceSetup, + AuthenticationServiceStart, +} from './authentication_service'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; export { @@ -33,149 +21,13 @@ export { OIDCAuthenticationProvider, } from './providers'; export { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from './http_authentication'; +export type { CreateAPIKeyResult, InvalidateAPIKeyResult, CreateAPIKeyParams, InvalidateAPIKeyParams, GrantAPIKeyResult, } from './api_keys'; -export { - BasicHTTPAuthorizationHeaderCredentials, - HTTPAuthorizationHeader, -} from './http_authentication'; - -interface SetupAuthenticationParams { - legacyAuditLogger: SecurityAuditLogger; - audit: AuditServiceSetup; - getFeatureUsageService: () => SecurityFeatureUsageServiceStart; - http: HttpServiceSetup; - clusterClient: ILegacyClusterClient; - config: ConfigType; - license: SecurityLicense; - loggers: LoggerFactory; - session: PublicMethodsOf; -} - -export type Authentication = UnwrapPromise>; - -export async function setupAuthentication({ - legacyAuditLogger: auditLogger, - audit, - getFeatureUsageService, - http, - clusterClient, - config, - license, - loggers, - session, -}: SetupAuthenticationParams) { - const authLogger = loggers.get('authentication'); - - /** - * Retrieves currently authenticated user associated with the specified request. - * @param request - */ - const getCurrentUser = (request: KibanaRequest) => { - if (!license.isEnabled()) { - return null; - } - - return (http.auth.get(request).state ?? null) as AuthenticatedUser | null; - }; - - const authenticator = new Authenticator({ - legacyAuditLogger: auditLogger, - audit, - loggers, - clusterClient, - basePath: http.basePath, - config: { authc: config.authc }, - getCurrentUser, - getFeatureUsageService, - license, - session, - }); - - authLogger.debug('Successfully initialized authenticator.'); - - http.registerAuth(async (request, response, t) => { - // If security is disabled continue with no user credentials and delete the client cookie as well. - if (!license.isEnabled()) { - return t.authenticated(); - } - - let authenticationResult; - try { - authenticationResult = await authenticator.authenticate(request); - } catch (err) { - authLogger.error(err); - return response.internalError(); - } - - if (authenticationResult.succeeded()) { - return t.authenticated({ - state: authenticationResult.user, - requestHeaders: authenticationResult.authHeaders, - responseHeaders: authenticationResult.authResponseHeaders, - }); - } - - if (authenticationResult.redirected()) { - // Some authentication mechanisms may require user to be redirected to another location to - // initiate or complete authentication flow. It can be Kibana own login page for basic - // authentication (username and password) or arbitrary external page managed by 3rd party - // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who - // decides what location user should be redirected to. - return t.redirected({ - location: authenticationResult.redirectURL!, - ...(authenticationResult.authResponseHeaders || {}), - }); - } - - if (authenticationResult.failed()) { - authLogger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); - const error = authenticationResult.error!; - // proxy Elasticsearch "native" errors - const statusCode = getErrorStatusCode(error); - if (typeof statusCode === 'number') { - return response.customError({ - body: error, - statusCode, - headers: authenticationResult.authResponseHeaders, - }); - } - - return response.unauthorized({ - headers: authenticationResult.authResponseHeaders, - }); - } - - authLogger.debug('Could not handle authentication attempt'); - return t.notHandled(); - }); - - authLogger.debug('Successfully registered core authentication handler.'); - - const apiKeys = new APIKeys({ - clusterClient, - logger: loggers.get('api-key'), - license, - }); - return { - login: authenticator.login.bind(authenticator), - logout: authenticator.logout.bind(authenticator), - isProviderTypeEnabled: authenticator.isProviderTypeEnabled.bind(authenticator), - acknowledgeAccessAgreement: authenticator.acknowledgeAccessAgreement.bind(authenticator), - getCurrentUser, - areAPIKeysEnabled: () => apiKeys.areAPIKeysEnabled(), - createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => - apiKeys.create(request, params), - grantAPIKeyAsInternalUser: (request: KibanaRequest, params: CreateAPIKeyParams) => - apiKeys.grantAsInternalUser(request, params), - invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) => - apiKeys.invalidate(request, params), - invalidateAPIKeyAsInternalUser: (params: InvalidateAPIKeyParams) => - apiKeys.invalidateAsInternalUser(params), - isAuthenticated: (request: KibanaRequest) => http.auth.isAuthenticated(request), - }; -} diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts index 7823e8b401190..0aaad251ae642 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts @@ -196,63 +196,6 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen }, }); - /** - * Creates an API key in Elasticsearch for the current user. - * - * @param {string} name A name for this API key - * @param {object} role_descriptors Role descriptors for this API key, if not - * provided then permissions of authenticated user are applied. - * @param {string} [expiration] Optional expiration for the API key being generated. If expiration - * is not provided then the API keys do not expire. - * - * @returns {{id: string, name: string, api_key: string, expiration?: number}} - */ - shield.createAPIKey = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/api_key', - }, - }); - - /** - * Grants an API key in Elasticsearch for the current user. - * - * @param {string} type The type of grant, either "password" or "access_token" - * @param {string} username Required when using the "password" type - * @param {string} password Required when using the "password" type - * @param {string} access_token Required when using the "access_token" type - * - * @returns {{api_key: string}} - */ - shield.grantAPIKey = ca({ - method: 'POST', - needBody: true, - url: { - fmt: '/_security/api_key/grant', - }, - }); - - /** - * Invalidates an API key in Elasticsearch. - * - * @param {string} [id] An API key id. - * @param {string} [name] An API key name. - * @param {string} [realm_name] The name of an authentication realm. - * @param {string} [username] The username of a user. - * - * NOTE: While all parameters are optional, at least one of them is required. - * - * @returns {{invalidated_api_keys: string[], previously_invalidated_api_keys: string[], error_count: number, error_details?: object[]}} - */ - shield.invalidateAPIKey = ca({ - method: 'DELETE', - needBody: true, - url: { - fmt: '/_security/api_key', - }, - }); - /** * Gets an access token in exchange to the certificate chain for the target subject distinguished name. * diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index d99fbc702a078..85f49bf3f931a 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -4,28 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TypeOf } from '@kbn/config-schema'; -import { RecursiveReadonly } from '@kbn/utility-types'; -import { +import type { TypeOf } from '@kbn/config-schema'; +import type { RecursiveReadonly } from '@kbn/utility-types'; +import type { PluginConfigDescriptor, PluginInitializer, PluginInitializerContext, } from '../../../../src/core/server'; import { ConfigSchema } from './config'; -import { Plugin, SecurityPluginSetup, PluginSetupDependencies } from './plugin'; +import { + Plugin, + SecurityPluginSetup, + SecurityPluginStart, + PluginSetupDependencies, +} from './plugin'; // These exports are part of public Security plugin contract, any change in signature of exported // functions or removal of exports should be considered as a breaking change. -export { - Authentication, - AuthenticationResult, - DeauthenticationResult, +export type { CreateAPIKeyResult, InvalidateAPIKeyParams, InvalidateAPIKeyResult, GrantAPIKeyResult, - SAMLLogin, - OIDCLogin, } from './authentication'; export { LegacyAuditLogger, @@ -35,8 +35,8 @@ export { EventType, EventOutcome, } from './audit'; -export { SecurityPluginSetup }; -export { AuthenticatedUser } from '../common/model'; +export type { SecurityPluginSetup, SecurityPluginStart }; +export type { AuthenticatedUser } from '../common/model'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, @@ -93,6 +93,6 @@ export const config: PluginConfigDescriptor> = { }; export const plugin: PluginInitializer< RecursiveReadonly, - void, + RecursiveReadonly, PluginSetupDependencies > = (initializerContext: PluginInitializerContext) => new Plugin(initializerContext); diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index 4ce0ec6e3c10e..df30d1bf9d6f6 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { authenticationMock } from './authentication/index.mock'; +import type { ApiResponse } from '@elastic/elasticsearch'; +import { authenticationServiceMock } from './authentication/authentication_service.mock'; import { authorizationMock } from './authorization/index.mock'; import { licenseMock } from '../common/licensing/index.mock'; import { auditServiceMock } from './audit/index.mock'; @@ -13,7 +14,7 @@ function createSetupMock() { const mockAuthz = authorizationMock.create(); return { audit: auditServiceMock.create(), - authc: authenticationMock.create(), + authc: authenticationServiceMock.createSetup(), authz: { actions: mockAuthz.actions, checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, @@ -25,6 +26,38 @@ function createSetupMock() { }; } +function createStartMock() { + const mockAuthz = authorizationMock.create(); + const mockAuthc = authenticationServiceMock.createStart(); + return { + authc: { + apiKeys: mockAuthc.apiKeys, + getCurrentUser: mockAuthc.getCurrentUser, + }, + authz: { + actions: mockAuthz.actions, + checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, + checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest, + mode: mockAuthz.mode, + }, + }; +} + +function createApiResponseMock( + apiResponse: Pick, 'body'> & + Partial, 'body'>> +): ApiResponse { + return { + statusCode: null, + headers: null, + warnings: null, + meta: {} as any, + ...apiResponse, + }; +} + export const securityMock = { createSetup: createSetupMock, + createStart: createStartMock, + createApiResponse: createApiResponseMock, }; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 65f9e76c4ee09..b9615eed990f0 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -8,17 +8,20 @@ import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; import { ILegacyCustomClusterClient } from '../../../../src/core/server'; import { ConfigSchema } from './config'; -import { Plugin, PluginSetupDependencies } from './plugin'; +import { Plugin, PluginSetupDependencies, PluginStartDependencies } from './plugin'; import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; import { featuresPluginMock } from '../../features/server/mocks'; import { taskManagerMock } from '../../task_manager/server/mocks'; +import { licensingMock } from '../../licensing/server/mocks'; describe('Security Plugin', () => { let plugin: Plugin; let mockCoreSetup: ReturnType; + let mockCoreStart: ReturnType; let mockClusterClient: jest.Mocked; - let mockDependencies: PluginSetupDependencies; + let mockSetupDependencies: PluginSetupDependencies; + let mockStartDependencies: PluginStartDependencies; beforeEach(() => { plugin = new Plugin( coreMock.createPluginInitializerContext( @@ -43,29 +46,34 @@ describe('Security Plugin', () => { mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - mockDependencies = ({ + mockSetupDependencies = ({ licensing: { license$: of({}), featureUsage: { register: jest.fn() } }, features: featuresPluginMock.createSetup(), taskManager: taskManagerMock.createSetup(), } as unknown) as PluginSetupDependencies; + + mockCoreStart = coreMock.createStart(); + + const mockFeaturesStart = featuresPluginMock.createStart(); + mockFeaturesStart.getKibanaFeatures.mockReturnValue([]); + mockStartDependencies = { + features: mockFeaturesStart, + licensing: licensingMock.createStart(), + taskManager: taskManagerMock.createStart(), + }; }); describe('setup()', () => { it('exposes proper contract', async () => { - await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` + await expect(plugin.setup(mockCoreSetup, mockSetupDependencies)).resolves + .toMatchInlineSnapshot(` Object { "audit": Object { "asScoped": [Function], "getLogger": [Function], }, "authc": Object { - "areAPIKeysEnabled": [Function], - "createAPIKey": [Function], "getCurrentUser": [Function], - "grantAPIKeyAsInternalUser": [Function], - "invalidateAPIKey": [Function], - "invalidateAPIKeyAsInternalUser": [Function], - "isAuthenticated": [Function], }, "authz": Object { "actions": Actions { @@ -119,8 +127,58 @@ describe('Security Plugin', () => { }); }); + describe('start()', () => { + it('exposes proper contract', async () => { + await plugin.setup(mockCoreSetup, mockSetupDependencies); + expect(plugin.start(mockCoreStart, mockStartDependencies)).toMatchInlineSnapshot(` + Object { + "authc": Object { + "apiKeys": Object { + "areAPIKeysEnabled": [Function], + "create": [Function], + "grantAsInternalUser": [Function], + "invalidate": [Function], + "invalidateAsInternalUser": [Function], + }, + "getCurrentUser": [Function], + }, + "authz": Object { + "actions": Actions { + "alerting": AlertingActions { + "prefix": "alerting:version:", + }, + "api": ApiActions { + "prefix": "api:version:", + }, + "app": AppActions { + "prefix": "app:version:", + }, + "login": "login:", + "savedObject": SavedObjectActions { + "prefix": "saved_object:version:", + }, + "space": SpaceActions { + "prefix": "space:version:", + }, + "ui": UIActions { + "prefix": "ui:version:", + }, + "version": "version:version", + "versionNumber": "version", + }, + "checkPrivilegesDynamicallyWithRequest": [Function], + "checkPrivilegesWithRequest": [Function], + "mode": Object { + "useRbacForRequest": [Function], + }, + }, + } + `); + }); + }); + describe('stop()', () => { - beforeEach(async () => await plugin.setup(mockCoreSetup, mockDependencies)); + beforeEach(async () => await plugin.setup(mockCoreSetup, mockSetupDependencies)); it('close does not throw', async () => { await plugin.stop(); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 15d25971800f8..4016b78b6d998 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -7,7 +7,6 @@ import { combineLatest } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { TypeOf } from '@kbn/config-schema'; -import { deepFreeze } from '@kbn/std'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { SecurityOssPluginSetup } from 'src/plugins/security_oss/server'; import { @@ -25,7 +24,11 @@ import { import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import { Authentication, setupAuthentication } from './authentication'; +import { + AuthenticationService, + AuthenticationServiceSetup, + AuthenticationServiceStart, +} from './authentication'; import { AuthorizationService, AuthorizationServiceSetup } from './authorization'; import { ConfigSchema, createConfig } from './config'; import { defineRoutes } from './routes'; @@ -53,16 +56,13 @@ export type FeaturesService = Pick< * Describes public Security plugin contract returned at the `setup` stage. */ export interface SecurityPluginSetup { - authc: Pick< - Authentication, - | 'isAuthenticated' - | 'getCurrentUser' - | 'areAPIKeysEnabled' - | 'createAPIKey' - | 'invalidateAPIKey' - | 'grantAPIKeyAsInternalUser' - | 'invalidateAPIKeyAsInternalUser' - >; + /** + * @deprecated Use `authc` methods from the `SecurityServiceStart` contract instead. + */ + authc: Pick; + /** + * @deprecated Use `authz` methods from the `SecurityServiceStart` contract instead. + */ authz: Pick< AuthorizationServiceSetup, 'actions' | 'checkPrivilegesDynamicallyWithRequest' | 'checkPrivilegesWithRequest' | 'mode' @@ -71,6 +71,17 @@ export interface SecurityPluginSetup { audit: AuditServiceSetup; } +/** + * Describes public Security plugin contract returned at the `start` stage. + */ +export interface SecurityPluginStart { + authc: Pick; + authz: Pick< + AuthorizationServiceSetup, + 'actions' | 'checkPrivilegesDynamicallyWithRequest' | 'checkPrivilegesWithRequest' | 'mode' + >; +} + export interface PluginSetupDependencies { features: FeaturesPluginSetup; licensing: LicensingPluginSetup; @@ -93,7 +104,8 @@ export interface PluginStartDependencies { export class Plugin { private readonly logger: Logger; private securityLicenseService?: SecurityLicenseService; - private authc?: Authentication; + private authenticationStart?: AuthenticationServiceStart; + private authorizationSetup?: AuthorizationServiceSetup; private readonly featureUsageService = new SecurityFeatureUsageService(); private featureUsageServiceStart?: SecurityFeatureUsageServiceStart; @@ -112,6 +124,9 @@ export class Plugin { private readonly sessionManagementService = new SessionManagementService( this.initializerContext.logger.get('session') ); + private readonly authenticationService = new AuthenticationService( + this.initializerContext.logger.get('authentication') + ); constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -179,7 +194,7 @@ export class Plugin { logging: core.logging, http: core.http, getSpaceId: (request) => spaces?.spacesService.getSpaceId(request), - getCurrentUser: (request) => this.authc?.getCurrentUser(request), + getCurrentUser: (request) => authenticationSetup.getCurrentUser(request), }); const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger()); @@ -191,7 +206,7 @@ export class Plugin { taskManager, }); - this.authc = await setupAuthentication({ + const authenticationSetup = this.authenticationService.setup({ legacyAuditLogger, audit, getFeatureUsageService: this.getFeatureUsageService, @@ -203,7 +218,7 @@ export class Plugin { session, }); - const authz = this.authorizationService.setup({ + this.authorizationSetup = this.authorizationService.setup({ http: core.http, capabilities: core.capabilities, getClusterClient: () => @@ -215,19 +230,19 @@ export class Plugin { buildNumber: this.initializerContext.env.packageInfo.buildNum, getSpacesService: () => spaces?.spacesService, features, - getCurrentUser: this.authc.getCurrentUser, + getCurrentUser: authenticationSetup.getCurrentUser, }); setupSpacesClient({ spaces, audit, - authz, + authz: this.authorizationSetup, }); setupSavedObjects({ legacyAuditLogger, audit, - authz, + authz: this.authorizationSetup, savedObjects: core.savedObjects, getSpacesService: () => spaces?.spacesService, }); @@ -238,36 +253,35 @@ export class Plugin { httpResources: core.http.resources, logger: this.initializerContext.logger.get('routes'), config, - authc: this.authc, - authz, + authz: this.authorizationSetup, license, session, getFeatures: () => startServicesPromise.then((services) => services.features.getKibanaFeatures()), getFeatureUsageService: this.getFeatureUsageService, + getAuthenticationService: () => { + if (!this.authenticationStart) { + throw new Error('Authentication service is not started!'); + } + + return this.authenticationStart; + }, }); - return deepFreeze({ + return Object.freeze({ audit: { asScoped: audit.asScoped, getLogger: audit.getLogger, }, - authc: { - isAuthenticated: this.authc.isAuthenticated, - getCurrentUser: this.authc.getCurrentUser, - areAPIKeysEnabled: this.authc.areAPIKeysEnabled, - createAPIKey: this.authc.createAPIKey, - invalidateAPIKey: this.authc.invalidateAPIKey, - grantAPIKeyAsInternalUser: this.authc.grantAPIKeyAsInternalUser, - invalidateAPIKeyAsInternalUser: this.authc.invalidateAPIKeyAsInternalUser, - }, + authc: { getCurrentUser: authenticationSetup.getCurrentUser }, authz: { - actions: authz.actions, - checkPrivilegesWithRequest: authz.checkPrivilegesWithRequest, - checkPrivilegesDynamicallyWithRequest: authz.checkPrivilegesDynamicallyWithRequest, - mode: authz.mode, + actions: this.authorizationSetup.actions, + checkPrivilegesWithRequest: this.authorizationSetup.checkPrivilegesWithRequest, + checkPrivilegesDynamicallyWithRequest: this.authorizationSetup + .checkPrivilegesDynamicallyWithRequest, + mode: this.authorizationSetup.mode, }, license, @@ -281,13 +295,29 @@ export class Plugin { featureUsage: licensing.featureUsage, }); + const clusterClient = core.elasticsearch.client; const { watchOnlineStatus$ } = this.elasticsearchService.start(); this.sessionManagementService.start({ online$: watchOnlineStatus$(), taskManager }); - this.authorizationService.start({ - features, - clusterClient: core.elasticsearch.client, - online$: watchOnlineStatus$(), + this.authenticationStart = this.authenticationService.start({ + http: core.http, + clusterClient, + }); + + this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); + + return Object.freeze({ + authc: { + apiKeys: this.authenticationStart.apiKeys, + getCurrentUser: this.authenticationStart.getCurrentUser, + }, + authz: { + actions: this.authorizationSetup!.actions, + checkPrivilegesWithRequest: this.authorizationSetup!.checkPrivilegesWithRequest, + checkPrivilegesDynamicallyWithRequest: this.authorizationSetup! + .checkPrivilegesDynamicallyWithRequest, + mode: this.authorizationSetup!.mode, + }, }); } diff --git a/x-pack/plugins/security/server/routes/api_keys/enabled.test.ts b/x-pack/plugins/security/server/routes/api_keys/enabled.test.ts index 3a22b9fe003a1..53950cb431941 100644 --- a/x-pack/plugins/security/server/routes/api_keys/enabled.test.ts +++ b/x-pack/plugins/security/server/routes/api_keys/enabled.test.ts @@ -5,6 +5,7 @@ */ import Boom from '@hapi/boom'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { kibanaResponseFactory, RequestHandler, @@ -14,8 +15,9 @@ import { import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../index.mock'; +import type { AuthenticationServiceStart } from '../../authentication'; import { defineEnabledApiKeysRoutes } from './enabled'; -import { Authentication } from '../../authentication'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; describe('API keys enabled', () => { function getMockContext( @@ -27,10 +29,11 @@ describe('API keys enabled', () => { } let routeHandler: RequestHandler; - let authc: jest.Mocked; + let authc: DeeplyMockedKeys; beforeEach(() => { + authc = authenticationServiceMock.createStart(); const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); - authc = mockRouteDefinitionParams.authc; + mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc); defineEnabledApiKeysRoutes(mockRouteDefinitionParams); @@ -56,7 +59,7 @@ describe('API keys enabled', () => { test('returns error from cluster client', async () => { const error = Boom.notAcceptable('test not acceptable message'); - authc.areAPIKeysEnabled.mockRejectedValue(error); + authc.apiKeys.areAPIKeysEnabled.mockRejectedValue(error); const response = await routeHandler( getMockContext(), @@ -71,7 +74,7 @@ describe('API keys enabled', () => { describe('success', () => { test('returns true if API Keys are enabled', async () => { - authc.areAPIKeysEnabled.mockResolvedValue(true); + authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(true); const response = await routeHandler( getMockContext(), @@ -84,7 +87,7 @@ describe('API keys enabled', () => { }); test('returns false if API Keys are disabled', async () => { - authc.areAPIKeysEnabled.mockResolvedValue(false); + authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(false); const response = await routeHandler( getMockContext(), diff --git a/x-pack/plugins/security/server/routes/api_keys/enabled.ts b/x-pack/plugins/security/server/routes/api_keys/enabled.ts index 2f5b8343bcd89..340a54bdd6d92 100644 --- a/x-pack/plugins/security/server/routes/api_keys/enabled.ts +++ b/x-pack/plugins/security/server/routes/api_keys/enabled.ts @@ -8,7 +8,10 @@ import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; import { RouteDefinitionParams } from '..'; -export function defineEnabledApiKeysRoutes({ router, authc }: RouteDefinitionParams) { +export function defineEnabledApiKeysRoutes({ + router, + getAuthenticationService, +}: RouteDefinitionParams) { router.get( { path: '/internal/security/api_key/_enabled', @@ -16,7 +19,7 @@ export function defineEnabledApiKeysRoutes({ router, authc }: RouteDefinitionPar }, createLicensedRouteHandler(async (context, request, response) => { try { - const apiKeysEnabled = await authc.areAPIKeysEnabled(); + const apiKeysEnabled = await getAuthenticationService().apiKeys.areAPIKeysEnabled(); return response.ok({ body: { apiKeysEnabled } }); } catch (error) { diff --git a/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts b/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts index b06d1329dc1db..3f1c042d3580b 100644 --- a/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts +++ b/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts @@ -11,6 +11,7 @@ import { kibanaResponseFactory } from '../../../../../../src/core/server'; import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../index.mock'; import { defineCheckPrivilegesRoutes } from './privileges'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; interface TestOptions { licenseCheckResult?: LicenseCheck; @@ -36,7 +37,9 @@ describe('Check API keys privileges', () => { licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } } as any, }; - mockRouteDefinitionParams.authc.areAPIKeysEnabled.mockResolvedValue(areAPIKeysEnabled); + const authc = authenticationServiceMock.createStart(); + authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(areAPIKeysEnabled); + mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc); if (apiResponse) { mockContext.core.elasticsearch.client.asCurrentUser.security.hasPrivileges.mockImplementation( diff --git a/x-pack/plugins/security/server/routes/api_keys/privileges.ts b/x-pack/plugins/security/server/routes/api_keys/privileges.ts index dd5d81060c7e5..d6ecb41998273 100644 --- a/x-pack/plugins/security/server/routes/api_keys/privileges.ts +++ b/x-pack/plugins/security/server/routes/api_keys/privileges.ts @@ -8,7 +8,10 @@ import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; import { RouteDefinitionParams } from '..'; -export function defineCheckPrivilegesRoutes({ router, authc }: RouteDefinitionParams) { +export function defineCheckPrivilegesRoutes({ + router, + getAuthenticationService, +}: RouteDefinitionParams) { router.get( { path: '/internal/security/api_key/privileges', @@ -37,7 +40,7 @@ export function defineCheckPrivilegesRoutes({ router, authc }: RouteDefinitionPa }>({ body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] }, }), - authc.areAPIKeysEnabled(), + getAuthenticationService().apiKeys.areAPIKeysEnabled(), ]); const isAdmin = manageSecurity || manageApiKey; diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts index 8d800595d28ed..b032930e4400a 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -5,6 +5,7 @@ */ import { Type } from '@kbn/config-schema'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { IRouter, kibanaResponseFactory, @@ -12,10 +13,10 @@ import { RequestHandlerContext, RouteConfig, } from '../../../../../../src/core/server'; -import { SecurityLicense, SecurityLicenseFeatures } from '../../../common/licensing'; +import type { SecurityLicense, SecurityLicenseFeatures } from '../../../common/licensing'; import { - Authentication, AuthenticationResult, + AuthenticationServiceStart, DeauthenticationResult, OIDCLogin, SAMLLogin, @@ -25,17 +26,19 @@ import { defineCommonRoutes } from './common'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { routeDefinitionParamsMock } from '../index.mock'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; describe('Common authentication routes', () => { let router: jest.Mocked; - let authc: jest.Mocked; + let authc: DeeplyMockedKeys; let license: jest.Mocked; let mockContext: RequestHandlerContext; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; - authc = routeParamsMock.authc; license = routeParamsMock.license; + authc = authenticationServiceMock.createStart(); + routeParamsMock.getAuthenticationService.mockReturnValue(authc); mockContext = ({ licensing: { diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index a37f20c9ef82c..e6370eaca3426 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -24,7 +24,7 @@ import { RouteDefinitionParams } from '..'; */ export function defineCommonRoutes({ router, - authc, + getAuthenticationService, basePath, license, logger, @@ -55,7 +55,7 @@ export function defineCommonRoutes({ } try { - const deauthenticationResult = await authc.logout(request); + const deauthenticationResult = await getAuthenticationService().logout(request); if (deauthenticationResult.failed()) { return response.customError(wrapIntoCustomErrorResponse(deauthenticationResult.error)); } @@ -82,7 +82,7 @@ export function defineCommonRoutes({ ); } - return response.ok({ body: authc.getCurrentUser(request)! }); + return response.ok({ body: getAuthenticationService().getCurrentUser(request)! }); }) ); } @@ -142,7 +142,7 @@ export function defineCommonRoutes({ const redirectURL = parseNext(currentURL, basePath.serverBasePath); try { - const authenticationResult = await authc.login(request, { + const authenticationResult = await getAuthenticationService().login(request, { provider: { name: providerName }, redirectURL, value: getLoginAttemptForProviderType(providerType, redirectURL, params), @@ -178,7 +178,7 @@ export function defineCommonRoutes({ } try { - await authc.acknowledgeAccessAgreement(request); + await getAuthenticationService().acknowledgeAccessAgreement(request); } catch (err) { logger.error(err); return response.internalError(); diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index 6527fd0220584..0ab0d026851cf 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -12,11 +12,11 @@ import { RouteDefinitionParams } from '..'; export function defineAuthenticationRoutes(params: RouteDefinitionParams) { defineCommonRoutes(params); - if (params.authc.isProviderTypeEnabled('saml')) { + if (params.config.authc.sortedProviders.some(({ type }) => type === 'saml')) { defineSAMLRoutes(params); } - if (params.authc.isProviderTypeEnabled('oidc')) { + if (params.config.authc.sortedProviders.some(({ type }) => type === 'oidc')) { defineOIDCRoutes(params); } } diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts index 7eaa619b330e0..75dbbe7c7a985 100644 --- a/x-pack/plugins/security/server/routes/authentication/oidc.ts +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -23,7 +23,7 @@ export function defineOIDCRoutes({ router, httpResources, logger, - authc, + getAuthenticationService, basePath, }: RouteDefinitionParams) { // Generate two identical routes with new and deprecated URL and issue a warning if route with deprecated URL is ever used. @@ -241,7 +241,7 @@ export function defineOIDCRoutes({ try { // We handle the fact that the user might get redirected to Kibana while already having a session // Return an error notifying the user they are already logged in. - const authenticationResult = await authc.login(request, { + const authenticationResult = await getAuthenticationService().login(request, { provider: { type: OIDCAuthenticationProvider.type }, value: loginAttempt, }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index 5f5161126f215..d1d5f601d7a43 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -5,21 +5,24 @@ */ import { Type } from '@kbn/config-schema'; -import { Authentication, AuthenticationResult, SAMLLogin } from '../../authentication'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { AuthenticationResult, AuthenticationServiceStart, SAMLLogin } from '../../authentication'; import { defineSAMLRoutes } from './saml'; -import { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; +import type { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { routeDefinitionParamsMock } from '../index.mock'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; describe('SAML authentication routes', () => { let router: jest.Mocked; - let authc: jest.Mocked; + let authc: DeeplyMockedKeys; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; - authc = routeParamsMock.authc; + authc = authenticationServiceMock.createStart(); + routeParamsMock.getAuthenticationService.mockReturnValue(authc); defineSAMLRoutes(routeParamsMock); }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 58ec7f559bc28..ca586712e40ed 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -12,7 +12,11 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -export function defineSAMLRoutes({ router, logger, authc }: RouteDefinitionParams) { +export function defineSAMLRoutes({ + router, + logger, + getAuthenticationService, +}: RouteDefinitionParams) { router.post( { path: '/api/security/saml/callback', @@ -27,7 +31,7 @@ export function defineSAMLRoutes({ router, logger, authc }: RouteDefinitionParam async (context, request, response) => { try { // When authenticating using SAML we _expect_ to redirect to the Kibana target location. - const authenticationResult = await authc.login(request, { + const authenticationResult = await getAuthenticationService().login(request, { provider: { type: SAMLAuthenticationProvider.type }, value: { type: SAMLLogin.LoginWithSAMLResponse, diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 1df499d981632..4103594faba15 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -4,18 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import { httpServiceMock, loggingSystemMock, httpResourcesMock, } from '../../../../../src/core/server/mocks'; -import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; +import { authenticationServiceMock } from '../authentication/authentication_service.mock'; import { sessionMock } from '../session_management/session.mock'; -import { RouteDefinitionParams } from '.'; -import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { RouteDefinitionParams } from '.'; export const routeDefinitionParamsMock = { create: (config: Record = {}) => @@ -27,12 +27,12 @@ export const routeDefinitionParamsMock = { config: createConfig(ConfigSchema.validate(config), loggingSystemMock.create().get(), { isTLSEnabled: false, }), - authc: authenticationMock.create(), authz: authorizationMock.create(), license: licenseMock.create(), httpResources: httpResourcesMock.createRegistrar(), getFeatures: jest.fn(), getFeatureUsageService: jest.fn(), session: sessionMock.create(), + getAuthenticationService: jest.fn().mockReturnValue(authenticationServiceMock.createStart()), } as unknown) as DeeplyMockedKeys), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index db71b04b3e6f0..899215c49fa9f 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { KibanaFeature } from '../../../features/server'; -import { HttpResources, IBasePath, IRouter, Logger } from '../../../../../src/core/server'; -import { SecurityLicense } from '../../common/licensing'; -import { Authentication } from '../authentication'; -import { AuthorizationServiceSetup } from '../authorization'; -import { ConfigType } from '../config'; +import type { KibanaFeature } from '../../../features/server'; +import type { HttpResources, IBasePath, IRouter, Logger } from '../../../../../src/core/server'; +import type { SecurityLicense } from '../../common/licensing'; +import type { AuthenticationServiceStart } from '../authentication'; +import type { AuthorizationServiceSetup } from '../authorization'; +import type { ConfigType } from '../config'; +import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import type { Session } from '../session_management'; import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; @@ -19,8 +21,6 @@ import { defineUsersRoutes } from './users'; import { defineRoleMappingRoutes } from './role_mapping'; import { defineSessionManagementRoutes } from './session_management'; import { defineViewRoutes } from './views'; -import { SecurityFeatureUsageServiceStart } from '../feature_usage'; -import { Session } from '../session_management'; /** * Describes parameters used to define HTTP routes. @@ -31,12 +31,12 @@ export interface RouteDefinitionParams { httpResources: HttpResources; logger: Logger; config: ConfigType; - authc: Authentication; authz: AuthorizationServiceSetup; session: PublicMethodsOf; license: SecurityLicense; getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; + getAuthenticationService: () => AuthenticationServiceStart; } export function defineRoutes(params: RouteDefinitionParams) { diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index d98c0acb7d86d..f7e5e76f70b72 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -16,7 +16,7 @@ import { RequestHandlerContext, RouteConfig, } from '../../../../../../src/core/server'; -import { Authentication, AuthenticationResult } from '../../authentication'; +import { AuthenticationResult, AuthenticationServiceStart } from '../../authentication'; import { Session } from '../../session_management'; import { defineChangeUserPasswordRoutes } from './change_password'; @@ -24,10 +24,11 @@ import { coreMock, httpServerMock } from '../../../../../../src/core/server/mock import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { sessionMock } from '../../session_management/session.mock'; import { routeDefinitionParamsMock } from '../index.mock'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; describe('Change password', () => { let router: jest.Mocked; - let authc: jest.Mocked; + let authc: DeeplyMockedKeys; let session: jest.Mocked>; let routeHandler: RequestHandler; let routeConfig: RouteConfig; @@ -48,8 +49,9 @@ describe('Change password', () => { beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; - authc = routeParamsMock.authc; session = routeParamsMock.session; + authc = authenticationServiceMock.createStart(); + routeParamsMock.getAuthenticationService.mockReturnValue(authc); authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser(mockAuthenticatedUser())); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index 66d36b4294883..f29edf9c1fc54 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -14,7 +14,11 @@ import { } from '../../authentication'; import { RouteDefinitionParams } from '..'; -export function defineChangeUserPasswordRoutes({ authc, session, router }: RouteDefinitionParams) { +export function defineChangeUserPasswordRoutes({ + getAuthenticationService, + session, + router, +}: RouteDefinitionParams) { router.post( { path: '/internal/security/users/{username}/password', @@ -30,7 +34,7 @@ export function defineChangeUserPasswordRoutes({ authc, session, router }: Route const { username } = request.params; const { password: currentPassword, newPassword } = request.body; - const currentUser = authc.getCurrentUser(request); + const currentUser = getAuthenticationService().getCurrentUser(request); const isUserChangingOwnPassword = currentUser && currentUser.username === username && canUserChangePassword(currentUser); const currentSession = isUserChangingOwnPassword ? await session.get(request) : null; @@ -74,7 +78,7 @@ export function defineChangeUserPasswordRoutes({ authc, session, router }: Route // session and in such cases we shouldn't create a new one. if (isUserChangingOwnPassword && currentSession) { try { - const authenticationResult = await authc.login(request, { + const authenticationResult = await getAuthenticationService().login(request, { provider: { name: currentSession.provider.name }, value: { username, password: newPassword }, }); diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index fa2088a80b183..a84a9ea47d108 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -10,10 +10,9 @@ import { routeDefinitionParamsMock } from '../index.mock'; describe('View routes', () => { it('does not register Login routes if both `basic` and `token` providers are disabled', () => { - const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( - (provider) => provider !== 'basic' && provider !== 'token' - ); + const routeParamsMock = routeDefinitionParamsMock.create({ + authc: { providers: { pki: { pki1: { order: 0 } } } }, + }); defineViewRoutes(routeParamsMock); @@ -36,10 +35,9 @@ describe('View routes', () => { }); it('registers Login routes if `basic` provider is enabled', () => { - const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( - (provider) => provider !== 'token' - ); + const routeParamsMock = routeDefinitionParamsMock.create({ + authc: { providers: { basic: { basic1: { order: 0 } } } }, + }); defineViewRoutes(routeParamsMock); @@ -64,10 +62,9 @@ describe('View routes', () => { }); it('registers Login routes if `token` provider is enabled', () => { - const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( - (provider) => provider !== 'basic' - ); + const routeParamsMock = routeDefinitionParamsMock.create({ + authc: { providers: { token: { token1: { order: 0 } } } }, + }); defineViewRoutes(routeParamsMock); @@ -93,9 +90,8 @@ describe('View routes', () => { it('registers Login routes if Login Selector is enabled even if both `token` and `basic` providers are not enabled', () => { const routeParamsMock = routeDefinitionParamsMock.create({ - authc: { selector: { enabled: true } }, + authc: { selector: { enabled: true }, providers: { pki: { pki1: { order: 0 } } } }, }); - routeParamsMock.authc.isProviderTypeEnabled.mockReturnValue(false); defineViewRoutes(routeParamsMock); diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts index 64d288dfc7c7d..578a9c2386d75 100644 --- a/x-pack/plugins/security/server/routes/views/index.ts +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -16,8 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineViewRoutes(params: RouteDefinitionParams) { if ( params.config.authc.selector.enabled || - params.authc.isProviderTypeEnabled('basic') || - params.authc.isProviderTypeEnabled('token') + params.config.authc.sortedProviders.some(({ type }) => type === 'basic' || type === 'token') ) { defineLoginRoutes(params); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 73e4845924acf..38ac6372fdb9c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -129,10 +129,11 @@ export const getDeleteAsPostBulkRequest = () => body: [{ rule_id: 'rule-1' }], }); -export const getPrivilegeRequest = () => +export const getPrivilegeRequest = (options: { auth?: { isAuthenticated: boolean } } = {}) => requestMock.create({ method: 'get', path: DETECTION_ENGINE_PRIVILEGES_URL, + ...options, }); export const addPrepackagedRulesRequest = () => diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts index cb4ec99748e47..945be0c584134 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { securityMock } from '../../../../../../security/server/mocks'; import { readPrivilegesRoute } from './read_privileges_route'; import { serverMock, requestContextMock } from '../__mocks__'; import { getPrivilegeRequest, getMockPrivilegesResult } from '../__mocks__/request_responses'; @@ -12,26 +11,29 @@ import { getPrivilegeRequest, getMockPrivilegesResult } from '../__mocks__/reque describe('read_privileges route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let mockSecurity: ReturnType; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - mockSecurity = securityMock.createSetup(); - mockSecurity.authc.isAuthenticated.mockReturnValue(false); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getMockPrivilegesResult()); - readPrivilegesRoute(server.router, mockSecurity, false); + readPrivilegesRoute(server.router, false); }); describe('normal status codes', () => { test('returns 200 when doing a normal request', async () => { - const response = await server.inject(getPrivilegeRequest(), context); + const response = await server.inject( + getPrivilegeRequest({ auth: { isAuthenticated: false } }), + context + ); expect(response.status).toEqual(200); }); test('returns the payload when doing a normal request', async () => { - const response = await server.inject(getPrivilegeRequest(), context); + const response = await server.inject( + getPrivilegeRequest({ auth: { isAuthenticated: false } }), + context + ); const expectedBody = { ...getMockPrivilegesResult(), is_authenticated: false, @@ -42,14 +44,16 @@ describe('read_privileges route', () => { }); test('is authenticated when security says so', async () => { - mockSecurity.authc.isAuthenticated.mockReturnValue(true); const expectedBody = { ...getMockPrivilegesResult(), is_authenticated: true, has_encryption_key: true, }; - const response = await server.inject(getPrivilegeRequest(), context); + const response = await server.inject( + getPrivilegeRequest({ auth: { isAuthenticated: true } }), + context + ); expect(response.status).toEqual(200); expect(response.body).toEqual(expectedBody); }); @@ -58,38 +62,22 @@ describe('read_privileges route', () => { clients.clusterClient.callAsCurrentUser.mockImplementation(() => { throw new Error('Test error'); }); - const response = await server.inject(getPrivilegeRequest(), context); + const response = await server.inject( + getPrivilegeRequest({ auth: { isAuthenticated: false } }), + context + ); expect(response.status).toEqual(500); expect(response.body).toEqual({ message: 'Test error', status_code: 500 }); }); it('returns 404 if siem client is unavailable', async () => { const { securitySolution, ...contextWithoutSecuritySolution } = context; - const response = await server.inject(getPrivilegeRequest(), contextWithoutSecuritySolution); + const response = await server.inject( + getPrivilegeRequest({ auth: { isAuthenticated: false } }), + contextWithoutSecuritySolution + ); expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); }); - - describe('when security plugin is disabled', () => { - beforeEach(() => { - server = serverMock.create(); - ({ clients, context } = requestContextMock.createTools()); - - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getMockPrivilegesResult()); - readPrivilegesRoute(server.router, undefined, false); - }); - - it('returns unauthenticated', async () => { - const expectedBody = { - ...getMockPrivilegesResult(), - is_authenticated: false, - has_encryption_key: true, - }; - - const response = await server.inject(getPrivilegeRequest(), context); - expect(response.status).toEqual(200); - expect(response.body).toEqual(expectedBody); - }); - }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts index 715a5be7462d1..174aa4911ba1e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts @@ -8,15 +8,10 @@ import { merge } from 'lodash/fp'; import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_PRIVILEGES_URL } from '../../../../../common/constants'; -import { SetupPlugins } from '../../../../plugin'; import { buildSiemResponse, transformError } from '../utils'; import { readPrivileges } from '../../privileges/read_privileges'; -export const readPrivilegesRoute = ( - router: IRouter, - security: SetupPlugins['security'], - usingEphemeralEncryptionKey: boolean -) => { +export const readPrivilegesRoute = (router: IRouter, usingEphemeralEncryptionKey: boolean) => { router.get( { path: DETECTION_ENGINE_PRIVILEGES_URL, @@ -39,7 +34,7 @@ export const readPrivilegesRoute = ( const index = siemClient.getSignalsIndex(); const clusterPrivileges = await readPrivileges(clusterClient.callAsCurrentUser, index); const privileges = merge(clusterPrivileges, { - is_authenticated: security?.authc.isAuthenticated(request) ?? false, + is_authenticated: request.auth.isAuthenticated ?? false, has_encryption_key: !usingEphemeralEncryptionKey, }); diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 000bd875930f9..3467d0bb66860 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -93,5 +93,5 @@ export const initRoutes = ( readTagsRoute(router); // Privileges API to get the generic user privileges - readPrivilegesRoute(router, security, usingEphemeralEncryptionKey); + readPrivilegesRoute(router, usingEphemeralEncryptionKey); }; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts index d832902fe066d..13e5c66b73460 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts @@ -12,26 +12,23 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../.. import { defineAlertTypes } from './alert_types'; import { defineActionTypes } from './action_types'; import { defineRoutes } from './routes'; -import { SpacesPluginSetup } from '../../../../../../../plugins/spaces/server'; -import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; +import { SpacesPluginStart } from '../../../../../../../plugins/spaces/server'; +import { SecurityPluginStart } from '../../../../../../../plugins/security/server'; export interface FixtureSetupDeps { features: FeaturesPluginSetup; actions: ActionsPluginSetup; alerts: AlertingPluginSetup; - spaces?: SpacesPluginSetup; - security?: SecurityPluginSetup; } export interface FixtureStartDeps { encryptedSavedObjects: EncryptedSavedObjectsPluginStart; + security?: SecurityPluginStart; + spaces?: SpacesPluginStart; } export class FixturePlugin implements Plugin { - public setup( - core: CoreSetup, - { features, actions, alerts, spaces, security }: FixtureSetupDeps - ) { + public setup(core: CoreSetup, { features, actions, alerts }: FixtureSetupDeps) { features.registerKibanaFeature({ id: 'alertsFixture', name: 'Alerts', @@ -108,7 +105,7 @@ export class FixturePlugin implements Plugin, - { spaces, security }: Partial -) { +export function defineRoutes(core: CoreSetup) { const router = core.http.createRouter(); router.put( { @@ -40,13 +37,16 @@ export function defineRoutes( ): Promise> => { const { id } = req.params; + const [ + { savedObjects }, + { encryptedSavedObjects, security, spaces }, + ] = await core.getStartServices(); if (!security) { return res.ok({ body: {}, }); } - const [{ savedObjects }, { encryptedSavedObjects }] = await core.getStartServices(); const encryptedSavedObjectsWithAlerts = await encryptedSavedObjects.getClient({ includedHiddenTypes: ['alert'], }); @@ -70,7 +70,7 @@ export function defineRoutes( // Create an API key using the new grant API - in this case the Kibana system user is creating the // API key for the user, instead of having the user create it themselves, which requires api_key // privileges - const createAPIKeyResult = await security.authc.grantAPIKeyAsInternalUser(req, { + const createAPIKeyResult = await security.authc.apiKeys.grantAsInternalUser(req, { name: `alert:migrated-to-7.10:${user.username}`, role_descriptors: {}, }); From 3d8b95fe6702048713deb93bd3424cd29097b3f1 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 9 Dec 2020 12:44:33 -0700 Subject: [PATCH 41/52] skip flaky suite (#77969) --- x-pack/test/functional/apps/lens/smokescreen.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index b91399a4a6756..462b385f27e5d 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -13,7 +13,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const testSubjects = getService('testSubjects'); - describe('lens smokescreen tests', () => { + // FLAKY: https://github.com/elastic/kibana/issues/77969 + describe.skip('lens smokescreen tests', () => { it('should allow creation of lens xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); From 2355dde1e957afebb0b01f92c9f7817250b4a8db Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 9 Dec 2020 14:50:51 -0500 Subject: [PATCH 42/52] [Fleet] Support editing bool variable in agent policy (#85070) --- .../plugins/fleet/common/types/models/epm.ts | 3 +- .../package_policy_input_var_field.tsx | 31 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 66a2a58a25ac5..96868fa8cfc3b 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -222,6 +222,7 @@ export interface RegistryElasticsearch { 'index_template.mappings'?: object; } +export type RegistryVarType = 'integer' | 'bool' | 'password' | 'text' | 'yaml'; // EPR types this as `[]map[string]interface{}` // which means the official/possible type is Record // but we effectively only see this shape @@ -229,7 +230,7 @@ export interface RegistryVarsEntry { name: string; title?: string; description?: string; - type: string; + type: RegistryVarType; required?: boolean; show_user?: boolean; multi?: boolean; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx index 9d036f5154b8f..883e85620c911 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_var_field.tsx @@ -6,7 +6,14 @@ import React, { useState, memo, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFormRow, EuiFieldText, EuiComboBox, EuiText, EuiCodeEditor } from '@elastic/eui'; +import { + EuiFormRow, + EuiSwitch, + EuiFieldText, + EuiComboBox, + EuiText, + EuiCodeEditor, +} from '@elastic/eui'; import { RegistryVarsEntry } from '../../../../types'; import 'brace/mode/yaml'; @@ -23,6 +30,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ const { multi, required, type, title, name, description } = varDef; const isInvalid = (isDirty || forceShowErrors) && !!varErrors; const errors = isInvalid ? varErrors : null; + const fieldLabel = title || name; const field = useMemo(() => { if (multi) { @@ -59,6 +67,18 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{ /> ); } + if (type === 'bool') { + return ( + onChange(e.target.checked)} + onBlur={() => setIsDirty(true)} + /> + ); + } + return ( setIsDirty(true)} /> ); - }, [isInvalid, multi, onChange, type, value]); + }, [isInvalid, multi, onChange, type, value, fieldLabel]); + + // Boolean cannot be optional by default set to false + const isOptional = type !== 'bool' && !required; return ( Date: Wed, 9 Dec 2020 12:53:14 -0800 Subject: [PATCH 43/52] [CI] Set correct script execute permissions (#85475) Signed-off-by: Tyler Smalley --- test/scripts/checks/jest_configs.sh | 0 test/scripts/checks/mocha_coverage.sh | 0 test/scripts/checks/plugins_with_circular_deps.sh | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 test/scripts/checks/jest_configs.sh mode change 100644 => 100755 test/scripts/checks/mocha_coverage.sh mode change 100644 => 100755 test/scripts/checks/plugins_with_circular_deps.sh diff --git a/test/scripts/checks/jest_configs.sh b/test/scripts/checks/jest_configs.sh old mode 100644 new mode 100755 diff --git a/test/scripts/checks/mocha_coverage.sh b/test/scripts/checks/mocha_coverage.sh old mode 100644 new mode 100755 diff --git a/test/scripts/checks/plugins_with_circular_deps.sh b/test/scripts/checks/plugins_with_circular_deps.sh old mode 100644 new mode 100755 From ac06cb87cd15dea5cccd1f1b320026fa7a95c95b Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 9 Dec 2020 22:50:55 +0100 Subject: [PATCH 44/52] =?UTF-8?q?chore:=20=F0=9F=A4=96=20remove=20extraPub?= =?UTF-8?q?licDirs=20(#85454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/ui_actions/kibana.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/plugins/ui_actions/kibana.json b/src/plugins/ui_actions/kibana.json index 337c5ddf0fd5c..ca979aa021026 100644 --- a/src/plugins/ui_actions/kibana.json +++ b/src/plugins/ui_actions/kibana.json @@ -3,9 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "extraPublicDirs": [ - "public/tests/test_samples" - ], "requiredBundles": [ "kibanaUtils", "kibanaReact" From 5bb47d48b0294c4be5afb07c39307cc04308b7e1 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 9 Dec 2020 15:07:22 -0700 Subject: [PATCH 45/52] [Security Solutions][Detection Engine] Fixes one liner access control with find_rules REST API ## Summary Fixes one liner access control where during the project rename, one got named to `access` instead of `access:securitySolution` --- .../lib/detection_engine/routes/rules/find_rules_route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts index a68e534a2d4ea..b2074ad20b674 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts @@ -28,7 +28,7 @@ export const findRulesRoute = (router: IRouter) => { ), }, options: { - tags: ['access'], + tags: ['access:securitySolution'], }, }, async (context, request, response) => { From 6c52ac84c682d3a3c50075b696b42103e9595245 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 9 Dec 2020 17:50:33 -0500 Subject: [PATCH 46/52] [FLEET] New Integration Policy Details page for use in Integrations section (#85355) * new UI route to show Edit Package Policy page * Package policy List items point to new Integration Policy details page * Refactor to use common service to generate pkgKey * add breadcrumb for edit policy under integrations --- .../tutorial_module_notice.tsx | 3 +- .../fleet/constants/page_paths.ts | 4 + .../fleet/hooks/use_breadcrumbs.tsx | 14 ++ .../components/layout.tsx | 6 +- .../create_package_policy_page/index.tsx | 3 +- .../step_define_package_policy.tsx | 5 +- .../step_select_package.tsx | 5 +- .../create_package_policy_page/types.ts | 2 +- .../edit_package_policy_page/index.tsx | 103 +++++++-- .../epm/components/package_list_grid.tsx | 3 +- .../applications/fleet/sections/epm/index.tsx | 4 + .../epm/screens/detail/index.test.tsx | 205 +++++++++++++++++- .../sections/epm/screens/detail/index.tsx | 3 +- .../screens/detail/package_policies_panel.tsx | 4 +- .../sections/epm/screens/policy/index.tsx | 17 ++ .../services/pkg_key_from_package_info.ts | 11 + 16 files changed, 352 insertions(+), 40 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx index 3430a4eb5b258..6cd701da61e26 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx @@ -8,6 +8,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; import { TutorialModuleNoticeComponent } from 'src/plugins/home/public'; import { useGetPackages, useLink, useCapabilities } from '../../hooks'; +import { pkgKeyFromPackageInfo } from '../../services/pkg_key_from_package_info'; const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName }) => { const { getHref } = useLink(); @@ -41,7 +42,7 @@ const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName } availableAsIntegrationLink: ( '/integrations/installed', integration_details: ({ pkgkey, panel }) => `/integrations/detail/${pkgkey}${panel ? `/${panel}` : ''}`, + integration_policy_edit: ({ packagePolicyId }) => + `/integrations/edit-integration/${packagePolicyId}`, policies: () => '/policies', policies_list: () => '/policies', policy_details: ({ policyId, tabId }) => `/policies/${policyId}${tabId ? `/${tabId}` : ''}`, diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 40654645ecd3f..4feff29896459 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -73,6 +73,20 @@ const breadcrumbGetters: { }, { text: pkgTitle }, ], + integration_policy_edit: ({ pkgTitle, pkgkey, policyName }) => [ + BASE_BREADCRUMB, + { + href: pagePathGetters.integrations(), + text: i18n.translate('xpack.fleet.breadcrumbs.integrationPageTitle', { + defaultMessage: 'Integration', + }), + }, + { + href: pagePathGetters.integration_details({ pkgkey, panel: 'policies' }), + text: pkgTitle, + }, + { text: policyName }, + ], policies: () => [ BASE_BREADCRUMB, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx index 9188f0069b8bf..cac133acd4d2d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx @@ -38,7 +38,7 @@ export const CreatePackagePolicyPageLayout: React.FunctionComponent<{ 'data-test-subj': dataTestSubj, }) => { const pageTitle = useMemo(() => { - if ((from === 'package' || from === 'edit') && packageInfo) { + if ((from === 'package' || from === 'package-edit' || from === 'edit') && packageInfo) { return ( @@ -76,7 +76,7 @@ export const CreatePackagePolicyPageLayout: React.FunctionComponent<{ ); } - return from === 'edit' ? ( + return from === 'edit' || from === 'package-edit' ? (

{ - return from === 'edit' ? ( + return from === 'edit' || from === 'package-edit' ? ( { ? packageInfo && ( ) : agentPolicy && ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx index f6533a06cea27..b7de9d0afe8f5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx @@ -20,6 +20,7 @@ import { AgentPolicy, PackageInfo, PackagePolicy, NewPackagePolicy } from '../.. import { packageToPackagePolicyInputs } from '../../../services'; import { Loading } from '../../../components'; import { PackagePolicyValidationResults } from './services'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; export const StepDefinePackagePolicy: React.FunctionComponent<{ agentPolicy: AgentPolicy; @@ -34,8 +35,8 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ // Update package policy's package and agent policy info useEffect(() => { const pkg = packagePolicy.package; - const currentPkgKey = pkg ? `${pkg.name}-${pkg.version}` : ''; - const pkgKey = `${packageInfo.name}-${packageInfo.version}`; + const currentPkgKey = pkg ? pkgKeyFromPackageInfo(pkg) : ''; + const pkgKey = pkgKeyFromPackageInfo(packageInfo); // If package has changed, create shell package policy with input&stream values based on package info if (currentPkgKey !== pkgKey) { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx index 8c646323c312c..3bcafaecbf8d9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx @@ -16,6 +16,7 @@ import { sendGetPackageInfoByKey, } from '../../../hooks'; import { PackageIcon } from '../../../components/package_icon'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; export const StepSelectPackage: React.FunctionComponent<{ agentPolicyId: string; @@ -32,7 +33,7 @@ export const StepSelectPackage: React.FunctionComponent<{ }) => { // Selected package state const [selectedPkgKey, setSelectedPkgKey] = useState( - packageInfo ? `${packageInfo.name}-${packageInfo.version}` : undefined + packageInfo ? pkgKeyFromPackageInfo(packageInfo) : undefined ); const [selectedPkgError, setSelectedPkgError] = useState(); @@ -92,7 +93,7 @@ export const StepSelectPackage: React.FunctionComponent<{ updatePackageInfo(undefined); } }; - if (!packageInfo || selectedPkgKey !== `${packageInfo.name}-${packageInfo.version}`) { + if (!packageInfo || selectedPkgKey !== pkgKeyFromPackageInfo(packageInfo)) { fetchPackageInfo(); } }, [selectedPkgKey, packageInfo, updatePackageInfo, setIsLoadingSecondStep]); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts index c6e16c2cb4d97..7eb5d95c1ab05 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export type CreatePackagePolicyFrom = 'package' | 'policy' | 'edit'; +export type CreatePackagePolicyFrom = 'package' | 'package-edit' | 'policy' | 'edit'; export type PackagePolicyFormState = 'VALID' | 'INVALID' | 'CONFIRM' | 'LOADING' | 'SUBMITTED'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 56950d155f782..26f99bd88a923 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { useRouteMatch, useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -45,15 +45,24 @@ import { useUIExtension } from '../../../hooks/use_ui_extension'; import { ExtensionWrapper } from '../../../components/extension_wrapper'; import { GetOnePackagePolicyResponse } from '../../../../../../common/types/rest_spec'; import { PackagePolicyEditExtensionComponentProps } from '../../../types'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; -export const EditPackagePolicyPage: React.FunctionComponent = () => { +export const EditPackagePolicyPage = memo(() => { + const { + params: { packagePolicyId }, + } = useRouteMatch<{ policyId: string; packagePolicyId: string }>(); + + return ; +}); + +export const EditPackagePolicyForm = memo<{ + packagePolicyId: string; + from?: CreatePackagePolicyFrom; +}>(({ packagePolicyId, from = 'edit' }) => { const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); - const { - params: { policyId, packagePolicyId }, - } = useRouteMatch<{ policyId: string; packagePolicyId: string }>(); const history = useHistory(); const { getHref, getPath } = useLink(); @@ -76,16 +85,31 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { GetOnePackagePolicyResponse['item'] >(); + const policyId = agentPolicy?.id ?? ''; + // Retrieve agent policy, package, and package policy info useEffect(() => { const getData = async () => { setIsLoadingData(true); setLoadingError(undefined); try { - const [{ data: agentPolicyData }, { data: packagePolicyData }] = await Promise.all([ - sendGetOneAgentPolicy(policyId), - sendGetOnePackagePolicy(packagePolicyId), - ]); + const { + data: packagePolicyData, + error: packagePolicyError, + } = await sendGetOnePackagePolicy(packagePolicyId); + + if (packagePolicyError) { + throw packagePolicyError; + } + + const { data: agentPolicyData, error: agentPolicyError } = await sendGetOneAgentPolicy( + packagePolicyData!.item.policy_id + ); + + if (agentPolicyError) { + throw agentPolicyError; + } + if (agentPolicyData?.item) { setAgentPolicy(agentPolicyData.item); } @@ -123,7 +147,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { setPackagePolicy(newPackagePolicy); if (packagePolicyData.item.package) { const { data: packageData } = await sendGetPackageInfoByKey( - `${packagePolicyData.item.package.name}-${packagePolicyData.item.package.version}` + pkgKeyFromPackageInfo(packagePolicyData.item.package) ); if (packageData?.response) { setPackageInfo(packageData.response); @@ -150,7 +174,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { } }; - if (isFleetEnabled) { + if (isFleetEnabled && policyId) { getAgentCount(); } }, [policyId, isFleetEnabled]); @@ -214,8 +238,32 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { [updatePackagePolicy] ); - // Cancel url - const cancelUrl = getHref('policy_details', { policyId }); + // Cancel url + Success redirect Path: + // if `from === 'edit'` then it links back to Policy Details + // if `from === 'package-edit'` then it links back to the Integration Policy List + const cancelUrl = useMemo((): string => { + if (packageInfo && policyId) { + return from === 'package-edit' + ? getHref('integration_details', { + pkgkey: pkgKeyFromPackageInfo(packageInfo!), + panel: 'policies', + }) + : getHref('policy_details', { policyId }); + } + return '/'; + }, [from, getHref, packageInfo, policyId]); + + const successRedirectPath = useMemo(() => { + if (packageInfo && policyId) { + return from === 'package-edit' + ? getPath('integration_details', { + pkgkey: pkgKeyFromPackageInfo(packageInfo!), + panel: 'policies', + }) + : getPath('policy_details', { policyId }); + } + return '/'; + }, [from, getPath, packageInfo, policyId]); // Save package policy const [formState, setFormState] = useState('INVALID'); @@ -237,7 +285,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { } const { error } = await savePackagePolicy(); if (!error) { - history.push(getPath('policy_details', { policyId })); + history.push(successRedirectPath); notifications.toasts.addSuccess({ title: i18n.translate('xpack.fleet.editPackagePolicy.updatedNotificationTitle', { defaultMessage: `Successfully updated '{packagePolicyName}'`, @@ -287,7 +335,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { }; const layoutProps = { - from: 'edit' as CreatePackagePolicyFrom, + from, cancelUrl, agentPolicy, packageInfo, @@ -363,13 +411,21 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { error={ loadingError || i18n.translate('xpack.fleet.editPackagePolicy.errorLoadingDataMessage', { - defaultMessage: 'There was an error loading this intergration information', + defaultMessage: 'There was an error loading this integration information', }) } /> ) : ( <> - + {from === 'package' || from === 'package-edit' ? ( + + ) : ( + + )} {formState === 'CONFIRM' && ( { )} ); -}; +}); -const Breadcrumb: React.FunctionComponent<{ policyName: string; policyId: string }> = ({ +const PoliciesBreadcrumb: React.FunctionComponent<{ policyName: string; policyId: string }> = ({ policyName, policyId, }) => { useBreadcrumbs('edit_integration', { policyName, policyId }); return null; }; + +const IntegrationsBreadcrumb = memo<{ + pkgTitle: string; + policyName: string; + pkgkey: string; +}>(({ pkgTitle, policyName, pkgkey }) => { + useBreadcrumbs('integration_policy_edit', { policyName, pkgTitle, pkgkey }); + return null; +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx index b96fda2c23af1..42e4a6051d725 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx @@ -21,6 +21,7 @@ import { Loading } from '../../../components'; import { PackageList } from '../../../types'; import { useLocalSearch, searchIdField } from '../hooks'; import { PackageCard } from './package_card'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; interface ListProps { isLoading?: boolean; @@ -118,7 +119,7 @@ function GridColumn({ list }: GridColumnProps) { {list.length ? ( list.map((item) => ( - + )) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx index 8884d1f9d7a75..733aa9dfcf8aa 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx @@ -11,6 +11,7 @@ import { useBreadcrumbs } from '../../hooks'; import { CreatePackagePolicyPage } from '../agent_policy/create_package_policy_page'; import { EPMHomePage } from './screens/home'; import { Detail } from './screens/detail'; +import { Policy } from './screens/policy'; export const EPMApp: React.FunctionComponent = () => { useBreadcrumbs('integrations'); @@ -20,6 +21,9 @@ export const EPMApp: React.FunctionComponent = () => { + + + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx index 7ed14a27c32cf..3d43725f2dc71 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx @@ -10,17 +10,26 @@ import React, { lazy, memo } from 'react'; import { PAGE_ROUTING_PATHS, pagePathGetters } from '../../../../constants'; import { Route } from 'react-router-dom'; import { + GetAgentPoliciesResponse, GetFleetStatusResponse, GetInfoResponse, + GetPackagePoliciesResponse, } from '../../../../../../../common/types/rest_spec'; import { DetailViewPanelName, KibanaAssetType } from '../../../../../../../common/types/models'; -import { epmRouteService, fleetSetupRouteService } from '../../../../../../../common/services'; -import { act } from '@testing-library/react'; +import { + agentPolicyRouteService, + epmRouteService, + fleetSetupRouteService, + packagePolicyRouteService, +} from '../../../../../../../common/services'; +import { act, cleanup } from '@testing-library/react'; describe('when on integration detail', () => { - const detailPageUrlPath = pagePathGetters.integration_details({ pkgkey: 'nginx-0.3.7' }); + const pkgkey = 'nginx-0.3.7'; + const detailPageUrlPath = pagePathGetters.integration_details({ pkgkey }); let testRenderer: TestRenderer; let renderResult: ReturnType; + let mockedApi: MockedApi; const render = () => (renderResult = testRenderer.render( @@ -30,10 +39,15 @@ describe('when on integration detail', () => { beforeEach(() => { testRenderer = createTestRendererMock(); - mockApiCalls(testRenderer.startServices.http); + mockedApi = mockApiCalls(testRenderer.startServices.http); testRenderer.history.push(detailPageUrlPath); }); + afterEach(() => { + cleanup(); + window.location.hash = '#/'; + }); + describe('and a custom UI extension is NOT registered', () => { beforeEach(() => render()); @@ -137,9 +151,48 @@ describe('when on integration detail', () => { }); }); }); + + describe('and on the Policies Tab', () => { + const policiesTabURLPath = pagePathGetters.integration_details({ pkgkey, panel: 'policies' }); + beforeEach(() => { + testRenderer.history.push(policiesTabURLPath); + render(); + }); + + it('should display policies list', () => { + const table = renderResult.getByTestId('integrationPolicyTable'); + expect(table).not.toBeNull(); + }); + + it('should link to integration policy detail when an integration policy is clicked', async () => { + await mockedApi.waitForApi(); + const firstPolicy = renderResult.getByTestId('integrationNameLink') as HTMLAnchorElement; + expect(firstPolicy.href).toEqual( + 'http://localhost/mock/app/fleet#/integrations/edit-integration/e8a37031-2907-44f6-89d2-98bd493f60dc' + ); + }); + }); }); -const mockApiCalls = (http: MockedFleetStartServices['http']) => { +interface MockedApi { + /** Will return a promise that resolves when triggered APIs are complete */ + waitForApi: () => Promise; +} + +const mockApiCalls = (http: MockedFleetStartServices['http']): MockedApi => { + let inflightApiCalls = 0; + const apiDoneListeners: Array<() => void> = []; + const markApiCallAsHandled = async () => { + inflightApiCalls++; + await new Promise((r) => setTimeout(r, 1)); + inflightApiCalls--; + + // If no more pending API calls, then notify listeners + if (inflightApiCalls === 0 && apiDoneListeners.length > 0) { + apiDoneListeners.splice(0).forEach((listener) => listener()); + } + }; + // @ts-ignore const epmPackageResponse: GetInfoResponse = { response: { @@ -369,7 +422,7 @@ const mockApiCalls = (http: MockedFleetStartServices['http']) => { owner: { github: 'elastic/integrations-services' }, latestVersion: '0.3.7', removable: true, - status: 'not_installed', + status: 'installed', }, } as GetInfoResponse; @@ -388,24 +441,162 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos const agentsSetupResponse: GetFleetStatusResponse = { isReady: true, missing_requirements: [] }; + const packagePoliciesResponse: GetPackagePoliciesResponse = { + items: [ + { + id: 'e8a37031-2907-44f6-89d2-98bd493f60dc', + version: 'WzgzMiwxXQ==', + name: 'nginx-1', + description: '', + namespace: 'default', + policy_id: '521c1b70-3976-11eb-ad1c-3baa423084d9', + enabled: true, + output_id: '', + inputs: [ + { + type: 'logfile', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'nginx.access' }, + vars: { paths: { value: ['/var/log/nginx/access.log*'], type: 'text' } }, + id: 'logfile-nginx.access-e8a37031-2907-44f6-89d2-98bd493f60dc', + compiled_stream: { + paths: ['/var/log/nginx/access.log*'], + exclude_files: ['.gz$'], + processors: [{ add_locale: null }], + }, + }, + { + enabled: true, + data_stream: { type: 'logs', dataset: 'nginx.error' }, + vars: { paths: { value: ['/var/log/nginx/error.log*'], type: 'text' } }, + id: 'logfile-nginx.error-e8a37031-2907-44f6-89d2-98bd493f60dc', + compiled_stream: { + paths: ['/var/log/nginx/error.log*'], + exclude_files: ['.gz$'], + multiline: { + pattern: '^\\d{4}\\/\\d{2}\\/\\d{2} ', + negate: true, + match: 'after', + }, + processors: [{ add_locale: null }], + }, + }, + { + enabled: false, + data_stream: { type: 'logs', dataset: 'nginx.ingress_controller' }, + vars: { paths: { value: ['/var/log/nginx/ingress.log*'], type: 'text' } }, + id: 'logfile-nginx.ingress_controller-e8a37031-2907-44f6-89d2-98bd493f60dc', + }, + ], + }, + { + type: 'nginx/metrics', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'metrics', dataset: 'nginx.stubstatus' }, + vars: { + period: { value: '10s', type: 'text' }, + server_status_path: { value: '/nginx_status', type: 'text' }, + }, + id: 'nginx/metrics-nginx.stubstatus-e8a37031-2907-44f6-89d2-98bd493f60dc', + compiled_stream: { + metricsets: ['stubstatus'], + hosts: ['http://127.0.0.1:80'], + period: '10s', + server_status_path: '/nginx_status', + }, + }, + ], + vars: { hosts: { value: ['http://127.0.0.1:80'], type: 'text' } }, + }, + ], + package: { name: 'nginx', title: 'Nginx', version: '0.3.7' }, + revision: 1, + created_at: '2020-12-09T13:46:31.013Z', + created_by: 'elastic', + updated_at: '2020-12-09T13:46:31.013Z', + updated_by: 'elastic', + }, + ], + total: 1, + page: 1, + perPage: 20, + }; + + const agentPoliciesResponse: GetAgentPoliciesResponse = { + items: [ + { + id: '521c1b70-3976-11eb-ad1c-3baa423084d9', + name: 'Default', + namespace: 'default', + description: 'Default agent policy created by Kibana', + status: 'active', + package_policies: [ + '4d09bd78-b0ad-4238-9fa3-d87d3c887c73', + '2babac18-eb8e-4ce4-b53b-4b7c5f507019', + 'e8a37031-2907-44f6-89d2-98bd493f60dc', + ], + is_default: true, + monitoring_enabled: ['logs', 'metrics'], + revision: 6, + updated_at: '2020-12-09T13:46:31.840Z', + updated_by: 'elastic', + agents: 0, + }, + ], + total: 1, + page: 1, + perPage: 100, + }; + http.get.mockImplementation(async (path) => { if (typeof path === 'string') { if (path === epmRouteService.getInfoPath(`nginx-0.3.7`)) { + markApiCallAsHandled(); return epmPackageResponse; } if (path === epmRouteService.getFilePath('/package/nginx/0.3.7/docs/README.md')) { + markApiCallAsHandled(); return packageReadMe; } if (path === fleetSetupRouteService.getFleetSetupPath()) { + markApiCallAsHandled(); return agentsSetupResponse; } + if (path === packagePolicyRouteService.getListPath()) { + markApiCallAsHandled(); + return packagePoliciesResponse; + } + + if (path === agentPolicyRouteService.getListPath()) { + markApiCallAsHandled(); + return agentPoliciesResponse; + } + const err = new Error(`API [GET ${path}] is not MOCKED!`); // eslint-disable-next-line no-console - console.log(err); + console.error(err); throw err; } }); + + return { + waitForApi() { + return new Promise((resolve) => { + if (inflightApiCalls > 0) { + apiDoneListeners.push(resolve); + } else { + resolve(); + } + }); + }, + }; }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx index 467276f0d0b8c..c70a11db004a6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx @@ -43,6 +43,7 @@ import { Content } from './content'; import './index.scss'; import { useUIExtension } from '../../../../hooks/use_ui_extension'; import { PLUGIN_ID } from '../../../../../../../common/constants'; +import { pkgKeyFromPackageInfo } from '../../../../services/pkg_key_from_package_info'; export const DEFAULT_PANEL: DetailViewPanelName = 'overview'; @@ -315,7 +316,7 @@ export function Detail() { isSelected: panelId === panel, 'data-test-subj': `tab-${panelId}`, href: getHref('integration_details', { - pkgkey: `${packageInfo?.name}-${packageInfo?.version}`, + pkgkey: pkgKeyFromPackageInfo(packageInfo || {}), panel: panelId, }), }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx index 8609b08c9a774..4061b86f1f740 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx @@ -36,8 +36,8 @@ const IntegrationDetailsLink = memo<{ return ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx new file mode 100644 index 0000000000000..fcd4821996efe --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import { EditPackagePolicyForm } from '../../../agent_policy/edit_package_policy_page'; + +export const Policy = memo(() => { + const { + params: { packagePolicyId }, + } = useRouteMatch<{ packagePolicyId: string }>(); + + return ; +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.ts b/x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.ts new file mode 100644 index 0000000000000..0e38abe6f5160 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const pkgKeyFromPackageInfo = ( + packageInfo: T +): string => { + return `${packageInfo.name}-${packageInfo.version}`; +}; From 3b9c2e4e9cf9470dd1c0163e97223646eeed153a Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 9 Dec 2020 18:08:49 -0500 Subject: [PATCH 47/52] Deprecate disabling the security plugin (#85159) Co-authored-by: Aleh Zasypkin Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/config_deprecations.test.ts | 186 ++++++++++++++++++ .../security/server/config_deprecations.ts | 67 +++++++ x-pack/plugins/security/server/index.ts | 49 +---- 3 files changed, 255 insertions(+), 47 deletions(-) create mode 100644 x-pack/plugins/security/server/config_deprecations.test.ts create mode 100644 x-pack/plugins/security/server/config_deprecations.ts diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts new file mode 100644 index 0000000000000..28cd4166b2683 --- /dev/null +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { configDeprecationFactory, applyDeprecations } from '@kbn/config'; +import { securityConfigDeprecationProvider } from './config_deprecations'; +import { cloneDeep } from 'lodash'; + +const applyConfigDeprecations = (settings: Record = {}) => { + const deprecations = securityConfigDeprecationProvider(configDeprecationFactory); + const deprecationMessages: string[] = []; + const migrated = applyDeprecations( + settings, + deprecations.map((deprecation) => ({ + deprecation, + path: 'xpack.security', + })), + (msg) => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; +}; + +describe('Config Deprecations', () => { + it('does not report deprecations for default configuration', () => { + const defaultConfig = { xpack: { security: {} } }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(defaultConfig)); + expect(migrated).toEqual(defaultConfig); + expect(messages).toHaveLength(0); + }); + + it('renames sessionTimeout to session.idleTimeout', () => { + const config = { + xpack: { + security: { + sessionTimeout: 123, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.sessionTimeout).not.toBeDefined(); + expect(migrated.xpack.security.session.idleTimeout).toEqual(123); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.sessionTimeout\\" is deprecated and has been replaced by \\"xpack.security.session.idleTimeout\\"", + ] + `); + }); + + it(`warns that 'authorization.legacyFallback.enabled' is unused`, () => { + const config = { + xpack: { + security: { + authorization: { + legacyFallback: { + enabled: true, + }, + }, + }, + }, + }; + const { messages } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "xpack.security.authorization.legacyFallback.enabled is deprecated and is no longer used", + ] + `); + }); + + it(`warns that 'authc.saml.maxRedirectURLSize is unused`, () => { + const config = { + xpack: { + security: { + authc: { + saml: { + maxRedirectURLSize: 123, + }, + }, + }, + }, + }; + const { messages } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "xpack.security.authc.saml.maxRedirectURLSize is deprecated and is no longer used", + ] + `); + }); + + it(`warns that 'xpack.security.authc.providers.saml..maxRedirectURLSize' is unused`, () => { + const config = { + xpack: { + security: { + authc: { + providers: { + saml: { + saml1: { + maxRedirectURLSize: 123, + }, + }, + }, + }, + }, + }, + }; + const { messages } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\`xpack.security.authc.providers.saml..maxRedirectURLSize\` is deprecated and is no longer used", + ] + `); + }); + + it(`warns when 'xpack.security.authc.providers' is an array of strings`, () => { + const config = { + xpack: { + security: { + authc: { + providers: ['basic', 'saml'], + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Defining \`xpack.security.authc.providers\` as an array of provider types is deprecated. Use extended \`object\` format instead.", + ] + `); + }); + + it(`warns when both the basic and token providers are enabled`, () => { + const config = { + xpack: { + security: { + authc: { + providers: ['basic', 'token'], + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Defining \`xpack.security.authc.providers\` as an array of provider types is deprecated. Use extended \`object\` format instead.", + "Enabling both \`basic\` and \`token\` authentication providers in \`xpack.security.authc.providers\` is deprecated. Login page will only use \`token\` provider.", + ] + `); + }); + + it('warns when the security plugin is disabled', () => { + const config = { + xpack: { + security: { + enabled: false, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Disabling the security plugin (\`xpack.security.enabled\`) will not be supported in the next major version (8.0). To turn off security features, disable them in Elasticsearch instead.", + ] + `); + }); + + it('does not warn when the security plugin is enabled', () => { + const config = { + xpack: { + security: { + enabled: true, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts new file mode 100644 index 0000000000000..3c5d82b93acba --- /dev/null +++ b/x-pack/plugins/security/server/config_deprecations.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { ConfigDeprecationProvider } from 'src/core/server'; + +export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ + rename, + unused, +}) => [ + rename('sessionTimeout', 'session.idleTimeout'), + unused('authorization.legacyFallback.enabled'), + unused('authc.saml.maxRedirectURLSize'), + // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. + (settings, fromPath, log) => { + if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { + log( + 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.' + ); + } + + return settings; + }, + (settings, fromPath, log) => { + const hasProviderType = (providerType: string) => { + const providers = settings?.xpack?.security?.authc?.providers; + if (Array.isArray(providers)) { + return providers.includes(providerType); + } + + return Object.values(providers?.[providerType] || {}).some( + (provider) => (provider as { enabled: boolean | undefined })?.enabled !== false + ); + }; + + if (hasProviderType('basic') && hasProviderType('token')) { + log( + 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' + ); + } + return settings; + }, + (settings, fromPath, log) => { + const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< + string, + any + >; + if (Object.values(samlProviders).find((provider) => !!provider.maxRedirectURLSize)) { + log( + '`xpack.security.authc.providers.saml..maxRedirectURLSize` is deprecated and is no longer used' + ); + } + + return settings; + }, + (settings, fromPath, log) => { + if (settings?.xpack?.security?.enabled === false) { + log( + 'Disabling the security plugin (`xpack.security.enabled`) will not be supported in the next major version (8.0). ' + + 'To turn off security features, disable them in Elasticsearch instead.' + ); + } + return settings; + }, +]; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 85f49bf3f931a..5d51b88d82939 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -12,6 +12,7 @@ import type { PluginInitializerContext, } from '../../../../src/core/server'; import { ConfigSchema } from './config'; +import { securityConfigDeprecationProvider } from './config_deprecations'; import { Plugin, SecurityPluginSetup, @@ -40,53 +41,7 @@ export type { AuthenticatedUser } from '../common/model'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, - deprecations: ({ rename, unused }) => [ - rename('sessionTimeout', 'session.idleTimeout'), - unused('authorization.legacyFallback.enabled'), - unused('authc.saml.maxRedirectURLSize'), - // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. - (settings, fromPath, log) => { - if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { - log( - 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.' - ); - } - - return settings; - }, - (settings, fromPath, log) => { - const hasProviderType = (providerType: string) => { - const providers = settings?.xpack?.security?.authc?.providers; - if (Array.isArray(providers)) { - return providers.includes(providerType); - } - - return Object.values(providers?.[providerType] || {}).some( - (provider) => (provider as { enabled: boolean | undefined })?.enabled !== false - ); - }; - - if (hasProviderType('basic') && hasProviderType('token')) { - log( - 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' - ); - } - return settings; - }, - (settings, fromPath, log) => { - const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< - string, - any - >; - if (Object.values(samlProviders).find((provider) => !!provider.maxRedirectURLSize)) { - log( - '`xpack.security.authc.providers.saml..maxRedirectURLSize` is deprecated and is no longer used' - ); - } - - return settings; - }, - ], + deprecations: securityConfigDeprecationProvider, exposeToBrowser: { loginAssistanceMessage: true, }, From 6dfdbe2e836eb3728f16d51b6bcace55700061d6 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 9 Dec 2020 18:11:53 -0500 Subject: [PATCH 48/52] Introduce external url service (#81234) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...lugin-core-public.httpsetup.externalurl.md | 11 + .../kibana-plugin-core-public.httpsetup.md | 1 + .../kibana-plugin-core-public.iexternalurl.md | 20 + ...in-core-public.iexternalurl.validateurl.md | 26 + ...in-core-public.iexternalurlpolicy.allow.md | 13 + ...gin-core-public.iexternalurlpolicy.host.md | 24 + ...a-plugin-core-public.iexternalurlpolicy.md | 22 + ...core-public.iexternalurlpolicy.protocol.md | 24 + .../core/public/kibana-plugin-core-public.md | 2 + ...a-plugin-core-server.iexternalurlconfig.md | 20 + ...n-core-server.iexternalurlconfig.policy.md | 13 + ...in-core-server.iexternalurlpolicy.allow.md | 13 + ...gin-core-server.iexternalurlpolicy.host.md | 24 + ...a-plugin-core-server.iexternalurlpolicy.md | 22 + ...core-server.iexternalurlpolicy.protocol.md | 24 + .../core/server/kibana-plugin-core-server.md | 2 + .../public/http/external_url_service.test.ts | 494 ++++++++++++++++++ src/core/public/http/external_url_service.ts | 111 ++++ src/core/public/http/http_service.mock.ts | 3 + src/core/public/http/http_service.ts | 2 + src/core/public/http/types.ts | 19 + src/core/public/index.ts | 3 +- .../injected_metadata_service.mock.ts | 2 + .../injected_metadata_service.ts | 11 + src/core/public/public.api.md | 14 + src/core/server/external_url/config.test.ts | 160 ++++++ src/core/server/external_url/config.ts | 61 +++ .../external_url/external_url_config.ts | 101 ++++ src/core/server/external_url/index.ts | 21 + .../http/cookie_session_storage.test.ts | 57 +- src/core/server/http/http_config.test.ts | 3 +- src/core/server/http/http_config.ts | 9 +- src/core/server/http/http_service.mock.ts | 2 + src/core/server/http/http_service.test.ts | 2 + src/core/server/http/http_service.ts | 10 +- src/core/server/http/http_tools.test.ts | 2 + .../lifecycle_handlers.test.ts | 51 +- src/core/server/http/test_utils.ts | 55 +- src/core/server/http/types.ts | 2 + src/core/server/index.ts | 1 + src/core/server/legacy/legacy_service.ts | 6 +- .../rendering_service.test.ts.snap | 35 ++ .../server/rendering/rendering_service.tsx | 1 + src/core/server/rendering/types.ts | 2 + src/core/server/server.api.md | 12 + src/core/server/server.ts | 2 + src/core/server/types.ts | 1 + src/core/server/utils/crypto/index.ts | 1 + src/core/server/utils/crypto/sha256.test.ts | 39 ++ src/core/server/utils/crypto/sha256.ts | 33 ++ .../dashboard_empty_screen.test.tsx.snap | 9 + .../__snapshots__/flyout.test.tsx.snap | 3 + ...telemetry_management_section.test.tsx.snap | 3 + .../api_keys/api_keys_management_app.test.tsx | 2 +- .../role_mappings_management_app.test.tsx | 6 +- .../roles/roles_management_app.test.tsx | 8 +- .../users/users_management_app.test.tsx | 6 +- 57 files changed, 1544 insertions(+), 82 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md create mode 100644 src/core/public/http/external_url_service.test.ts create mode 100644 src/core/public/http/external_url_service.ts create mode 100644 src/core/server/external_url/config.test.ts create mode 100644 src/core/server/external_url/config.ts create mode 100644 src/core/server/external_url/external_url_config.ts create mode 100644 src/core/server/external_url/index.ts create mode 100644 src/core/server/utils/crypto/sha256.test.ts create mode 100644 src/core/server/utils/crypto/sha256.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md b/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md new file mode 100644 index 0000000000000..b26c9d371e496 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [HttpSetup](./kibana-plugin-core-public.httpsetup.md) > [externalUrl](./kibana-plugin-core-public.httpsetup.externalurl.md) + +## HttpSetup.externalUrl property + +Signature: + +```typescript +externalUrl: IExternalUrl; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.httpsetup.md b/docs/development/core/public/kibana-plugin-core-public.httpsetup.md index bb43e9f588a72..b8a99cbb62353 100644 --- a/docs/development/core/public/kibana-plugin-core-public.httpsetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.httpsetup.md @@ -18,6 +18,7 @@ export interface HttpSetup | [anonymousPaths](./kibana-plugin-core-public.httpsetup.anonymouspaths.md) | IAnonymousPaths | APIs for denoting certain paths for not requiring authentication | | [basePath](./kibana-plugin-core-public.httpsetup.basepath.md) | IBasePath | APIs for manipulating the basePath on URL segments. See [IBasePath](./kibana-plugin-core-public.ibasepath.md) | | [delete](./kibana-plugin-core-public.httpsetup.delete.md) | HttpHandler | Makes an HTTP request with the DELETE method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | +| [externalUrl](./kibana-plugin-core-public.httpsetup.externalurl.md) | IExternalUrl | | | [fetch](./kibana-plugin-core-public.httpsetup.fetch.md) | HttpHandler | Makes an HTTP request. Defaults to a GET request unless overriden. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | | [get](./kibana-plugin-core-public.httpsetup.get.md) | HttpHandler | Makes an HTTP request with the GET method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | | [head](./kibana-plugin-core-public.httpsetup.head.md) | HttpHandler | Makes an HTTP request with the HEAD method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md new file mode 100644 index 0000000000000..5a598281c7be7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) + +## IExternalUrl interface + +APIs for working with external URLs. + +Signature: + +```typescript +export interface IExternalUrl +``` + +## Methods + +| Method | Description | +| --- | --- | +| [validateUrl(relativeOrAbsoluteUrl)](./kibana-plugin-core-public.iexternalurl.validateurl.md) | Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml.If the URL is valid, then a URL will be returned. Otherwise, this will return null. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md new file mode 100644 index 0000000000000..466d7cfebf547 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) > [validateUrl](./kibana-plugin-core-public.iexternalurl.validateurl.md) + +## IExternalUrl.validateUrl() method + +Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml. + +If the URL is valid, then a URL will be returned. Otherwise, this will return null. + +Signature: + +```typescript +validateUrl(relativeOrAbsoluteUrl: string): URL | null; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| relativeOrAbsoluteUrl | string | | + +Returns: + +`URL | null` + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md new file mode 100644 index 0000000000000..ec7129a43b99a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) + +## IExternalUrlPolicy.allow property + +Indicates if this policy allows or denies access to the described destination. + +Signature: + +```typescript +allow: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md new file mode 100644 index 0000000000000..5551d52cc1226 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) + +## IExternalUrlPolicy.host property + +Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. + +Signature: + +```typescript +host?: string; +``` + +## Example + + +```ts +// allows access to all of google.com, using any protocol. +allow: true, +host: 'google.com' + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md new file mode 100644 index 0000000000000..a87dc69d79e23 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) + +## IExternalUrlPolicy interface + +A policy describing whether access to an external destination is allowed. + +Signature: + +```typescript +export interface IExternalUrlPolicy +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) | boolean | Indicates if this policy allows or denies access to the described destination. | +| [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | +| [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md new file mode 100644 index 0000000000000..67b9b439a54f6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) + +## IExternalUrlPolicy.protocol property + +Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. + +Signature: + +```typescript +protocol?: string; +``` + +## Example + + +```ts +// allows access to all destinations over the `https` protocol. +allow: true, +protocol: 'https' + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 5f656b9ca510d..a3df5d30137df 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -73,6 +73,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IAnonymousPaths](./kibana-plugin-core-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | | [IBasePath](./kibana-plugin-core-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | | [IContextContainer](./kibana-plugin-core-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) | APIs for working with external URLs. | +| [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IHttpFetchError](./kibana-plugin-core-public.ihttpfetcherror.md) | | | [IHttpInterceptController](./kibana-plugin-core-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-core-public.httpinterceptor.md). | | [IHttpResponseInterceptorOverrides](./kibana-plugin-core-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md new file mode 100644 index 0000000000000..8df4db4aa9b5e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) + +## IExternalUrlConfig interface + +External Url configuration for use in Kibana. + +Signature: + +```typescript +export interface IExternalUrlConfig +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [policy](./kibana-plugin-core-server.iexternalurlconfig.policy.md) | IExternalUrlPolicy[] | A set of policies describing which external urls are allowed. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md new file mode 100644 index 0000000000000..b5b6f07038076 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) > [policy](./kibana-plugin-core-server.iexternalurlconfig.policy.md) + +## IExternalUrlConfig.policy property + +A set of policies describing which external urls are allowed. + +Signature: + +```typescript +readonly policy: IExternalUrlPolicy[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md new file mode 100644 index 0000000000000..e0c140409dcf0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [allow](./kibana-plugin-core-server.iexternalurlpolicy.allow.md) + +## IExternalUrlPolicy.allow property + +Indicates of this policy allows or denies access to the described destination. + +Signature: + +```typescript +allow: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md new file mode 100644 index 0000000000000..e65de074f1578 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [host](./kibana-plugin-core-server.iexternalurlpolicy.host.md) + +## IExternalUrlPolicy.host property + +Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. + +Signature: + +```typescript +host?: string; +``` + +## Example + + +```ts +// allows access to all of google.com, using any protocol. +allow: true, +host: 'google.com' + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md new file mode 100644 index 0000000000000..8e3658a10ed81 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) + +## IExternalUrlPolicy interface + +A policy describing whether access to an external destination is allowed. + +Signature: + +```typescript +export interface IExternalUrlPolicy +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [allow](./kibana-plugin-core-server.iexternalurlpolicy.allow.md) | boolean | Indicates of this policy allows or denies access to the described destination. | +| [host](./kibana-plugin-core-server.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | +| [protocol](./kibana-plugin-core-server.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md new file mode 100644 index 0000000000000..00c5d05eb0cc4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [protocol](./kibana-plugin-core-server.iexternalurlpolicy.protocol.md) + +## IExternalUrlPolicy.protocol property + +Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. + +Signature: + +```typescript +protocol?: string; +``` + +## Example + + +```ts +// allows access to all destinations over the `https` protocol. +allow: true, +protocol: 'https' + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 1a4209ff87c5b..269db90c4db9b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -94,6 +94,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IContextContainer](./kibana-plugin-core-server.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [ICspConfig](./kibana-plugin-core-server.icspconfig.md) | CSP configuration for use in Kibana. | | [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md) | See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) | +| [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) | External Url configuration for use in Kibana. | +| [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IKibanaResponse](./kibana-plugin-core-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution | | [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) | A tiny abstraction for TCP socket. | | [ImageValidation](./kibana-plugin-core-server.imagevalidation.md) | | diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts new file mode 100644 index 0000000000000..af34dba5e6216 --- /dev/null +++ b/src/core/public/http/external_url_service.test.ts @@ -0,0 +1,494 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ExternalUrlConfig } from 'src/core/server/types'; + +import { injectedMetadataServiceMock } from '../mocks'; +import { Sha256 } from '../utils'; + +import { ExternalUrlService } from './external_url_service'; + +const setupService = ({ + location, + serverBasePath, + policy, +}: { + location: URL; + serverBasePath: string; + policy: ExternalUrlConfig['policy']; +}) => { + const hashedPolicies = policy.map((entry) => { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + entry.host && !entry.host.includes('[') && !entry.host.endsWith('.') + ? `${entry.host}.` + : entry.host; + return { + ...entry, + host: hostToHash ? new Sha256().update(hostToHash, 'utf8').digest('hex') : undefined, + }; + }); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy: hashedPolicies }); + injectedMetadata.getServerBasePath.mockReturnValue(serverBasePath); + + const service = new ExternalUrlService(); + return { + setup: service.setup({ + injectedMetadata, + location, + }), + }; +}; + +const internalRequestScenarios = [ + { + description: 'without any policies', + allowExternal: false, + policy: [], + }, + { + description: 'with an unrestricted policy', + allowExternal: true, + policy: [ + { + allow: true, + }, + ], + }, + { + description: 'with a fully restricted policy', + allowExternal: false, + policy: [ + { + allow: false, + }, + ], + }, +]; + +describe('External Url Service', () => { + describe('#validateUrl', () => { + describe('internal requests with a server base path', () => { + const serverBasePath = '/base-path'; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + internalRequestScenarios.forEach(({ description, policy, allowExternal }) => { + describe(description, () => { + it('allows relative URLs that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); + + it('allows absolute URLs to Kibana that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); + + if (allowExternal) { + it('allows absolute URLs to Kibana that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${serverRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/some/path?foo=bar`); + }); + + it('allows relative URLs that attempt to bypass the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/../../path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/path?foo=bar`); + }); + + it('allows relative URLs that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/some/path?foo=bar`); + }); + } else { + it('disallows absolute URLs to Kibana that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${serverRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('disallows relative URLs that attempt to bypass the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/../../path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('disallows relative URLs that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + } + }); + }); + + describe('handles protocol resolution bypass', () => { + it('does not allow relative URLs that include a host', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${serverBasePath}${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('does allow relative URLs that include a host if allowed by policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual( + `https://www.google.com${serverBasePath}${urlCandidate}` + ); + }); + }); + }); + + describe('internal requests without a server base path', () => { + const serverBasePath = ''; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + internalRequestScenarios.forEach(({ description, policy }) => { + describe(description, () => { + it('allows relative URLs', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); + + it('allows absolute URLs to Kibana', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); + }); + }); + + describe('handles protocol resolution bypass', () => { + it('does not allow relative URLs that include a host', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('allows relative URLs that include a host in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`https://www.google.com${urlCandidate}`); + }); + }); + }); + + describe('external requests', () => { + const serverBasePath = '/base-path'; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + it('does not allow external urls by default', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `http://www.google.com`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('does not allow external urls with a fully restricted policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: false, + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('allows external urls with an unrestricted policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with a matching host and protocol in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with a partially matching host and protocol in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with a partially matching host and protocol in the allow list when the URL includes the root domain', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com./foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with an IPv4 address', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: '192.168.10.12', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://192.168.10.12/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with an IPv6 address', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls that specify a locally addressable host', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'some-host-name', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://some-host-name/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('disallows external urls with a matching host and unmatched protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `http://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('allows external urls with a matching host and any protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `ftp://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with any host and matching protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('disallows external urls that match multiple rules, one of which denies the request', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + protocol: 'https', + }, + { + allow: false, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + }); + }); +}); diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts new file mode 100644 index 0000000000000..e975451a7fdaa --- /dev/null +++ b/src/core/public/http/external_url_service.ts @@ -0,0 +1,111 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IExternalUrlPolicy } from 'src/core/server/types'; + +import { CoreService } from 'src/core/types'; +import { IExternalUrl } from './types'; +import { InjectedMetadataSetup } from '../injected_metadata'; +import { Sha256 } from '../utils'; + +interface SetupDeps { + location: Pick; + injectedMetadata: InjectedMetadataSetup; +} + +function* getHostHashes(actualHost: string) { + yield new Sha256().update(actualHost, 'utf8').digest('hex'); + let host = actualHost.substr(actualHost.indexOf('.') + 1); + while (host) { + yield new Sha256().update(host, 'utf8').digest('hex'); + if (host.indexOf('.') === -1) { + break; + } + host = host.substr(host.indexOf('.') + 1); + } +} + +const isHostMatch = (actualHost: string, ruleHostHash: string) => { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + !actualHost.includes('[') && !actualHost.endsWith('.') ? `${actualHost}.` : actualHost; + for (const hash of getHostHashes(hostToHash)) { + if (hash === ruleHostHash) { + return true; + } + } + return false; +}; + +const isProtocolMatch = (actualProtocol: string, ruleProtocol: string) => { + return normalizeProtocol(actualProtocol) === normalizeProtocol(ruleProtocol); +}; + +function normalizeProtocol(protocol: string) { + return protocol.endsWith(':') ? protocol.slice(0, -1).toLowerCase() : protocol.toLowerCase(); +} + +const createExternalUrlValidation = ( + rules: IExternalUrlPolicy[], + location: Pick, + serverBasePath: string +) => { + const base = new URL(location.origin + serverBasePath); + return function validateExternalUrl(next: string) { + const url = new URL(next, base); + + const isInternalURL = + url.origin === base.origin && + (!serverBasePath || url.pathname.startsWith(`${serverBasePath}/`)); + + if (isInternalURL) { + return url; + } + + let allowed: null | boolean = null; + rules.forEach((rule) => { + const hostMatch = rule.host ? isHostMatch(url.hostname || '', rule.host) : true; + + const protocolMatch = rule.protocol ? isProtocolMatch(url.protocol, rule.protocol) : true; + + const isRuleMatch = hostMatch && protocolMatch; + + if (isRuleMatch && allowed !== false) { + allowed = rule.allow; + } + }); + + return allowed === true ? url : null; + }; +}; + +export class ExternalUrlService implements CoreService { + setup({ injectedMetadata, location }: SetupDeps): IExternalUrl { + const serverBasePath = injectedMetadata.getServerBasePath(); + const { policy } = injectedMetadata.getExternalUrlConfig(); + + return { + validateUrl: createExternalUrlValidation(policy, location, serverBasePath), + }; + } + + start() {} + + stop() {} +} diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 68533159765fb..025336487c855 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -41,6 +41,9 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ register: jest.fn(), isAnonymous: jest.fn(), }, + externalUrl: { + validateUrl: jest.fn(), + }, addLoadingCountSource: jest.fn(), getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)), intercept: jest.fn(), diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 2eaaefe285755..a65eb5f76e1ac 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -25,6 +25,7 @@ import { AnonymousPathsService } from './anonymous_paths_service'; import { LoadingCountService } from './loading_count_service'; import { Fetch } from './fetch'; import { CoreService } from '../../types'; +import { ExternalUrlService } from './external_url_service'; interface HttpDeps { injectedMetadata: InjectedMetadataSetup; @@ -51,6 +52,7 @@ export class HttpService implements CoreService { this.service = { basePath, anonymousPaths: this.anonymousPaths.setup({ basePath }), + externalUrl: new ExternalUrlService().setup({ injectedMetadata, location: window.location }), intercept: fetchService.intercept.bind(fetchService), fetch: fetchService.fetch.bind(fetchService), delete: fetchService.delete.bind(fetchService), diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 7285d1a4288dc..5910aa0fc3238 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -33,6 +33,8 @@ export interface HttpSetup { */ anonymousPaths: IAnonymousPaths; + externalUrl: IExternalUrl; + /** * Adds a new {@link HttpInterceptor} to the global HTTP client. * @param interceptor a {@link HttpInterceptor} @@ -112,6 +114,23 @@ export interface IBasePath { */ readonly publicBaseUrl?: string; } +/** + * APIs for working with external URLs. + * + * @public + */ +export interface IExternalUrl { + /** + * Determines if the provided URL is a valid location to send users. + * Validation is based on the configured allow list in kibana.yml. + * + * If the URL is valid, then a URL will be returned. + * Otherwise, this will return null. + * + * @param relativeOrAbsoluteUrl + */ + validateUrl(relativeOrAbsoluteUrl: string): URL | null; +} /** * APIs for denoting paths as not requiring authentication diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 557529fc94dc4..8e240bfe91d48 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -77,7 +77,7 @@ import { HandlerParameters, } from './context'; -export { PackageInfo, EnvironmentMode } from '../server/types'; +export { PackageInfo, EnvironmentMode, IExternalUrlPolicy } from '../server/types'; export { CoreContext, CoreSystem } from './core_system'; export { DEFAULT_APP_CATEGORIES } from '../utils'; export { @@ -164,6 +164,7 @@ export { HttpHandler, IBasePath, IAnonymousPaths, + IExternalUrl, IHttpInterceptController, IHttpFetchError, IHttpResponseInterceptorOverrides, diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index 96282caa62c0a..ec05edcbbf25c 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -27,6 +27,7 @@ const createSetupContractMock = () => { getKibanaVersion: jest.fn(), getKibanaBranch: jest.fn(), getCspConfig: jest.fn(), + getExternalUrlConfig: jest.fn(), getAnonymousStatusPage: jest.fn(), getLegacyMetadata: jest.fn(), getPlugins: jest.fn(), @@ -35,6 +36,7 @@ const createSetupContractMock = () => { getKibanaBuildNumber: jest.fn(), }; setupContract.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); + setupContract.getExternalUrlConfig.mockReturnValue({ policy: [] }); setupContract.getKibanaVersion.mockReturnValue('kibanaVersion'); setupContract.getAnonymousStatusPage.mockReturnValue(false); setupContract.getLegacyMetadata.mockReturnValue({ diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 283710980e3ce..51025e24140da 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -22,6 +22,7 @@ import { deepFreeze } from '@kbn/std'; import { DiscoveredPlugin, PluginName } from '../../server'; import { EnvironmentMode, + IExternalUrlPolicy, PackageInfo, UiSettingsParams, UserProvidedValues, @@ -49,6 +50,9 @@ export interface InjectedMetadataParams { csp: { warnLegacyBrowsers: boolean; }; + externalUrl: { + policy: IExternalUrlPolicy[]; + }; vars: { [key: string]: unknown; }; @@ -112,6 +116,10 @@ export class InjectedMetadataService { return this.state.csp; }, + getExternalUrlConfig: () => { + return this.state.externalUrl; + }, + getPlugins: () => { return this.state.uiPlugins; }, @@ -154,6 +162,9 @@ export interface InjectedMetadataSetup { getCspConfig: () => { warnLegacyBrowsers: boolean; }; + getExternalUrlConfig: () => { + policy: IExternalUrlPolicy[]; + }; /** * An array of frontend plugins in topological order. */ diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 0b1d3f8263a23..65912e0954261 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -727,6 +727,8 @@ export interface HttpSetup { anonymousPaths: IAnonymousPaths; basePath: IBasePath; delete: HttpHandler; + // (undocumented) + externalUrl: IExternalUrl; fetch: HttpHandler; get: HttpHandler; getLoadingCount$(): Observable; @@ -777,6 +779,18 @@ export interface IContextContainer> { // @public export type IContextProvider, TContextName extends keyof HandlerContextType> = (context: PartialExceptFor, 'core'>, ...rest: HandlerParameters) => Promise[TContextName]> | HandlerContextType[TContextName]; +// @public +export interface IExternalUrl { + validateUrl(relativeOrAbsoluteUrl: string): URL | null; +} + +// @public +export interface IExternalUrlPolicy { + allow: boolean; + host?: string; + protocol?: string; +} + // @public (undocumented) export interface IHttpFetchError extends Error { // (undocumented) diff --git a/src/core/server/external_url/config.test.ts b/src/core/server/external_url/config.test.ts new file mode 100644 index 0000000000000..eeaf3751904d4 --- /dev/null +++ b/src/core/server/external_url/config.test.ts @@ -0,0 +1,160 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { config } from './config'; + +describe('externalUrl config', () => { + it('provides a default policy allowing all external urls', () => { + expect(config.schema.validate({})).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + } + `); + }); + + it('allows an empty policy', () => { + expect(config.schema.validate({ policy: [] })).toMatchInlineSnapshot(` + Object { + "policy": Array [], + } + `); + }); + + it('allows a policy with just a protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + protocol: 'http', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "protocol": "http", + }, + ], + } + `); + }); + + it('allows a policy with just a host', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "host": "www.google.com", + }, + ], + } + `); + }); + + it('allows a policy with both host and protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + protocol: 'http', + host: 'www.google.com', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "host": "www.google.com", + "protocol": "http", + }, + ], + } + `); + }); + + it('allows a policy without a host or protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + } + `); + }); + + describe('protocols', () => { + ['http', 'https', 'ftp', 'ftps', 'custom-protocol+123.bar'].forEach((protocol) => { + it(`allows a protocol of "${protocol}"`, () => { + config.schema.validate({ + policy: [ + { + allow: true, + protocol, + }, + ], + }); + }); + }); + + ['1http', '', 'custom-protocol()', 'https://'].forEach((protocol) => { + it(`disallows a protocol of "${protocol}"`, () => { + expect(() => + config.schema.validate({ + policy: [ + { + allow: true, + protocol, + }, + ], + }) + ).toThrowError(); + }); + }); + }); +}); diff --git a/src/core/server/external_url/config.ts b/src/core/server/external_url/config.ts new file mode 100644 index 0000000000000..4a26365a0c93d --- /dev/null +++ b/src/core/server/external_url/config.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TypeOf, schema } from '@kbn/config-schema'; +import { IExternalUrlPolicy } from '.'; + +/** + * @internal + */ +export type ExternalUrlConfigType = TypeOf; + +const allowSchema = schema.boolean(); + +const hostSchema = schema.string(); + +const protocolSchema = schema.string({ + validate: (value) => { + // tools.ietf.org/html/rfc3986#section-3.1 + // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + const schemaRegex = /^[a-zA-Z][a-zA-Z0-9\+\-\.]*$/; + if (!schemaRegex.test(value)) + throw new Error( + 'Protocol must begin with a letter, and can only contain letters, numbers, and the following characters: `+ - .`' + ); + }, +}); + +const policySchema = schema.object({ + allow: allowSchema, + protocol: schema.maybe(protocolSchema), + host: schema.maybe(hostSchema), +}); + +export const config = { + path: 'externalUrl', + schema: schema.object({ + policy: schema.arrayOf(policySchema, { + defaultValue: [ + { + allow: true, + }, + ], + }), + }), +}; diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts new file mode 100644 index 0000000000000..065a9cd1d2609 --- /dev/null +++ b/src/core/server/external_url/external_url_config.ts @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSHA256Hash } from '../utils'; +import { config } from './config'; + +const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); + +/** + * External Url configuration for use in Kibana. + * @public + */ +export interface IExternalUrlConfig { + /** + * A set of policies describing which external urls are allowed. + */ + readonly policy: IExternalUrlPolicy[]; +} + +/** + * A policy describing whether access to an external destination is allowed. + * @public + */ +export interface IExternalUrlPolicy { + /** + * Indicates if this policy allows or denies access to the described destination. + */ + allow: boolean; + + /** + * Optional host describing the external destination. + * May be combined with `protocol`. + * + * @example + * ```ts + * // allows access to all of google.com, using any protocol. + * allow: true, + * host: 'google.com' + * ``` + */ + host?: string; + + /** + * Optional protocol describing the external destination. + * May be combined with `host`. + * + * @example + * ```ts + * // allows access to all destinations over the `https` protocol. + * allow: true, + * protocol: 'https' + * ``` + */ + protocol?: string; +} + +/** + * External Url configuration for use in Kibana. + * @public + */ +export class ExternalUrlConfig implements IExternalUrlConfig { + static readonly DEFAULT = new ExternalUrlConfig(DEFAULT_CONFIG); + + public readonly policy: IExternalUrlPolicy[]; + /** + * Returns the default External Url configuration when passed with no config + * @internal + */ + constructor(rawConfig: IExternalUrlConfig) { + this.policy = rawConfig.policy.map((entry) => { + if (entry.host) { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + entry.host && !entry.host.includes('[') && !entry.host.endsWith('.') + ? `${entry.host}.` + : entry.host; + return { + ...entry, + host: createSHA256Hash(hostToHash), + }; + } + return entry; + }); + } +} diff --git a/src/core/server/external_url/index.ts b/src/core/server/external_url/index.ts new file mode 100644 index 0000000000000..dfc8e753fa644 --- /dev/null +++ b/src/core/server/external_url/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ExternalUrlConfig, IExternalUrlConfig, IExternalUrlPolicy } from './external_url_config'; +export { ExternalUrlConfigType, config } from './config'; diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index 7ac7e4b9712d0..0e7b55b7d35ab 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -46,29 +46,40 @@ const setupDeps = { context: contextSetup, }; -configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['http://1.2.3.4'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - healthCheck: { - delay: 2000, - }, - ssl: { - verificationMode: 'none', - }, - compression: { enabled: true }, - xsrf: { - disableProtection: true, - allowlist: [], - }, - customResponseHeaders: {}, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any) -); +configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['http://1.2.3.4'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + healthCheck: { + delay: 2000, + }, + ssl: { + verificationMode: 'none', + }, + compression: { enabled: true }, + xsrf: { + disableProtection: true, + allowlist: [], + }, + customResponseHeaders: {}, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); +}); beforeEach(() => { logger = loggingSystemMock.create(); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 6538c1ae973b7..c82e7c3796e4b 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -20,6 +20,7 @@ import uuid from 'uuid'; import { config, HttpConfig } from './http_config'; import { CspConfig } from '../csp'; +import { ExternalUrlConfig } from '../external_url'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost']; const invalidHostname = 'asdf$%^'; @@ -344,7 +345,7 @@ describe('HttpConfig', () => { }, }, }); - const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT); + const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT, ExternalUrlConfig.DEFAULT); expect(httpConfig.customResponseHeaders).toEqual({ string: 'string', diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 9a425fa645503..d26f077723ce3 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -22,6 +22,7 @@ import { hostname } from 'os'; import url from 'url'; import { CspConfigType, CspConfig, ICspConfig } from '../csp'; +import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url'; import { SslConfig, sslSchema } from './ssl_config'; const validBasePathRegex = /^\/.*[^\/]$/; @@ -156,13 +157,18 @@ export class HttpConfig { public ssl: SslConfig; public compression: { enabled: boolean; referrerWhitelist?: string[] }; public csp: ICspConfig; + public externalUrl: IExternalUrlConfig; public xsrf: { disableProtection: boolean; allowlist: string[] }; public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] }; /** * @internal */ - constructor(rawHttpConfig: HttpConfigType, rawCspConfig: CspConfigType) { + constructor( + rawHttpConfig: HttpConfigType, + rawCspConfig: CspConfigType, + rawExternalUrlConfig: ExternalUrlConfig + ) { this.autoListen = rawHttpConfig.autoListen; this.host = rawHttpConfig.host; this.port = rawHttpConfig.port; @@ -186,6 +192,7 @@ export class HttpConfig { this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); + this.externalUrl = rawExternalUrlConfig; this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 552f41d912417..d19bee27dd4cf 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -37,6 +37,7 @@ import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { configMock } from '../config/mocks'; +import { ExternalUrlConfig } from '../external_url'; type BasePathMocked = jest.Mocked; type AuthMocked = jest.Mocked; @@ -105,6 +106,7 @@ const createInternalSetupContractMock = () => { registerStaticDir: jest.fn(), basePath: createBasePathMock(), csp: CspConfig.DEFAULT, + externalUrl: ExternalUrlConfig.DEFAULT, auth: createAuthMock(), getAuthHeaders: jest.fn(), getServerInfo: jest.fn(), diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 3d55322461288..9075cb293667a 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -30,6 +30,7 @@ import { ConfigService, Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { contextServiceMock } from '../context/context_service.mock'; import { config as cspConfig } from '../csp'; +import { config as externalUrlConfig } from '../external_url'; const logger = loggingSystemMock.create(); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); @@ -48,6 +49,7 @@ const createConfigService = (value: Partial = {}) => { ); configService.setSchema(config.path, config.schema); configService.setSchema(cspConfig.path, cspConfig.schema); + configService.setSchema(externalUrlConfig.path, externalUrlConfig.schema); return configService; }; const contextSetup = contextServiceMock.createSetupContract(); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 171a20160d26d..ae2e82d8b2241 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -44,6 +44,11 @@ import { import { RequestHandlerContext } from '../../server'; import { registerCoreHandlers } from './lifecycle_handlers'; +import { + ExternalUrlConfigType, + config as externalUrlConfig, + ExternalUrlConfig, +} from '../external_url'; interface SetupDeps { context: ContextSetup; @@ -73,7 +78,8 @@ export class HttpService this.config$ = combineLatest([ configService.atPath(httpConfig.path), configService.atPath(cspConfig.path), - ]).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath(externalUrlConfig.path), + ]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); this.httpServer = new HttpServer(logger, 'Kibana'); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -103,6 +109,8 @@ export class HttpService this.internalSetup = { ...serverContract, + externalUrl: new ExternalUrlConfig(config.externalUrl), + createRouter: (path: string, pluginId: PluginOpaqueId = this.coreContext.coreId) => { const enhanceHandler = this.requestHandlerContext!.createHandler.bind(null, pluginId); const router = new Router(path, this.log, enhanceHandler); diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 1423e27b914a3..a409a7485a0ef 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -136,6 +136,7 @@ describe('getServerOptions', () => { certificate: 'some-certificate-path', }, }), + {} as any, {} as any ); @@ -165,6 +166,7 @@ describe('getServerOptions', () => { clientAuthentication: 'required', }, }), + {} as any, {} as any ); diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index 7df35b04c66cf..ba7f55caeba22 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -50,26 +50,37 @@ describe('core lifecycle handlers', () => { beforeEach(async () => { const configService = configServiceMock.create(); - configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - ssl: { - enabled: false, - }, - compression: { enabled: true }, - name: kibanaName, - customResponseHeaders: { - 'some-header': 'some-value', - }, - xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any) - ); + configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + name: kibanaName, + customResponseHeaders: { + 'some-header': 'some-value', + }, + xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); + }); server = createHttpServer({ configService }); const serverSetup = await server.setup(setupDeps); diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index cdcbe513e1224..0a5cee5505ef1 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -32,28 +32,39 @@ const env = Env.createDefault(REPO_ROOT, getEnvOptions()); const logger = loggingSystemMock.create(); const configService = configServiceMock.create(); -configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - ssl: { - enabled: false, - }, - compression: { enabled: true }, - xsrf: { - disableProtection: true, - allowlist: [], - }, - customResponseHeaders: {}, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - keepaliveTimeout: 120_000, - socketTimeout: 120_000, - } as any) -); +configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + xsrf: { + disableProtection: true, + allowlist: [], + }, + customResponseHeaders: {}, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + keepaliveTimeout: 120_000, + socketTimeout: 120_000, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); +}); const defaultContext: CoreContext = { coreId, diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index afd7b0174d158..558fa20e0fd6b 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -30,6 +30,7 @@ import { OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; import { IBasePath } from './base_path_service'; +import { ExternalUrlConfig } from '../external_url'; import { PluginOpaqueId, RequestHandlerContext } from '..'; /** @@ -280,6 +281,7 @@ export interface InternalHttpServiceSetup extends Omit { auth: HttpServerSetup['auth']; server: HttpServerSetup['server']; + externalUrl: ExternalUrlConfig; createRouter: (path: string, plugin?: PluginOpaqueId) => IRouter; registerStaticDir: (path: string, dirPath: string) => void; getAuthHeaders: GetAuthHeaders; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 6abe067f24c8c..0f2761b67437d 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -136,6 +136,7 @@ export { DeleteDocumentResponse, } from './elasticsearch'; export * from './elasticsearch/legacy/api_types'; +export { IExternalUrlConfig, IExternalUrlPolicy } from './external_url'; export { AuthenticationHandler, AuthHeaders, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 6da5d54869801..669286ccb2318 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -32,6 +32,7 @@ import { DevConfig, DevConfigType, config as devConfig } from '../dev'; import { BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig } from '../http'; import { Logger } from '../logging'; import { LegacyServiceSetupDeps, LegacyServiceStartDeps, LegacyConfig, LegacyVars } from './types'; +import { ExternalUrlConfigType, config as externalUrlConfig } from '../external_url'; import { CoreSetup, CoreStart } from '..'; interface LegacyKbnServer { @@ -84,8 +85,9 @@ export class LegacyService implements CoreService { .pipe(map((rawConfig) => new DevConfig(rawConfig))); this.httpConfig$ = combineLatest( configService.atPath(httpConfig.path), - configService.atPath(cspConfig.path) - ).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath(cspConfig.path), + configService.atPath(externalUrlConfig.path) + ).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); } public async setupLegacyConfig() { diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index a3f6b27f135be..f6b39ea24262b 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -23,6 +23,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -67,6 +74,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -111,6 +125,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -159,6 +180,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/translations/en.json", }, @@ -203,6 +231,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 4bbb2bd4811cb..b7c57f1c31e40 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -79,6 +79,7 @@ export class RenderingService { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, + externalUrl: http.externalUrl, vars: vars ?? {}, uiPlugins: await Promise.all( [...uiPlugins.public].map(async ([id, plugin]) => ({ diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 1954fc1c79e55..1b73b2be46835 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -25,6 +25,7 @@ import { InternalHttpServiceSetup, KibanaRequest, LegacyRequest } from '../http' import { UiPlugins, DiscoveredPlugin } from '../plugins'; import { IUiSettingsClient, UserProvidedValues } from '../ui_settings'; import type { InternalStatusServiceSetup } from '../status'; +import { IExternalUrlPolicy } from '../external_url'; /** @internal */ export interface RenderingMetadata { @@ -50,6 +51,7 @@ export interface RenderingMetadata { translationsUrl: string; }; csp: Pick; + externalUrl: { policy: IExternalUrlPolicy[] }; vars: Record; uiPlugins: Array<{ id: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b65ba329cec1e..81b794092e075 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -934,6 +934,18 @@ export interface ICustomClusterClient extends IClusterClient { close: () => Promise; } +// @public +export interface IExternalUrlConfig { + readonly policy: IExternalUrlPolicy[]; +} + +// @public +export interface IExternalUrlPolicy { + allow: boolean; + host?: string; + protocol?: string; +} + // @public export interface IKibanaResponse { // (undocumented) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 0b3249ad58750..75530e557de04 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -53,6 +53,7 @@ import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; +import { config as externalUrlConfig } from './external_url'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -314,6 +315,7 @@ export class Server { pathConfig, cspConfig, elasticsearchConfig, + externalUrlConfig, loggingConfig, httpConfig, pluginsConfig, diff --git a/src/core/server/types.ts b/src/core/server/types.ts index f8d2f635671fa..48b3a9058605c 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -23,3 +23,4 @@ export * from './saved_objects/types'; export * from './ui_settings/types'; export * from './legacy/types'; export type { EnvironmentMode, PackageInfo } from '@kbn/config'; +export type { ExternalUrlConfig, IExternalUrlPolicy } from './external_url'; diff --git a/src/core/server/utils/crypto/index.ts b/src/core/server/utils/crypto/index.ts index 9a36682cc4ecb..aa9728e0462d6 100644 --- a/src/core/server/utils/crypto/index.ts +++ b/src/core/server/utils/crypto/index.ts @@ -18,3 +18,4 @@ */ export { Pkcs12ReadResult, readPkcs12Keystore, readPkcs12Truststore } from './pkcs12'; +export { createSHA256Hash } from './sha256'; diff --git a/src/core/server/utils/crypto/sha256.test.ts b/src/core/server/utils/crypto/sha256.test.ts new file mode 100644 index 0000000000000..ddb8ffee36da6 --- /dev/null +++ b/src/core/server/utils/crypto/sha256.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSHA256Hash } from './sha256'; + +describe('createSHA256Hash', () => { + it('creates a hex-encoded hash by default', () => { + expect(createSHA256Hash('foo')).toMatchInlineSnapshot( + `"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"` + ); + }); + + it('allows the output encoding to be changed', () => { + expect(createSHA256Hash('foo', 'base64')).toMatchInlineSnapshot( + `"LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564="` + ); + }); + + it('accepts a buffer as input', () => { + const data = Buffer.from('foo', 'utf8'); + expect(createSHA256Hash(data)).toEqual(createSHA256Hash('foo')); + }); +}); diff --git a/src/core/server/utils/crypto/sha256.ts b/src/core/server/utils/crypto/sha256.ts new file mode 100644 index 0000000000000..de9eee2efad5a --- /dev/null +++ b/src/core/server/utils/crypto/sha256.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import crypto, { HexBase64Latin1Encoding } from 'crypto'; + +export const createSHA256Hash = ( + input: string | Buffer, + outputEncoding: HexBase64Latin1Encoding = 'hex' +) => { + let data: Buffer; + if (typeof input === 'string') { + data = Buffer.from(input, 'utf8'); + } else { + data = input; + } + return crypto.createHash('sha256').update(data).digest(outputEncoding); +}; diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index b64b485f65615..68d8a6a42eb5d 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -18,6 +18,9 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], @@ -382,6 +385,9 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], @@ -754,6 +760,9 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index d06fd0df98a8c..a48965cf7f41c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -180,6 +180,9 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index cad06255ffe98..896b1671328a9 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -321,6 +321,9 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index 30c5f8a361b42..ca370271b4360 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -43,7 +43,7 @@ describe('apiKeysManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API Keys' }]); expect(container).toMatchInlineSnapshot(`
- Page: {"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}}} + Page: {"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}}}
`); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index 5479bc36d1ed5..4ce49501a3ed1 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -54,7 +54,7 @@ describe('roleMappingsManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Role Mappings' }]); expect(container).toMatchInlineSnapshot(`
- Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -73,7 +73,7 @@ describe('roleMappingsManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -94,7 +94,7 @@ describe('roleMappingsManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Mapping Edit Page: {"name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}} + Role Mapping Edit Page: {"name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}}
`); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index 8bcf58428c08d..5e25cf8581f6e 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -71,7 +71,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }]); expect(container).toMatchInlineSnapshot(`
- Roles Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Roles Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -87,7 +87,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"edit","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + Role Edit Page: {"action":"edit","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -108,7 +108,7 @@ describe('rolesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"edit","roleName":"role@name","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@name","search":"","hash":""}}} + Role Edit Page: {"action":"edit","roleName":"role@name","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@name","search":"","hash":""}}}
`); @@ -126,7 +126,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"clone","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/clone/someRoleName","search":"","hash":""}}} + Role Edit Page: {"action":"clone","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/clone/someRoleName","search":"","hash":""}}}
`); diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index c9e448d90d925..f0a594469bd16 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -60,7 +60,7 @@ describe('usersManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Users' }]); expect(container).toMatchInlineSnapshot(`
- Users Page: {"notifications":{"toasts":{}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Users Page: {"notifications":{"toasts":{}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -76,7 +76,7 @@ describe('usersManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Users' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -97,7 +97,7 @@ describe('usersManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"username":"foo@bar.com","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/foo@bar.com","search":"","hash":""}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"username":"foo@bar.com","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/foo@bar.com","search":"","hash":""}}}
`); From 21d85b2cc0f76bf0dbc6e9709a27051f67df5d7b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Dec 2020 18:21:06 -0600 Subject: [PATCH 49/52] Update dependency @elastic/charts to v24.4.0 (#85452) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3687e6d590ce7..1295217b4bcbe 100644 --- a/package.json +++ b/package.json @@ -354,7 +354,7 @@ "@cypress/webpack-preprocessor": "^5.4.11", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.3.0", + "@elastic/charts": "24.4.0", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", diff --git a/yarn.lock b/yarn.lock index 34abc2cd39593..789ff171ef9c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1381,10 +1381,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@24.3.0": - version "24.3.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.3.0.tgz#5bb62143c2f941becbbbf91aafde849034b6330f" - integrity sha512-CmyekVOdy242m9pYf2yBNA6d54b8cohmNeoWghtNkM2wHT8Ut856zPV7mRhAMgNG61I7/pNCEnCD0OOpZPr4Xw== +"@elastic/charts@24.4.0": + version "24.4.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.4.0.tgz#217f55540f48a8f59c49250781d99c36110b2544" + integrity sha512-8dxDEs0g1mV4MjPgIArAmdDQDKjH8EitCLh8/Rouv8kkxvdXnL86VkSHpUbZNK9zPAecArwHBSkyCBZNmbqT2A== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" From d8a9407a8dd1837ab433eea13a98fe5fe6c7a1d0 Mon Sep 17 00:00:00 2001 From: Andrew Wilkins Date: Thu, 10 Dec 2020 09:34:40 +0800 Subject: [PATCH 50/52] [APM] enable 'sanitize_field_names' for Go (#85373) https://github.com/elastic/apm-agent-go/pull/856 added central config support for 'sanitize_field_names' to the Go agent, so we can now enable it in the UI too. --- .../agent_configuration/setting_definitions/general_settings.ts | 2 +- .../agent_configuration/setting_definitions/index.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index e5961ac6cf6ef..e978b6d55251b 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -235,7 +235,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'Sometimes it is necessary to sanitize, i.e., remove, sensitive data sent to Elastic APM. This config accepts a list of wildcard patterns of field names which should be sanitized. These apply to HTTP headers (including cookies) and `application/x-www-form-urlencoded` data (POST form fields). The query string and the captured request body (such as `application/json` data) will not get sanitized.', } ), - includeAgents: ['java', 'python'], + includeAgents: ['java', 'python', 'go'], }, // Ignore transactions based on URLs diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index dc5ce6cef97bc..abe353ab8f3a3 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -46,6 +46,7 @@ describe('filterByAgent', () => { 'capture_body', 'capture_headers', 'recording', + 'sanitize_field_names', 'span_frames_min_duration', 'stack_trace_limit', 'transaction_max_spans', From c9b5ec730381b9270191e1d708c1dbc5c7f6b6ce Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 9 Dec 2020 21:07:31 -0500 Subject: [PATCH 51/52] [Fleet] Update agent listing for better status reporting (#84798) --- .../fleet/common/services/agent_status.ts | 10 +- .../fleet/common/types/models/agent.ts | 2 + .../fleet/common/types/rest_spec/agent.ts | 1 + .../fleet/components/search_bar.tsx | 206 +++------ .../fleet/hooks/use_request/agents.ts | 21 +- .../components/search_and_filter_bar.tsx | 212 +++++++++ .../components/status_badges.tsx | 52 +++ .../agent_list_page/components/status_bar.tsx | 45 ++ .../components/table_header.tsx | 68 +++ .../sections/agents/agent_list_page/index.tsx | 412 ++++++++---------- .../agents/components/agent_health.tsx | 4 +- .../agents/components/list_layout.tsx | 115 +---- .../sections/agents/services/agent_status.tsx | 71 +++ .../public/applications/fleet/types/index.ts | 1 + .../fleet/server/routes/agent/handlers.ts | 3 +- .../fleet/server/services/agents/status.ts | 27 +- .../fleet/server/types/rest_spec/agent.ts | 1 + .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - 19 files changed, 752 insertions(+), 515 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx diff --git a/x-pack/plugins/fleet/common/services/agent_status.ts b/x-pack/plugins/fleet/common/services/agent_status.ts index cd990d70c3612..30b52bcb28748 100644 --- a/x-pack/plugins/fleet/common/services/agent_status.ts +++ b/x-pack/plugins/fleet/common/services/agent_status.ts @@ -62,6 +62,14 @@ export function buildKueryForOfflineAgents() { }s AND not (${buildKueryForErrorAgents()})`; } -export function buildKueryForUpdatingAgents() { +export function buildKueryForUpgradingAgents() { return `${AGENT_SAVED_OBJECT_TYPE}.upgrade_started_at:*`; } + +export function buildKueryForUpdatingAgents() { + return `(${buildKueryForUpgradingAgents()}) or (${buildKueryForEnrollingAgents()}) or (${buildKueryForUnenrollingAgents()})`; +} + +export function buildKueryForInactiveAgents() { + return `${AGENT_SAVED_OBJECT_TYPE}.active:false`; +} diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 872b389d248a3..59fab14f90e6e 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -22,6 +22,8 @@ export type AgentStatus = | 'updating' | 'degraded'; +export type SimplifiedAgentStatus = 'healthy' | 'unhealthy' | 'updating' | 'offline' | 'inactive'; + export type AgentActionType = | 'POLICY_CHANGE' | 'UNENROLL' diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index da7d126c4ecd3..236fc586bf528 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -206,6 +206,7 @@ export interface UpdateAgentRequest { export interface GetAgentStatusRequest { query: { + kuery?: string; policyId?: string; }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx index 9ebc8ea9380a9..fbc36f9c8ba23 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx @@ -4,33 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; -import { IFieldType } from 'src/plugins/data/public'; -// @ts-ignore -import { EuiSuggest, EuiSuggestItemProps } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useDebounce, useStartServices } from '../hooks'; +import React, { useState, useEffect, useMemo } from 'react'; +import { + QueryStringInput, + IFieldType, + esKuery, +} from '../../../../../../../src/plugins/data/public'; +import { useStartServices } from '../hooks'; import { INDEX_NAME, AGENT_SAVED_OBJECT_TYPE } from '../constants'; -const DEBOUNCE_SEARCH_MS = 150; const HIDDEN_FIELDS = [`${AGENT_SAVED_OBJECT_TYPE}.actions`]; -interface Suggestion { - label: string; - description: string; - value: string; - type: { - color: string; - iconType: string; - }; - start: number; - end: number; -} - interface Props { value: string; fieldPrefix: string; - onChange: (newValue: string) => void; + onChange: (newValue: string, submit?: boolean) => void; placeholder?: string; } @@ -40,135 +28,73 @@ export const SearchBar: React.FunctionComponent = ({ onChange, placeholder, }) => { - const { suggestions } = useSuggestions(fieldPrefix, value); - - // TODO fix type when correctly typed in EUI - const onAutocompleteClick = (suggestion: any) => { - onChange( - [value.slice(0, suggestion.start), suggestion.value, value.slice(suggestion.end, -1)].join('') - ); - }; - // TODO fix type when correctly typed in EUI - const onChangeSearch = (e: any) => { - onChange(e.value); - }; - - return ( - { - return { - ...suggestion, - // For type - onClick: () => {}, - descriptionDisplay: 'wrap', - labelWidth: '40', - }; - })} - /> - ); -}; - -export function transformSuggestionType(type: string): { iconType: string; color: string } { - switch (type) { - case 'field': - return { iconType: 'kqlField', color: 'tint4' }; - case 'value': - return { iconType: 'kqlValue', color: 'tint0' }; - case 'conjunction': - return { iconType: 'kqlSelector', color: 'tint3' }; - case 'operator': - return { iconType: 'kqlOperand', color: 'tint1' }; - default: - return { iconType: 'kqlOther', color: 'tint1' }; - } -} - -function useSuggestions(fieldPrefix: string, search: string) { const { data } = useStartServices(); + const [indexPatternFields, setIndexPatternFields] = useState(); - const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH_MS); - const [suggestions, setSuggestions] = useState([]); + const isQueryValid = useMemo(() => { + if (!value || value === '') { + return true; + } - const fetchSuggestions = async () => { try { - const res = (await data.indexPatterns.getFieldsForWildcard({ - pattern: INDEX_NAME, - })) as IFieldType[]; - if (!data || !data.autocomplete) { - throw new Error('Missing data plugin'); - } - const query = debouncedSearch || ''; - // @ts-ignore - const esSuggestions = ( - await data.autocomplete.getQuerySuggestions({ - language: 'kuery', - indexPatterns: [ - { - title: INDEX_NAME, - fields: res, - }, - ], - boolFilter: [], - query, - selectionStart: query.length, - selectionEnd: query.length, - }) - ) - .filter((suggestion) => { - if (suggestion.type === 'conjunction') { - return true; - } - if (suggestion.type === 'value') { - return true; - } - if (suggestion.type === 'operator') { - return true; - } + esKuery.fromKueryExpression(value); + return true; + } catch (e) { + return false; + } + }, [value]); - if (fieldPrefix && suggestion.text.startsWith(fieldPrefix)) { + useEffect(() => { + const fetchFields = async () => { + try { + const _fields: IFieldType[] = await data.indexPatterns.getFieldsForWildcard({ + pattern: INDEX_NAME, + }); + const fields = (_fields || []).filter((field) => { + if (fieldPrefix && field.name.startsWith(fieldPrefix)) { for (const hiddenField of HIDDEN_FIELDS) { - if (suggestion.text.startsWith(hiddenField)) { + if (field.name.startsWith(hiddenField)) { return false; } } return true; } + }); + setIndexPatternFields(fields); + } catch (err) { + setIndexPatternFields(undefined); + } + }; + fetchFields(); + }, [data.indexPatterns, fieldPrefix]); - return false; - }) - .map((suggestion: any) => ({ - label: suggestion.text, - description: suggestion.description || '', - type: transformSuggestionType(suggestion.type), - start: suggestion.start, - end: suggestion.end, - value: suggestion.text, - })); - - setSuggestions(esSuggestions); - } catch (err) { - setSuggestions([]); - } - }; - - useEffect(() => { - fetchSuggestions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearch]); - - return { - suggestions, - }; -} + return ( + { + onChange(newQuery.query as string); + }} + onSubmit={(newQuery) => { + onChange(newQuery.query as string, true); + }} + /> + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts index 7bbf621c57894..b6a3ecfde78d6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts @@ -62,13 +62,10 @@ export function useGetAgents(query: GetAgentsRequest['query'], options?: Request }); } -export function sendGetAgentStatus( - query: GetAgentStatusRequest['query'], - options?: RequestOptions -) { - return sendRequest({ +export function sendGetAgents(query: GetAgentsRequest['query'], options?: RequestOptions) { + return sendRequest({ method: 'get', - path: agentRouteService.getStatusPath(), + path: agentRouteService.getListPath(), query, ...options, }); @@ -83,6 +80,18 @@ export function useGetAgentStatus(query: GetAgentStatusRequest['query'], options }); } +export function sendGetAgentStatus( + query: GetAgentStatusRequest['query'], + options?: RequestOptions +) { + return sendRequest({ + method: 'get', + path: agentRouteService.getStatusPath(), + query, + ...options, + }); +} + export function sendPutAgentReassign( agentId: string, body: PutAgentReassignRequest['body'], diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx new file mode 100644 index 0000000000000..baea6d364e586 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + EuiFilterButton, + EuiFilterGroup, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentPolicy } from '../../../../types'; +import { SearchBar } from '../../../../components'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../../constants'; + +const statusFilters = [ + { + status: 'healthy', + label: i18n.translate('xpack.fleet.agentList.statusHealthyFilterText', { + defaultMessage: 'Healthy', + }), + }, + { + status: 'unhealthy', + label: i18n.translate('xpack.fleet.agentList.statusUnhealthyFilterText', { + defaultMessage: 'Unhealthy', + }), + }, + { + status: 'updating', + label: i18n.translate('xpack.fleet.agentList.statusUpdatingFilterText', { + defaultMessage: 'Updating', + }), + }, + { + status: 'offline', + label: i18n.translate('xpack.fleet.agentList.statusOfflineFilterText', { + defaultMessage: 'Offline', + }), + }, + { + status: 'inactive', + label: i18n.translate('xpack.fleet.agentList.statusInactiveFilterText', { + defaultMessage: 'Inactive', + }), + }, +]; + +export const SearchAndFilterBar: React.FunctionComponent<{ + agentPolicies: AgentPolicy[]; + draftKuery: string; + onDraftKueryChange: (kuery: string) => void; + onSubmitSearch: (kuery: string) => void; + selectedAgentPolicies: string[]; + onSelectedAgentPoliciesChange: (selectedPolicies: string[]) => void; + selectedStatus: string[]; + onSelectedStatusChange: (selectedStatus: string[]) => void; + showUpgradeable: boolean; + onShowUpgradeableChange: (showUpgradeable: boolean) => void; +}> = ({ + agentPolicies, + draftKuery, + onDraftKueryChange, + onSubmitSearch, + selectedAgentPolicies, + onSelectedAgentPoliciesChange, + selectedStatus, + onSelectedStatusChange, + showUpgradeable, + onShowUpgradeableChange, +}) => { + // Policies state for filtering + const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); + + // Status for filtering + const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); + + // Add a agent policy id to current search + const addAgentPolicyFilter = (policyId: string) => { + onSelectedAgentPoliciesChange([...selectedAgentPolicies, policyId]); + }; + + // Remove a agent policy id from current search + const removeAgentPolicyFilter = (policyId: string) => { + onSelectedAgentPoliciesChange( + selectedAgentPolicies.filter((agentPolicy) => agentPolicy !== policyId) + ); + }; + + return ( + <> + {/* Search and filter bar */} + + + + + { + onDraftKueryChange(newSearch); + if (submit) { + onSubmitSearch(newSearch); + } + }} + fieldPrefix={AGENT_SAVED_OBJECT_TYPE} + /> + + + + setIsStatutsFilterOpen(!isStatusFilterOpen)} + isSelected={isStatusFilterOpen} + hasActiveFilters={selectedStatus.length > 0} + numActiveFilters={selectedStatus.length} + disabled={agentPolicies.length === 0} + > + + + } + isOpen={isStatusFilterOpen} + closePopover={() => setIsStatutsFilterOpen(false)} + panelPaddingSize="none" + > +
+ {statusFilters.map(({ label, status }, idx) => ( + { + if (selectedStatus.includes(status)) { + onSelectedStatusChange([...selectedStatus.filter((s) => s !== status)]); + } else { + onSelectedStatusChange([...selectedStatus, status]); + } + }} + > + {label} + + ))} +
+
+ setIsAgentPoliciesFilterOpen(!isAgentPoliciesFilterOpen)} + isSelected={isAgentPoliciesFilterOpen} + hasActiveFilters={selectedAgentPolicies.length > 0} + numActiveFilters={selectedAgentPolicies.length} + numFilters={agentPolicies.length} + disabled={agentPolicies.length === 0} + > + + + } + isOpen={isAgentPoliciesFilterOpen} + closePopover={() => setIsAgentPoliciesFilterOpen(false)} + panelPaddingSize="none" + > +
+ {agentPolicies.map((agentPolicy, index) => ( + { + if (selectedAgentPolicies.includes(agentPolicy.id)) { + removeAgentPolicyFilter(agentPolicy.id); + } else { + addAgentPolicyFilter(agentPolicy.id); + } + }} + > + {agentPolicy.name} + + ))} +
+
+ { + onShowUpgradeableChange(!showUpgradeable); + }} + > + + +
+
+
+
+
+ + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.tsx new file mode 100644 index 0000000000000..250b021c77c15 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiHealth, EuiNotificationBadge, EuiFlexItem } from '@elastic/eui'; +import React, { memo, useMemo } from 'react'; +import { + AGENT_STATUSES, + getColorForAgentStatus, + getLabelForAgentStatus, +} from '../../services/agent_status'; +import { SimplifiedAgentStatus } from '../../../../types'; + +export const AgentStatusBadges: React.FC<{ + showInactive?: boolean; + agentStatus: { [k in SimplifiedAgentStatus]: number }; +}> = memo(({ agentStatus, showInactive }) => { + const agentStatuses = useMemo(() => { + return AGENT_STATUSES.filter((status) => (showInactive ? true : status !== 'inactive')); + }, [showInactive]); + + return ( + + {agentStatuses.map((status) => ( + + + + ))} + + ); +}); + +const AgentStatusBadge: React.FC<{ status: SimplifiedAgentStatus; count: number }> = memo( + ({ status, count }) => { + return ( + <> + + + {getLabelForAgentStatus(status)} + + + {count} + + + + + + ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx new file mode 100644 index 0000000000000..b2fa2eacbd5f2 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import styled from 'styled-components'; +import { EuiColorPaletteDisplay } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { AGENT_STATUSES, getColorForAgentStatus } from '../../services/agent_status'; +import { SimplifiedAgentStatus } from '../../../../types'; + +const StyledEuiColorPaletteDisplay = styled(EuiColorPaletteDisplay)` + &.ingest-agent-status-bar { + border: none; + border-radius: 0; + &:after { + border: none; + } + } +`; + +export const AgentStatusBar: React.FC<{ + agentStatus: { [k in SimplifiedAgentStatus]: number }; +}> = ({ agentStatus }) => { + const palette = useMemo(() => { + return AGENT_STATUSES.reduce((acc, status) => { + const previousStop = acc.length > 0 ? acc[acc.length - 1].stop : 0; + acc.push({ + stop: previousStop + (agentStatus[status] || 0), + color: getColorForAgentStatus(status), + }); + return acc; + }, [] as Array<{ stop: number; color: string }>); + }, [agentStatus]); + return ( + <> + + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx new file mode 100644 index 0000000000000..80ab76ffde4a0 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { Agent, SimplifiedAgentStatus } from '../../../../types'; + +import { AgentStatusBar } from './status_bar'; +import { AgentBulkActions } from './bulk_actions'; +import {} from '@elastic/eui'; +import { AgentStatusBadges } from './status_badges'; + +export type SelectionMode = 'manual' | 'query'; + +export const AgentTableHeader: React.FunctionComponent<{ + agentStatus?: { [k in SimplifiedAgentStatus]: number }; + showInactive: boolean; + totalAgents: number; + totalInactiveAgents: number; + selectableAgents: number; + selectionMode: SelectionMode; + setSelectionMode: (mode: SelectionMode) => void; + currentQuery: string; + selectedAgents: Agent[]; + setSelectedAgents: (agents: Agent[]) => void; + refreshAgents: () => void; +}> = ({ + agentStatus, + totalAgents, + totalInactiveAgents, + selectableAgents, + selectionMode, + setSelectionMode, + currentQuery, + selectedAgents, + setSelectedAgents, + refreshAgents, + showInactive, +}) => { + return ( + <> + + + + + + {agentStatus && ( + + )} + + + + {agentStatus && } + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 1d08a1f791976..2067a2bd91c58 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -3,41 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useMemo, useCallback, useRef } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { EuiBasicTable, EuiButton, EuiEmptyPrompt, - EuiFilterButton, - EuiFilterGroup, - EuiFilterSelectItem, EuiFlexGroup, EuiFlexItem, EuiLink, - EuiPopover, EuiSpacer, EuiText, EuiContextMenuItem, EuiIcon, EuiPortal, - EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; import { AgentEnrollmentFlyout } from '../components'; -import { Agent, AgentPolicy } from '../../../types'; +import { Agent, AgentPolicy, SimplifiedAgentStatus } from '../../../types'; import { usePagination, useCapabilities, useGetAgentPolicies, - useGetAgents, + sendGetAgents, + sendGetAgentStatus, useUrlParams, useLink, useBreadcrumbs, useLicense, useKibanaVersion, + useStartServices, } from '../../../hooks'; -import { SearchBar, ContextMenuActions } from '../../../components'; +import { ContextMenuActions } from '../../../components'; import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { @@ -46,37 +43,11 @@ import { AgentUnenrollAgentModal, AgentUpgradeAgentModal, } from '../components'; -import { AgentBulkActions, SelectionMode } from './components/bulk_actions'; - -const REFRESH_INTERVAL_MS = 5000; - -const statusFilters = [ - { - status: 'online', - label: i18n.translate('xpack.fleet.agentList.statusOnlineFilterText', { - defaultMessage: 'Online', - }), - }, - { - status: 'offline', - label: i18n.translate('xpack.fleet.agentList.statusOfflineFilterText', { - defaultMessage: 'Offline', - }), - }, - , - { - status: 'error', - label: i18n.translate('xpack.fleet.agentList.statusErrorFilterText', { - defaultMessage: 'Error', - }), - }, - { - status: 'updating', - label: i18n.translate('xpack.fleet.agentList.statusUpdatingFilterText', { - defaultMessage: 'Updating', - }), - }, -] as Array<{ label: string; status: string }>; +import { AgentTableHeader } from './components/table_header'; +import { SelectionMode } from './components/bulk_actions'; +import { SearchAndFilterBar } from './components/search_and_filter_bar'; + +const REFRESH_INTERVAL_MS = 10000; const RowActions = React.memo<{ agent: Agent; @@ -160,6 +131,7 @@ function safeMetadata(val: any) { } export const AgentListPage: React.FunctionComponent<{}> = () => { + const { notifications } = useStartServices(); useBreadcrumbs('fleet_agent_list'); const { getHref } = useLink(); const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; @@ -168,50 +140,43 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const kibanaVersion = useKibanaVersion(); // Agent data states - const [showInactive, setShowInactive] = useState(false); const [showUpgradeable, setShowUpgradeable] = useState(false); // Table and search states + const [draftKuery, setDraftKuery] = useState(defaultKuery); const [search, setSearch] = useState(defaultKuery); const [selectionMode, setSelectionMode] = useState('manual'); const [selectedAgents, setSelectedAgents] = useState([]); const tableRef = useRef>(null); const { pagination, pageSizeOptions, setPagination } = usePagination(); + const onSubmitSearch = useCallback( + (newKuery: string) => { + setSearch(newKuery); + setPagination({ + ...pagination, + currentPage: 1, + }); + }, + [setSearch, pagination, setPagination] + ); + // Policies state for filtering - const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); const [selectedAgentPolicies, setSelectedAgentPolicies] = useState([]); // Status for filtering - const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); const [selectedStatus, setSelectedStatus] = useState([]); const isUsingFilter = - search.trim() || - selectedAgentPolicies.length || - selectedStatus.length || - showInactive || - showUpgradeable; + search.trim() || selectedAgentPolicies.length || selectedStatus.length || showUpgradeable; const clearFilters = useCallback(() => { + setDraftKuery(''); setSearch(''); setSelectedAgentPolicies([]); setSelectedStatus([]); - setShowInactive(false); setShowUpgradeable(false); - }, [setSearch, setSelectedAgentPolicies, setSelectedStatus, setShowInactive, setShowUpgradeable]); - - // Add a agent policy id to current search - const addAgentPolicyFilter = (policyId: string) => { - setSelectedAgentPolicies([...selectedAgentPolicies, policyId]); - }; - - // Remove a agent policy id from current search - const removeAgentPolicyFilter = (policyId: string) => { - setSelectedAgentPolicies( - selectedAgentPolicies.filter((agentPolicy) => agentPolicy !== policyId) - ); - }; + }, [setSearch, setDraftKuery, setSelectedAgentPolicies, setSelectedStatus, setShowUpgradeable]); // Agent enrollment flyout state const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); @@ -221,65 +186,140 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const [agentToUnenroll, setAgentToUnenroll] = useState(undefined); const [agentToUpgrade, setAgentToUpgrade] = useState(undefined); - let kuery = search.trim(); - if (selectedAgentPolicies.length) { - if (kuery) { - kuery = `(${kuery}) and`; + // Kuery + const kuery = useMemo(() => { + let kueryBuilder = search.trim(); + if (selectedAgentPolicies.length) { + if (kueryBuilder) { + kueryBuilder = `(${kueryBuilder}) and`; + } + kueryBuilder = `${kueryBuilder} ${AGENT_SAVED_OBJECT_TYPE}.policy_id : (${selectedAgentPolicies + .map((agentPolicy) => `"${agentPolicy}"`) + .join(' or ')})`; } - kuery = `${kuery} ${AGENT_SAVED_OBJECT_TYPE}.policy_id : (${selectedAgentPolicies - .map((agentPolicy) => `"${agentPolicy}"`) - .join(' or ')})`; - } - if (selectedStatus.length) { - const kueryStatus = selectedStatus - .map((status) => { - switch (status) { - case 'online': - return AgentStatusKueryHelper.buildKueryForOnlineAgents(); - case 'offline': - return AgentStatusKueryHelper.buildKueryForOfflineAgents(); - case 'updating': - return AgentStatusKueryHelper.buildKueryForUpdatingAgents(); - case 'error': - return AgentStatusKueryHelper.buildKueryForErrorAgents(); - } + if (selectedStatus.length) { + const kueryStatus = selectedStatus + .map((status) => { + switch (status) { + case 'healthy': + return AgentStatusKueryHelper.buildKueryForOnlineAgents(); + case 'unhealthy': + return AgentStatusKueryHelper.buildKueryForErrorAgents(); + case 'offline': + return AgentStatusKueryHelper.buildKueryForOfflineAgents(); + case 'updating': + return AgentStatusKueryHelper.buildKueryForUpdatingAgents(); + case 'inactive': + return AgentStatusKueryHelper.buildKueryForInactiveAgents(); + } - return ''; - }) - .join(' or '); + return undefined; + }) + .filter((statusKuery) => statusKuery !== undefined) + .join(' or '); - if (kuery) { - kuery = `(${kuery}) and ${kueryStatus}`; - } else { - kuery = kueryStatus; + if (kueryBuilder) { + kueryBuilder = `(${kueryBuilder}) and ${kueryStatus}`; + } else { + kueryBuilder = kueryStatus; + } } - } - const agentsRequest = useGetAgents( - { - page: pagination.currentPage, - perPage: pagination.pageSize, - kuery: kuery && kuery !== '' ? kuery : undefined, - showInactive, - showUpgradeable, - }, - { - pollIntervalMs: REFRESH_INTERVAL_MS, + return kueryBuilder; + }, [selectedStatus, selectedAgentPolicies, search]); + + const showInactive = useMemo(() => { + return selectedStatus.includes('inactive'); + }, [selectedStatus]); + + const [agents, setAgents] = useState([]); + const [agentsStatus, setAgentsStatus] = useState< + { [key in SimplifiedAgentStatus]: number } | undefined + >(); + const [isLoading, setIsLoading] = useState(false); + const [totalAgents, setTotalAgents] = useState(0); + const [totalInactiveAgents, setTotalInactiveAgents] = useState(0); + + // Request to fetch agents and agent status + const currentRequestRef = useRef(0); + const fetchData = useCallback(() => { + async function fetchDataAsync() { + currentRequestRef.current++; + const currentRequest = currentRequestRef.current; + + try { + setIsLoading(true); + const [agentsRequest, agentsStatusRequest] = await Promise.all([ + sendGetAgents({ + page: pagination.currentPage, + perPage: pagination.pageSize, + kuery: kuery && kuery !== '' ? kuery : undefined, + showInactive, + showUpgradeable, + }), + sendGetAgentStatus({ + kuery: kuery && kuery !== '' ? kuery : undefined, + }), + ]); + // Return if a newer request as been triggered + if (currentRequestRef.current !== currentRequest) { + return; + } + if (agentsRequest.error) { + throw agentsRequest.error; + } + if (!agentsRequest.data) { + throw new Error('Invalid GET /agents response'); + } + if (agentsStatusRequest.error) { + throw agentsStatusRequest.error; + } + if (!agentsStatusRequest.data) { + throw new Error('Invalid GET /agents-status response'); + } + + setAgentsStatus({ + healthy: agentsStatusRequest.data.results.online, + unhealthy: agentsStatusRequest.data.results.error, + offline: agentsStatusRequest.data.results.offline, + updating: agentsStatusRequest.data.results.other, + inactive: agentsRequest.data.totalInactive, + }); + + setAgents(agentsRequest.data.list); + setTotalAgents(agentsRequest.data.total); + setTotalInactiveAgents(agentsRequest.data.totalInactive); + } catch (error) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.agentList.errorFetchingDataTitle', { + defaultMessage: 'Error fetching agents', + }), + }); + } + setIsLoading(false); } - ); + fetchDataAsync(); + }, [pagination, kuery, showInactive, showUpgradeable, notifications.toasts]); - const agents = agentsRequest.data ? agentsRequest.data.list : []; - const totalAgents = agentsRequest.data ? agentsRequest.data.total : 0; - const totalInactiveAgents = agentsRequest.data ? agentsRequest.data.totalInactive : 0; - const { isLoading } = agentsRequest; + // Send request to get agent list and status + useEffect(() => { + fetchData(); + const interval = setInterval(() => { + fetchData(); + }, REFRESH_INTERVAL_MS); + + return () => clearInterval(interval); + }, [fetchData]); const agentPoliciesRequest = useGetAgentPolicies({ page: 1, perPage: 1000, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - const agentPolicies = agentPoliciesRequest.data ? agentPoliciesRequest.data.items : []; + const agentPolicies = useMemo( + () => (agentPoliciesRequest.data ? agentPoliciesRequest.data.items : []), + [agentPoliciesRequest] + ); const agentPoliciesIndexedById = useMemo(() => { return agentPolicies.reduce((acc, agentPolicy) => { acc[agentPolicy.id] = agentPolicy; @@ -287,7 +327,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return acc; }, {} as { [k: string]: AgentPolicy }); }, [agentPolicies]); - const { isLoading: isAgentPoliciesLoading } = agentPoliciesRequest; const columns = [ { @@ -405,7 +444,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return ( agentsRequest.resendRequest()} + refresh={() => fetchData()} onReassignClick={() => setAgentToReassign(agent)} onUnenrollClick={() => setAgentToUnenroll(agent)} onUpgradeClick={() => setAgentToUpgrade(agent)} @@ -452,7 +491,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { agents={[agentToReassign]} onClose={() => { setAgentToReassign(undefined); - agentsRequest.resendRequest(); + fetchData(); }} /> @@ -464,7 +503,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { agentCount={1} onClose={() => { setAgentToUnenroll(undefined); - agentsRequest.resendRequest(); + fetchData(); }} useForceUnenroll={agentToUnenroll.status === 'unenrolling'} /> @@ -478,7 +517,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { agentCount={1} onClose={() => { setAgentToUpgrade(undefined); - agentsRequest.resendRequest(); + fetchData(); }} version={kibanaVersion} /> @@ -486,134 +525,26 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} {/* Search and filter bar */} - - - - - { - setPagination({ - ...pagination, - currentPage: 1, - }); - setSearch(newSearch); - }} - fieldPrefix={AGENT_SAVED_OBJECT_TYPE} - /> - - - - setIsStatutsFilterOpen(!isStatusFilterOpen)} - isSelected={isStatusFilterOpen} - hasActiveFilters={selectedStatus.length > 0} - numActiveFilters={selectedStatus.length} - disabled={isAgentPoliciesLoading} - > - - - } - isOpen={isStatusFilterOpen} - closePopover={() => setIsStatutsFilterOpen(false)} - panelPaddingSize="none" - > -
- {statusFilters.map(({ label, status }, idx) => ( - { - if (selectedStatus.includes(status)) { - setSelectedStatus([...selectedStatus.filter((s) => s !== status)]); - } else { - setSelectedStatus([...selectedStatus, status]); - } - }} - > - {label} - - ))} -
-
- setIsAgentPoliciesFilterOpen(!isAgentPoliciesFilterOpen)} - isSelected={isAgentPoliciesFilterOpen} - hasActiveFilters={selectedAgentPolicies.length > 0} - numActiveFilters={selectedAgentPolicies.length} - numFilters={agentPolicies.length} - disabled={isAgentPoliciesLoading} - > - - - } - isOpen={isAgentPoliciesFilterOpen} - closePopover={() => setIsAgentPoliciesFilterOpen(false)} - panelPaddingSize="none" - > -
- {agentPolicies.map((agentPolicy, index) => ( - { - if (selectedAgentPolicies.includes(agentPolicy.id)) { - removeAgentPolicyFilter(agentPolicy.id); - } else { - addAgentPolicyFilter(agentPolicy.id); - } - }} - > - {agentPolicy.name} - - ))} -
-
- { - setShowUpgradeable(!showUpgradeable); - }} - > - - - setShowInactive(!showInactive)} - > - - -
-
-
-
-
+ - {/* Agent total and bulk actions */} - agent.active).length || 0} selectionMode={selectionMode} setSelectionMode={setSelectionMode} @@ -625,10 +556,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { setSelectionMode('manual'); } }} - refreshAgents={() => agentsRequest.resendRequest()} + refreshAgents={() => fetchData()} /> - - + {/* Agent list table */} @@ -638,7 +568,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { loading={isLoading} hasActions={true} noItemsMessage={ - isLoading && agentsRequest.isInitialRequest ? ( + isLoading && currentRequestRef.current === 1 ? ( - + ), Unhealthy: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx index bf0163fe904e6..dfa093ca8bf80 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx @@ -5,122 +5,31 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import styled from 'styled-components'; -import { - EuiHealth, - EuiText, - EuiFlexGroup, - EuiFlexItem, - EuiStat, - EuiI18nNumber, - EuiButton, -} from '@elastic/eui'; +import { EuiText, EuiFlexGroup, EuiFlexItem, EuiButton, EuiPortal } from '@elastic/eui'; import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; import { useRouteMatch } from 'react-router-dom'; import { PAGE_ROUTING_PATHS } from '../../../constants'; import { WithHeaderLayout } from '../../../layouts'; import { useCapabilities, useLink, useGetAgentPolicies } from '../../../hooks'; -import { useGetAgentStatus } from '../../agent_policy/details_page/hooks'; import { AgentEnrollmentFlyout } from '../components'; -import { DonutChart } from './donut_chart'; - -const REFRESH_INTERVAL_MS = 5000; - -const Divider = styled.div` - width: 0; - height: 100%; - border-left: ${(props) => props.theme.eui.euiBorderThin}; - height: 45px; -`; export const ListLayout: React.FunctionComponent<{}> = ({ children }) => { const { getHref } = useLink(); const hasWriteCapabilites = useCapabilities().write; - const agentStatusRequest = useGetAgentStatus(undefined, { - pollIntervalMs: REFRESH_INTERVAL_MS, - }); - const agentStatus = agentStatusRequest.data?.results; // Agent enrollment flyout state const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = React.useState(false); - const headerRightColumn = ( - - - } - description={i18n.translate('xpack.fleet.agentListStatus.totalLabel', { - defaultMessage: 'Agents', - })} - /> - - - - + const headerRightColumn = hasWriteCapabilites ? ( + - - - - } - description={i18n.translate('xpack.fleet.agentListStatus.onlineLabel', { - defaultMessage: 'Online', - })} - /> + setIsEnrollmentFlyoutOpen(true)}> + + - - } - description={i18n.translate('xpack.fleet.agentListStatus.offlineLabel', { - defaultMessage: 'Offline', - })} - /> - - - } - description={i18n.translate('xpack.fleet.agentListStatus.errorLabel', { - defaultMessage: 'Error', - })} - /> - - {hasWriteCapabilites && ( - <> - - - - - setIsEnrollmentFlyoutOpen(true)}> - - - - - )} - ); + ) : undefined; const headerLeftColumn = ( @@ -177,10 +86,12 @@ export const ListLayout: React.FunctionComponent<{}> = ({ children }) => { } > {isEnrollmentFlyoutOpen ? ( - setIsEnrollmentFlyoutOpen(false)} - /> + + setIsEnrollmentFlyoutOpen(false)} + /> + ) : null} {children} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx new file mode 100644 index 0000000000000..5e7b42798c294 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { euiPaletteColorBlindBehindText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SimplifiedAgentStatus } from '../../../types'; + +const visColors = euiPaletteColorBlindBehindText(); +const colorToHexMap = { + // TODO - replace with variable once https://github.com/elastic/eui/issues/2731 is closed + default: '#d3dae6', + primary: visColors[1], + secondary: visColors[0], + accent: visColors[2], + warning: visColors[5], + danger: visColors[9], +}; + +export const AGENT_STATUSES: SimplifiedAgentStatus[] = [ + 'healthy', + 'unhealthy', + 'updating', + 'offline', + 'inactive', +]; + +export function getColorForAgentStatus(agentStatus: SimplifiedAgentStatus): string { + switch (agentStatus) { + case 'healthy': + return colorToHexMap.secondary; + case 'offline': + case 'inactive': + return colorToHexMap.default; + case 'unhealthy': + return colorToHexMap.warning; + case 'updating': + return colorToHexMap.primary; + default: + throw new Error(`Insuported Agent status ${agentStatus}`); + } +} + +export function getLabelForAgentStatus(agentStatus: SimplifiedAgentStatus): string { + switch (agentStatus) { + case 'healthy': + return i18n.translate('xpack.fleet.agentStatus.healthyLabel', { + defaultMessage: 'Healthy', + }); + case 'offline': + return i18n.translate('xpack.fleet.agentStatus.offlineLabel', { + defaultMessage: 'Offline', + }); + case 'inactive': + return i18n.translate('xpack.fleet.agentStatus.inactiveLabel', { + defaultMessage: 'Inactive', + }); + case 'unhealthy': + return i18n.translate('xpack.fleet.agentStatus.unhealthyLabel', { + defaultMessage: 'Unhealthy', + }); + case 'updating': + return i18n.translate('xpack.fleet.agentStatus.updatingLabel', { + defaultMessage: 'Updating', + }); + default: + throw new Error(`Insuported Agent status ${agentStatus}`); + } +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts index dd80c1ad77b85..dadacf6006085 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts @@ -12,6 +12,7 @@ export { AgentPolicy, NewAgentPolicy, AgentEvent, + SimplifiedAgentStatus, EnrollmentAPIKey, PackagePolicy, NewPackagePolicy, diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index eff7d3c3c5cf3..a867196f9762f 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -330,7 +330,8 @@ export const getAgentStatusForAgentPolicyHandler: RequestHandler< // TODO change path const results = await AgentService.getAgentStatusForAgentPolicy( soClient, - request.query.policyId + request.query.policyId, + request.query.kuery ); const body: GetAgentStatusResponse = { results }; diff --git a/x-pack/plugins/fleet/server/services/agents/status.ts b/x-pack/plugins/fleet/server/services/agents/status.ts index 35033cbe86ea5..0dfa6db7df9be 100644 --- a/x-pack/plugins/fleet/server/services/agents/status.ts +++ b/x-pack/plugins/fleet/server/services/agents/status.ts @@ -23,7 +23,8 @@ export const getAgentStatus = AgentStatusKueryHelper.getAgentStatus; export async function getAgentStatusForAgentPolicy( soClient: SavedObjectsClientContract, - agentPolicyId?: string + agentPolicyId?: string, + filterKuery?: string ) { const [all, online, error, offline] = await Promise.all( [ @@ -36,15 +37,29 @@ export async function getAgentStatusForAgentPolicy( showInactive: false, perPage: 0, page: 1, - kuery: agentPolicyId - ? kuery - ? `(${kuery}) and (${AGENT_SAVED_OBJECT_TYPE}.policy_id:"${agentPolicyId}")` - : `${AGENT_SAVED_OBJECT_TYPE}.policy_id:"${agentPolicyId}"` - : kuery, + kuery: joinKuerys( + ...[ + kuery, + filterKuery, + agentPolicyId ? `${AGENT_SAVED_OBJECT_TYPE}.policy_id:"${agentPolicyId}"` : undefined, + ] + ), }) ) ); + function joinKuerys(...kuerys: Array) { + return kuerys + .filter((kuery) => kuery !== undefined) + .reduce((acc, kuery) => { + if (acc === '') { + return `(${kuery})`; + } + + return `${acc} and (${kuery})`; + }, ''); + } + return { events: await getEventsCount(soClient, agentPolicyId), total: all.total, diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index 6de94cd9c936d..3e9262c2a9124 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -246,5 +246,6 @@ export const UpdateAgentRequestSchema = { export const GetAgentStatusRequestSchema = { query: schema.object({ policyId: schema.maybe(schema.string()), + kuery: schema.maybe(schema.string()), }), }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index eb1fd694114ce..2effc90946b58 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7190,22 +7190,15 @@ "xpack.fleet.agentList.policyFilterText": "エージェントポリシー", "xpack.fleet.agentList.reassignActionText": "新しいポリシーに割り当てる", "xpack.fleet.agentList.revisionNumber": "rev. {revNumber}", - "xpack.fleet.agentList.showInactiveSwitchLabel": "非アクティブ", "xpack.fleet.agentList.showUpgradeableFilterLabel": "アップグレードが利用可能です", "xpack.fleet.agentList.statusColumnTitle": "ステータス", - "xpack.fleet.agentList.statusErrorFilterText": "エラー", "xpack.fleet.agentList.statusFilterText": "ステータス", "xpack.fleet.agentList.statusOfflineFilterText": "オフライン", - "xpack.fleet.agentList.statusOnlineFilterText": "オンライン", "xpack.fleet.agentList.statusUpdatingFilterText": "更新中", "xpack.fleet.agentList.unenrollOneButton": "エージェントの登録解除", "xpack.fleet.agentList.upgradeOneButton": "エージェントをアップグレード", "xpack.fleet.agentList.versionTitle": "バージョン", "xpack.fleet.agentList.viewActionText": "エージェントを表示", - "xpack.fleet.agentListStatus.errorLabel": "エラー", - "xpack.fleet.agentListStatus.offlineLabel": "オフライン", - "xpack.fleet.agentListStatus.onlineLabel": "オンライン", - "xpack.fleet.agentListStatus.totalLabel": "エージェント", "xpack.fleet.agentPolicy.confirmModalCalloutDescription": "選択されたエージェントポリシー{policyName}が一部のエージェントですでに使用されていることをFleetが検出しました。このアクションの結果として、Fleetはこのポリシーで使用されているすべてのエージェントに更新をデプロイします。", "xpack.fleet.agentPolicy.confirmModalCancelButtonLabel": "キャンセル", "xpack.fleet.agentPolicy.confirmModalConfirmButtonLabel": "変更を保存してデプロイ", @@ -7359,7 +7352,6 @@ "xpack.fleet.dataStreamList.viewDashboardActionText": "ダッシュボードを表示", "xpack.fleet.dataStreamList.viewDashboardsActionText": "ダッシュボードを表示", "xpack.fleet.dataStreamList.viewDashboardsPanelTitle": "ダッシュボードを表示", - "xpack.fleet.defaultSearchPlaceholderText": "検索", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsMessage": "{agentsCount, plural, one {#個のエージェントは} other {#個のエージェントは}}このエージェントポリシーに割り当てられました。このポリシーを削除する前に、これらのエージェントの割り当てを解除します。", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsTitle": "使用中のポリシー", "xpack.fleet.deleteAgentPolicy.confirmModal.cancelButtonLabel": "キャンセル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8ad261449854e..b2380d9ac226b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7196,22 +7196,15 @@ "xpack.fleet.agentList.policyFilterText": "代理策略", "xpack.fleet.agentList.reassignActionText": "分配到新策略", "xpack.fleet.agentList.revisionNumber": "修订 {revNumber}", - "xpack.fleet.agentList.showInactiveSwitchLabel": "非活动", "xpack.fleet.agentList.showUpgradeableFilterLabel": "升级可用", "xpack.fleet.agentList.statusColumnTitle": "状态", - "xpack.fleet.agentList.statusErrorFilterText": "错误", "xpack.fleet.agentList.statusFilterText": "状态", "xpack.fleet.agentList.statusOfflineFilterText": "脱机", - "xpack.fleet.agentList.statusOnlineFilterText": "联机", "xpack.fleet.agentList.statusUpdatingFilterText": "正在更新", "xpack.fleet.agentList.unenrollOneButton": "取消注册代理", "xpack.fleet.agentList.upgradeOneButton": "升级代理", "xpack.fleet.agentList.versionTitle": "版本", "xpack.fleet.agentList.viewActionText": "查看代理", - "xpack.fleet.agentListStatus.errorLabel": "错误", - "xpack.fleet.agentListStatus.offlineLabel": "脱机", - "xpack.fleet.agentListStatus.onlineLabel": "联机", - "xpack.fleet.agentListStatus.totalLabel": "代理", "xpack.fleet.agentPolicy.confirmModalCalloutDescription": "Fleet 检测到您的部分代理已在使用选定代理策略 {policyName}。由于此操作,Fleet 会将更新部署到使用此策略的所有代理。", "xpack.fleet.agentPolicy.confirmModalCalloutTitle": "此操作将更新 {agentCount, plural, one {# 个代理} other {# 个代理}}", "xpack.fleet.agentPolicy.confirmModalCancelButtonLabel": "取消", @@ -7366,7 +7359,6 @@ "xpack.fleet.dataStreamList.viewDashboardActionText": "查看仪表板", "xpack.fleet.dataStreamList.viewDashboardsActionText": "查看仪表板", "xpack.fleet.dataStreamList.viewDashboardsPanelTitle": "查看仪表板", - "xpack.fleet.defaultSearchPlaceholderText": "搜索", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsMessage": "{agentsCount, plural, one {# 个代理} other {# 个代理}}已分配到此代理策略。在删除此策略前取消分配这些代理。", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsTitle": "在用的策略", "xpack.fleet.deleteAgentPolicy.confirmModal.cancelButtonLabel": "取消", From 0f408041b48a03fa46492f62d08fe5d8025390ea Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 9 Dec 2020 22:16:38 -0500 Subject: [PATCH 52/52] [SECURITY SOLUTION] Bundles _source -> Fields + able to sort on multiple fields in Timeline (#83761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * replace _source with fields * wip * unit test * regroup sorting and number together * fix bugs from review * mistake * Update x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx Co-authored-by: Patryk Kopyciński * fix snapshot * review + fix topN and filter from detail view * fix tests * fix test Co-authored-by: Patryk Kopyciński --- .../matrix_histogram/events/index.ts | 1 + .../common/search_strategy/timeline/index.ts | 2 +- .../common/types/timeline/index.ts | 7 +- .../cypress/integration/events_viewer.spec.ts | 4 +- .../expected_timelines_export.ndjson | 2 +- .../draggable_wrapper_hover_content.tsx | 4 +- .../events_viewer/events_viewer.test.tsx | 10 +- .../events_viewer/events_viewer.tsx | 15 +- .../public/common/mock/global_state.ts | 2 +- .../public/common/mock/timeline_results.ts | 14 +- .../components/alerts_table/actions.test.tsx | 52 +- .../public/graphql/introspection.json | 39 +- .../security_solution/public/graphql/types.ts | 58 +- .../components/open_timeline/helpers.test.ts | 80 ++- .../components/open_timeline/helpers.ts | 29 +- .../__snapshots__/index.test.tsx.snap | 12 +- .../body/column_headers/actions/index.tsx | 4 +- .../body/column_headers/column_header.tsx | 4 +- .../header/__snapshots__/index.test.tsx.snap | 20 +- .../column_headers/header/header_content.tsx | 8 +- .../body/column_headers/header/helpers.ts | 17 +- .../body/column_headers/header/index.test.tsx | 54 +- .../body/column_headers/header/index.tsx | 53 +- .../body/column_headers/index.test.tsx | 170 +++++- .../timeline/body/column_headers/index.tsx | 75 ++- .../body/column_headers/translations.ts | 4 + .../components/timeline/body/constants.ts | 10 +- .../components/timeline/body/index.test.tsx | 25 +- .../components/timeline/body/index.tsx | 4 +- .../timeline/body/renderers/constants.tsx | 1 + .../body/renderers/formatted_field.tsx | 3 + .../sort_indicator.test.tsx.snap | 3 + .../body/sort/sort_indicator.test.tsx | 14 +- .../timeline/body/sort/sort_indicator.tsx | 9 +- .../timeline/body/sort/sort_number.tsx | 26 + .../timelines/components/timeline/events.ts | 4 + .../components/timeline/index.test.tsx | 17 +- .../timelines/components/timeline/index.tsx | 10 +- .../__snapshots__/index.test.tsx.snap | 10 +- .../timeline/query_tab_content/index.test.tsx | 10 +- .../timeline/query_tab_content/index.tsx | 11 +- .../timelines/components/timeline/styles.tsx | 16 + .../public/timelines/containers/api.test.ts | 10 +- .../timelines/containers/details/index.tsx | 3 + .../public/timelines/containers/index.tsx | 12 +- .../containers/local_storage/index.tsx | 7 +- .../containers/one/index.gql_query.ts | 5 +- .../timelines/containers/persist.gql_query.ts | 5 +- .../timelines/store/timeline/actions.ts | 4 +- .../timelines/store/timeline/defaults.ts | 10 +- .../timelines/store/timeline/epic.test.ts | 12 +- .../timeline/epic_local_storage.test.tsx | 20 +- .../timelines/store/timeline/helpers.ts | 4 +- .../public/timelines/store/timeline/model.ts | 2 +- .../timelines/store/timeline/reducer.test.ts | 24 +- .../server/graphql/timeline/schema.gql.ts | 8 +- .../security_solution/server/graphql/types.ts | 35 +- .../convert_saved_object_to_savedtimeline.ts | 6 + .../search_strategy/helpers/to_array.ts | 36 +- .../factory/events/all/helpers.test.ts | 123 ++++ .../timeline/factory/events/all/helpers.ts | 18 +- .../timeline/factory/events/all/index.ts | 6 +- .../events/all/query.events_all.dsl.ts | 15 +- .../factory/events/details/helpers.test.ts | 153 +++++ .../factory/events/details/helpers.ts | 62 +- .../timeline/factory/events/details/index.ts | 9 +- .../details/query.events_details.dsl.ts | 1 + .../saved_objects/timeline.ts | 2 +- .../security_solution/timeline_details.ts | 577 +++++++++++++----- 69 files changed, 1474 insertions(+), 608 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx create mode 100644 x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts index f1307335215ed..4844b0c545198 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts @@ -28,6 +28,7 @@ export interface EventsActionGroupData { export interface EventHit extends SearchHit { sort: string[]; _source: EventSource; + fields: Record; aggregations: { // eslint-disable-next-line @typescript-eslint/no-explicit-any [agg: string]: any; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index 578f905617746..6f71ed5c27516 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -31,7 +31,7 @@ export interface TimelineRequestBasicOptions extends IEsSearchRequest { export interface TimelineRequestOptionsPaginated extends TimelineRequestBasicOptions { pagination: Pick; - sort: SortField; + sort: Array>; } export type TimelineStrategyResponseType< diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 967b3870cb9e0..4b26b4157da0c 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -143,10 +143,15 @@ const SavedFavoriteRuntimeType = runtimeTypes.partial({ /* * Sort Types */ -const SavedSortRuntimeType = runtimeTypes.partial({ + +const SavedSortObject = runtimeTypes.partial({ columnId: unionWithNullType(runtimeTypes.string), sortDirection: unionWithNullType(runtimeTypes.string), }); +const SavedSortRuntimeType = runtimeTypes.union([ + runtimeTypes.array(SavedSortObject), + SavedSortObject, +]); /* * Timeline Statuses diff --git a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts index 9eb49c19c23f6..664de967b9aff 100644 --- a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts @@ -157,9 +157,9 @@ describe('Events Viewer', () => { it('re-orders columns via drag and drop', () => { const originalColumnOrder = - '@timestampmessagehost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; + '@timestamp1messagehost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; const expectedOrderAfterDragAndDrop = - 'message@timestamphost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; + 'message@timestamp1host.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; cy.get(HEADERS_GROUP).invoke('text').should('equal', originalColumnOrder); dragAndDropColumn({ column: 0, newPosition: 0 }); diff --git a/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson b/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson index 9cca356a8b052..7ee8d189d7dca 100644 --- a/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson +++ b/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson @@ -1 +1 @@ -{"savedObjectId":"0162c130-78be-11ea-9718-118a926974a4","version":"WzcsMV0=","columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"message"},{"columnHeaderType":"not-filtered","id":"event.category"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"source.ip"},{"columnHeaderType":"not-filtered","id":"destination.ip"},{"columnHeaderType":"not-filtered","id":"user.name"}],"created":1586256805054,"createdBy":"elastic","dataProviders":[],"dateRange":{"end":1586256837669,"start":1546343624710},"description":"description","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"expression":"host.name:*","kind":"kuery"},"serializedQuery":"{\"bool\":{\"should\":[{\"exists\":{\"field\":\"host.name\"}}],\"minimum_should_match\":1}}"}},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"title":"SIEM test","updated":1586256839298,"updatedBy":"elastic","timelineType":"default","eventNotes":[],"globalNotes":[],"pinnedEventIds":[]} +{"savedObjectId":"0162c130-78be-11ea-9718-118a926974a4","version":"WzcsMV0=","columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"message"},{"columnHeaderType":"not-filtered","id":"event.category"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"source.ip"},{"columnHeaderType":"not-filtered","id":"destination.ip"},{"columnHeaderType":"not-filtered","id":"user.name"}],"created":1586256805054,"createdBy":"elastic","dataProviders":[],"dateRange":{"end":1586256837669,"start":1546343624710},"description":"description","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"expression":"host.name:*","kind":"kuery"},"serializedQuery":"{\"bool\":{\"should\":[{\"exists\":{\"field\":\"host.name\"}}],\"minimum_should_match\":1}}"}},"savedQueryId":null,"sort":[{"columnId":"@timestamp","sortDirection":"desc"}],"title":"SIEM test","updated":1586256839298,"updatedBy":"elastic","timelineType":"default","eventNotes":[],"globalNotes":[],"pinnedEventIds":[]} diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index f0eae407eedce..5aaef5cbb9ac4 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -19,7 +19,7 @@ import { allowTopN } from './helpers'; import * as i18n from './translations'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; -import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../../../timelines/components/timeline/styles'; +import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; @@ -230,7 +230,7 @@ export const useGetTimelineId = function ( if ( myElem != null && myElem.classList != null && - myElem.classList.contains(SELECTOR_TIMELINE_BODY_CLASS_NAME) && + myElem.classList.contains(SELECTOR_TIMELINE_GLOBAL_CONTAINER) && myElem.hasAttribute('data-timeline-id') ) { setTimelineId(myElem.getAttribute('data-timeline-id')); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 7132add229edb..8710503924d84 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -79,10 +79,12 @@ const eventsViewerDefaultProps = { language: 'kql', }, start: from, - sort: { - columnId: 'foo', - sortDirection: 'none' as SortDirection, - }, + sort: [ + { + columnId: 'foo', + sortDirection: 'none' as SortDirection, + }, + ], scopeId: SourcererScopeName.timeline, utilityBar, }; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 208d60ac73865..c578e017c4d95 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -115,7 +115,7 @@ interface Props { query: Query; onRuleChange?: () => void; start: string; - sort: Sort; + sort: Sort[]; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; @@ -202,11 +202,12 @@ const EventsViewerComponent: React.FC = ({ ]); const sortField = useMemo( - () => ({ - field: sort.columnId, - direction: sort.sortDirection as Direction, - }), - [sort.columnId, sort.sortDirection] + () => + sort.map(({ columnId, sortDirection }) => ({ + field: columnId, + direction: sortDirection as Direction, + })), + [sort] ); const [ @@ -341,7 +342,7 @@ export const EventsViewer = React.memo( prevProps.kqlMode === nextProps.kqlMode && deepEqual(prevProps.query, nextProps.query) && prevProps.start === nextProps.start && - prevProps.sort === nextProps.sort && + deepEqual(prevProps.sort, nextProps.sort) && prevProps.utilityBar === nextProps.utilityBar && prevProps.graphEventId === nextProps.graphEventId ); diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index db414dfab5c09..db21847991534 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -239,7 +239,7 @@ export const mockGlobalState: State = { pinnedEventIds: {}, pinnedEventsSaveObject: {}, itemsPerPageOptions: [5, 10, 20], - sort: { columnId: '@timestamp', sortDirection: Direction.desc }, + sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }], isSaving: false, version: null, status: TimelineStatus.active, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index d927fcb27e099..c8d9fc981d880 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2142,10 +2142,12 @@ export const mockTimelineModel: TimelineModel = { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ], status: TimelineStatus.active, title: 'Test rule', timelineType: TimelineType.default, @@ -2177,7 +2179,7 @@ export const mockTimelineResult: TimelineResult = { templateTimelineId: null, templateTimelineVersion: null, savedQueryId: null, - sort: { columnId: '@timestamp', sortDirection: 'desc' }, + sort: [{ columnId: '@timestamp', sortDirection: 'desc' }], version: '1', }; @@ -2247,7 +2249,7 @@ export const defaultTimelineProps: CreateTimelineProps = { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { columnId: '@timestamp', sortDirection: Direction.desc }, + sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 55258af7332e1..d251cce381536 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -105,80 +105,38 @@ describe('alert actions', () => { activeTab: TimelineTabs.query, columns: [ { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: '@timestamp', - placeholder: undefined, - type: undefined, width: 190, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'message', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'event.category', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'host.name', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'source.ip', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'destination.ip', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'user.name', - placeholder: undefined, - type: undefined, width: 180, }, ], @@ -242,10 +200,12 @@ describe('alert actions', () => { selectedEventIds: {}, show: true, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 30dec34ab39b7..9e0cf10a54aa9 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -2231,7 +2231,7 @@ "name": "sort", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "SortTimelineResult", "ofType": null }, + "type": { "kind": "SCALAR", "name": "ToAny", "ofType": null }, "isDeprecated": false, "deprecationReason": null }, @@ -2953,33 +2953,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "OBJECT", - "name": "SortTimelineResult", - "description": "", - "fields": [ - { - "name": "columnId", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sortDirection", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, { "kind": "ENUM", "name": "TimelineStatus", @@ -3650,7 +3623,15 @@ { "name": "sort", "description": "", - "type": { "kind": "INPUT_OBJECT", "name": "SortTimelineInput", "ofType": null }, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "INPUT_OBJECT", "name": "SortTimelineInput", "ofType": null } + } + }, "defaultValue": null }, { diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index 17f8e19a60552..435576a02b30e 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -103,7 +103,7 @@ export interface TimelineInput { savedQueryId?: Maybe; - sort?: Maybe; + sort?: Maybe; status?: Maybe; } @@ -512,17 +512,17 @@ export interface CloudFields { machine?: Maybe; - provider?: Maybe[]>; + provider?: Maybe<(Maybe)[]>; - region?: Maybe[]>; + region?: Maybe<(Maybe)[]>; } export interface CloudInstance { - id?: Maybe[]>; + id?: Maybe<(Maybe)[]>; } export interface CloudMachine { - type?: Maybe[]>; + type?: Maybe<(Maybe)[]>; } export interface EndpointFields { @@ -632,7 +632,7 @@ export interface TimelineResult { savedObjectId: string; - sort?: Maybe; + sort?: Maybe; status?: Maybe; @@ -775,14 +775,8 @@ export interface KueryFilterQueryResult { expression?: Maybe; } -export interface SortTimelineResult { - columnId?: Maybe; - - sortDirection?: Maybe; -} - export interface ResponseTimelines { - timeline: Maybe[]; + timeline: (Maybe)[]; totalCount?: Maybe; @@ -1533,9 +1527,9 @@ export interface HostFields { id?: Maybe; - ip?: Maybe[]>; + ip?: Maybe<(Maybe)[]>; - mac?: Maybe[]>; + mac?: Maybe<(Maybe)[]>; name?: Maybe; @@ -1551,7 +1545,7 @@ export interface IndexField { /** Example of field's value */ example?: Maybe; /** whether the field's belong to an alias index */ - indexes: Maybe[]; + indexes: (Maybe)[]; /** The name of the field */ name: string; /** The type of the field's values as recognized by Kibana */ @@ -1749,7 +1743,7 @@ export namespace GetHostOverviewQuery { __typename?: 'AgentFields'; id: Maybe; - } + }; export type Host = { __typename?: 'HostEcsFields'; @@ -1788,21 +1782,21 @@ export namespace GetHostOverviewQuery { machine: Maybe; - provider: Maybe[]>; + provider: Maybe<(Maybe)[]>; - region: Maybe[]>; + region: Maybe<(Maybe)[]>; }; export type Instance = { __typename?: 'CloudInstance'; - id: Maybe[]>; + id: Maybe<(Maybe)[]>; }; export type Machine = { __typename?: 'CloudMachine'; - type: Maybe[]>; + type: Maybe<(Maybe)[]>; }; export type Inspect = { @@ -1985,7 +1979,7 @@ export namespace GetAllTimeline { favoriteCount: Maybe; - timeline: Maybe[]; + timeline: (Maybe)[]; }; export type Timeline = { @@ -2240,7 +2234,7 @@ export namespace GetOneTimeline { savedQueryId: Maybe; - sort: Maybe; + sort: Maybe; created: Maybe; @@ -2494,14 +2488,6 @@ export namespace GetOneTimeline { version: Maybe; }; - - export type Sort = { - __typename?: 'SortTimelineResult'; - - columnId: Maybe; - - sortDirection: Maybe; - }; } export namespace PersistTimelineMutation { @@ -2560,7 +2546,7 @@ export namespace PersistTimelineMutation { savedQueryId: Maybe; - sort: Maybe; + sort: Maybe; created: Maybe; @@ -2744,14 +2730,6 @@ export namespace PersistTimelineMutation { end: Maybe; }; - - export type Sort = { - __typename?: 'SortTimelineResult'; - - columnId: Maybe; - - sortDirection: Maybe; - }; } export namespace PersistTimelinePinnedEventMutation { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 5a1540b970300..6c76da44c8557 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -312,10 +312,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, @@ -411,10 +413,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.template, @@ -510,10 +514,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, @@ -607,10 +613,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, @@ -745,10 +753,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, id: 'savedObject-1', }); @@ -912,10 +922,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, id: 'savedObject-1', }); @@ -1007,10 +1019,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.immutable, title: 'Awesome Timeline', timelineType: TimelineType.template, @@ -1106,10 +1120,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.active, title: 'Awesome Timeline', timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 1ee529cc77a91..76eb9196e8c5c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -108,21 +108,20 @@ const parseString = (params: string) => { } }; -const setTimelineColumn = (col: ColumnHeaderResult) => { - const timelineCols: ColumnHeaderOptions = { - ...col, - columnHeaderType: defaultColumnHeaderType, - id: col.id != null ? col.id : 'unknown', - placeholder: col.placeholder != null ? col.placeholder : undefined, - category: col.category != null ? col.category : undefined, - description: col.description != null ? col.description : undefined, - example: col.example != null ? col.example : undefined, - type: col.type != null ? col.type : undefined, - aggregatable: col.aggregatable != null ? col.aggregatable : undefined, - width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, - }; - return timelineCols; -}; +const setTimelineColumn = (col: ColumnHeaderResult) => + Object.entries(col).reduce( + (acc, [key, value]) => { + if (key !== 'id' && value != null) { + return { ...acc, [key]: value }; + } + return acc; + }, + { + columnHeaderType: defaultColumnHeaderType, + id: col.id != null ? col.id : 'unknown', + width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, + } + ); const setTimelineFilters = (filter: FilterTimelineResult) => ({ $state: { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 7772bcede76fc..36e0652c3032a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx index c4c4e0e0c7065..8ec8827ccbed6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx @@ -18,7 +18,7 @@ interface Props { header: ColumnHeaderOptions; isLoading: boolean; onColumnRemoved: OnColumnRemoved; - sort: Sort; + sort: Sort[]; } /** Given a `header`, returns the `SortDirection` applicable to it */ @@ -53,7 +53,7 @@ CloseButton.displayName = 'CloseButton'; export const Actions = React.memo(({ header, onColumnRemoved, sort, isLoading }) => { return ( <> - {sort.columnId === header.id && isLoading ? ( + {sort.some((i) => i.columnId === header.id) && isLoading ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx index 8bf9b6ceb346a..543ffe2798947 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx @@ -26,7 +26,7 @@ interface ColumneHeaderProps { header: ColumnHeaderOptions; isDragging: boolean; onFilterChange?: OnFilterChange; - sort: Sort; + sort: Sort[]; timelineId: string; } @@ -131,6 +131,6 @@ export const ColumnHeader = React.memo( prevProps.timelineId === nextProps.timelineId && prevProps.isDragging === nextProps.isDragging && prevProps.onFilterChange === nextProps.onFilterChange && - prevProps.sort === nextProps.sort && + deepEqual(prevProps.sort, nextProps.sort) && deepEqual(prevProps.header, nextProps.header) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap index 517f537b9a01b..fa9a4e78d88f2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -14,10 +14,12 @@ exports[`Header renders correctly against snapshot 1`] = ` isResizing={false} onClick={[Function]} sort={ - Object { - "columnId": "@timestamp", - "sortDirection": "desc", - } + Array [ + Object { + "columnId": "@timestamp", + "sortDirection": "desc", + }, + ] } > diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx index 19d0220cd3462..656cf234ea662 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx @@ -14,15 +14,14 @@ import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from import { Sort } from '../../sort'; import { SortIndicator } from '../../sort/sort_indicator'; import { HeaderToolTipContent } from '../header_tooltip_content'; -import { getSortDirection } from './helpers'; - +import { getSortDirection, getSortIndex } from './helpers'; interface HeaderContentProps { children: React.ReactNode; header: ColumnHeaderOptions; isLoading: boolean; isResizing: boolean; onClick: () => void; - sort: Sort; + sort: Sort[]; } const HeaderContentComponent: React.FC = ({ @@ -33,7 +32,7 @@ const HeaderContentComponent: React.FC = ({ onClick, sort, }) => ( - + {header.aggregatable ? ( = ({ ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts index 609f690903bf2..b2ad186ce1b1e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts @@ -11,7 +11,7 @@ import { Sort, SortDirection } from '../../sort'; interface GetNewSortDirectionOnClickParams { clickedHeader: ColumnHeaderOptions; - currentSort: Sort; + currentSort: Sort[]; } /** Given a `header`, returns the `SortDirection` applicable to it */ @@ -19,7 +19,10 @@ export const getNewSortDirectionOnClick = ({ clickedHeader, currentSort, }: GetNewSortDirectionOnClickParams): Direction => - clickedHeader.id === currentSort.columnId ? getNextSortDirection(currentSort) : Direction.desc; + currentSort.reduce( + (acc, item) => (clickedHeader.id === item.columnId ? getNextSortDirection(item) : acc), + Direction.desc + ); /** Given a current sort direction, it returns the next sort direction */ export const getNextSortDirection = (currentSort: Sort): Direction => { @@ -37,8 +40,14 @@ export const getNextSortDirection = (currentSort: Sort): Direction => { interface GetSortDirectionParams { header: ColumnHeaderOptions; - sort: Sort; + sort: Sort[]; } export const getSortDirection = ({ header, sort }: GetSortDirectionParams): SortDirection => - header.id === sort.columnId ? sort.sortDirection : 'none'; + sort.reduce( + (acc, item) => (header.id === item.columnId ? item.sortDirection : acc), + 'none' + ); + +export const getSortIndex = ({ header, sort }: GetSortDirectionParams): number => + sort.findIndex((s) => s.columnId === header.id); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx index 3ef9beb89309e..58d40c94ac338 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx @@ -32,10 +32,12 @@ const filteredColumnHeader: ColumnHeaderType = 'text-filter'; describe('Header', () => { const columnHeader = defaultHeaders[0]; - const sort: Sort = { - columnId: columnHeader.id, - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: columnHeader.id, + sortDirection: Direction.desc, + }, + ]; const timelineId = 'fakeId'; test('renders correctly against snapshot', () => { @@ -119,10 +121,12 @@ describe('Header', () => { expect(mockDispatch).toBeCalledWith( timelineActions.updateSort({ id: timelineId, - sort: { - columnId: columnHeader.id, - sortDirection: Direction.asc, // (because the previous state was Direction.desc) - }, + sort: [ + { + columnId: columnHeader.id, + sortDirection: Direction.asc, // (because the previous state was Direction.desc) + }, + ], }) ); }); @@ -158,7 +162,7 @@ describe('Header', () => { ); - wrapper.find('[data-test-subj="header"]').first().simulate('click'); + wrapper.find(`[data-test-subj="header-${columnHeader.id}"]`).first().simulate('click'); expect(mockOnColumnSorted).not.toHaveBeenCalled(); }); @@ -180,14 +184,16 @@ describe('Header', () => { describe('getSortDirection', () => { test('it returns the sort direction when the header id matches the sort column id', () => { - expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort.sortDirection); + expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort[0].sortDirection); }); test('it returns "none" when sort direction when the header id does NOT match the sort column id', () => { - const nonMatching: Sort = { - columnId: 'differentSocks', - sortDirection: Direction.desc, - }; + const nonMatching: Sort[] = [ + { + columnId: 'differentSocks', + sortDirection: Direction.desc, + }, + ]; expect(getSortDirection({ header: columnHeader, sort: nonMatching })).toEqual('none'); }); @@ -221,10 +227,12 @@ describe('Header', () => { describe('getNewSortDirectionOnClick', () => { test('it returns the expected new sort direction when the header id matches the sort column id', () => { - const sortMatches: Sort = { - columnId: columnHeader.id, - sortDirection: Direction.desc, - }; + const sortMatches: Sort[] = [ + { + columnId: columnHeader.id, + sortDirection: Direction.desc, + }, + ]; expect( getNewSortDirectionOnClick({ @@ -235,10 +243,12 @@ describe('Header', () => { }); test('it returns the expected new sort direction when the header id does NOT match the sort column id', () => { - const sortDoesNotMatch: Sort = { - columnId: 'someOtherColumn', - sortDirection: 'none', - }; + const sortDoesNotMatch: Sort[] = [ + { + columnId: 'someOtherColumn', + sortDirection: 'none', + }, + ]; expect( getNewSortDirectionOnClick({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx index 15d75cc9a4384..192a9c6b0973b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx @@ -21,7 +21,7 @@ import { useManageTimeline } from '../../../../manage_timeline'; interface Props { header: ColumnHeaderOptions; onFilterChange?: OnFilterChange; - sort: Sort; + sort: Sort[]; timelineId: string; } @@ -33,22 +33,39 @@ export const HeaderComponent: React.FC = ({ }) => { const dispatch = useDispatch(); - const onClick = useCallback( - () => - dispatch( - timelineActions.updateSort({ - id: timelineId, - sort: { - columnId: header.id, - sortDirection: getNewSortDirectionOnClick({ - clickedHeader: header, - currentSort: sort, - }), - }, - }) - ), - [dispatch, header, timelineId, sort] - ); + const onColumnSort = useCallback(() => { + const columnId = header.id; + const sortDirection = getNewSortDirectionOnClick({ + clickedHeader: header, + currentSort: sort, + }); + const headerIndex = sort.findIndex((col) => col.columnId === columnId); + let newSort = []; + if (headerIndex === -1) { + newSort = [ + ...sort, + { + columnId, + sortDirection, + }, + ]; + } else { + newSort = [ + ...sort.slice(0, headerIndex), + { + columnId, + sortDirection, + }, + ...sort.slice(headerIndex + 1), + ]; + } + dispatch( + timelineActions.updateSort({ + id: timelineId, + sort: newSort, + }) + ); + }, [dispatch, header, sort, timelineId]); const onColumnRemoved = useCallback( (columnId) => dispatch(timelineActions.removeColumn({ id: timelineId, columnId })), @@ -68,7 +85,7 @@ export const HeaderComponent: React.FC = ({ header={header} isLoading={isLoading} isResizing={false} - onClick={onClick} + onClick={onColumnSort} sort={sort} > { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); +const timelineId = 'test'; describe('ColumnHeaders', () => { const mount = useMountAppended(); describe('rendering', () => { - const sort: Sort = { - columnId: 'fooColumn', - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ]; test('renders correctly against snapshot', () => { const wrapper = shallow( @@ -39,7 +54,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} - timelineId={'test'} + timelineId={timelineId} /> ); @@ -58,7 +73,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} - timelineId={'test'} + timelineId={timelineId} /> ); @@ -78,7 +93,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} - timelineId={'test'} + timelineId={timelineId} /> ); @@ -88,4 +103,145 @@ describe('ColumnHeaders', () => { }); }); }); + + describe('#onColumnsSorted', () => { + let mockSort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + sortDirection: Direction.asc, + }, + ]; + let mockDefaultHeaders = cloneDeep( + defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) + ); + + beforeEach(() => { + mockDefaultHeaders = cloneDeep( + defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) + ); + mockSort = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + sortDirection: Direction.asc, + }, + ]; + }); + + test('Add column `event.category` as desc sorting', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-event.category"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + timelineActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + sortDirection: Direction.asc, + }, + { columnId: 'event.category', sortDirection: Direction.desc }, + ], + }) + ); + }); + + test('Change order of column `@timestamp` from desc to asc without changing index position', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-@timestamp"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + timelineActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.asc, + }, + { columnId: 'host.name', sortDirection: Direction.asc }, + ], + }) + ); + }); + + test('Change order of column `host.name` from asc to desc without changing index position', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-host.name"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + timelineActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { columnId: 'host.name', sortDirection: Direction.desc }, + ], + }) + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index aeab6a774ca41..66856f3bd6284 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiCheckbox, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiCheckbox, + EuiDataGridSorting, + EuiToolTip, + useDataGridColumnSorting, +} from '@elastic/eui'; +import deepEqual from 'fast-deep-equal'; import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Droppable, DraggableChildrenFn } from 'react-beautiful-dnd'; -import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; import { DragEffects } from '../../../../../common/components/drag_and_drop/draggable_wrapper'; import { DraggableFieldBadge } from '../../../../../common/components/draggables/field_badge'; @@ -34,11 +42,18 @@ import { EventsThGroupData, EventsTrHeader, } from '../../styles'; -import { Sort } from '../sort'; +import { Sort, SortDirection } from '../sort'; import { EventsSelect } from './events_select'; import { ColumnHeader } from './column_header'; import * as i18n from './translations'; +import { timelineActions } from '../../../../store/timeline'; + +const SortingColumnsContainer = styled.div` + .euiPopover .euiButtonEmpty .euiButtonContent .euiButtonEmpty__text { + display: none; + } +`; interface Props { actionsColumnWidth: number; @@ -49,7 +64,7 @@ interface Props { onSelectAll: OnSelectAll; showEventsSelect: boolean; showSelectAllCheckbox: boolean; - sort: Sort; + sort: Sort[]; timelineId: string; } @@ -98,6 +113,7 @@ export const ColumnHeadersComponent = ({ sort, timelineId, }: Props) => { + const dispatch = useDispatch(); const [draggingIndex, setDraggingIndex] = useState(null); const { timelineFullScreen, @@ -189,6 +205,48 @@ export const ColumnHeadersComponent = ({ [ColumnHeaderList] ); + const myColumns = useMemo( + () => + columnHeaders.map(({ aggregatable, label, id, type }) => ({ + id, + isSortable: aggregatable, + displayAsText: label, + schema: type, + })), + [columnHeaders] + ); + + const onSortColumns = useCallback( + (cols: EuiDataGridSorting['columns']) => + dispatch( + timelineActions.updateSort({ + id: timelineId, + sort: cols.map(({ id, direction }) => ({ + columnId: id, + sortDirection: direction as SortDirection, + })), + }) + ), + [dispatch, timelineId] + ); + const sortedColumns = useMemo( + () => ({ + onSort: onSortColumns, + columns: sort.map<{ id: string; direction: 'asc' | 'desc' }>( + ({ columnId, sortDirection }) => ({ + id: columnId, + direction: sortDirection as 'asc' | 'desc', + }) + ), + }), + [onSortColumns, sort] + ); + const displayValues = useMemo( + () => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.label ?? ch.id }), {}), + [columnHeaders] + ); + const ColumnSorting = useDataGridColumnSorting(myColumns, sortedColumns, {}, [], displayValues); + return ( @@ -245,6 +303,13 @@ export const ColumnHeadersComponent = ({ + + + + {ColumnSorting} + + + {showEventsSelect && ( @@ -278,7 +343,7 @@ export const ColumnHeaders = React.memo( prevProps.onSelectAll === nextProps.onSelectAll && prevProps.showEventsSelect === nextProps.showEventsSelect && prevProps.showSelectAllCheckbox === nextProps.showSelectAllCheckbox && - prevProps.sort === nextProps.sort && + deepEqual(prevProps.sort, nextProps.sort) && prevProps.timelineId === nextProps.timelineId && deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && deepEqual(prevProps.browserFields, nextProps.browserFields) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts index 1ebfa957b654f..c946182ddfe06 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts @@ -22,6 +22,10 @@ export const FULL_SCREEN = i18n.translate('xpack.securitySolution.timeline.fullS defaultMessage: 'Full screen', }); +export const SORT_FIELDS = i18n.translate('xpack.securitySolution.timeline.sortFieldsButton', { + defaultMessage: 'Sort fields', +}); + export const TYPE = i18n.translate('xpack.securitySolution.timeline.typeTooltip', { defaultMessage: 'Type', }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts index 6fddb5403561e..bf70d7bff1ff5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts @@ -7,15 +7,17 @@ /** The minimum (fixed) width of the Actions column */ export const MINIMUM_ACTIONS_COLUMN_WIDTH = 50; // px; +/** Additional column width to include when checkboxes are shown **/ +export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; + /** The (fixed) width of the Actions column */ -export const DEFAULT_ACTIONS_COLUMN_WIDTH = 24 * 4; // px; +export const DEFAULT_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 5; // px; /** * The (fixed) width of the Actions column when the timeline body is used as * an events viewer, which has fewer actions than a regular events viewer */ -export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = 24 * 3; // px; -/** Additional column width to include when checkboxes are shown **/ -export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; +export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 4; // px; + /** The default minimum width of a column (when a width for the column type is not specified) */ export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px /** The default minimum width of a column of type `date` */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index fc9967bdeff98..704af61b4a12f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -16,13 +16,14 @@ import { TestProviders } from '../../../../common/mock/test_providers'; import { BodyComponent, StatefulBodyProps } from '.'; import { Sort } from './sort'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; -import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../styles'; import { timelineActions } from '../../../store/timeline'; -const mockSort: Sort = { - columnId: '@timestamp', - sortDirection: Direction.desc, -}; +const mockSort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, +]; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -130,20 +131,6 @@ describe('Body', () => { }); }); }, 20000); - - test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_BODY_CLASS_NAME}`, () => { - const wrapper = mount( - - - - ); - expect( - wrapper - .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_BODY_CLASS_NAME}`) - .first() - .exists() - ).toEqual(true); - }); }); describe('action on event', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 45641a34f2cf4..ea397b67c31cc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -33,7 +33,7 @@ interface OwnProps { data: TimelineItem[]; id: string; isEventViewer?: boolean; - sort: Sort; + sort: Sort[]; refetch: inputsModel.Refetch; onRuleChange?: () => void; } @@ -144,7 +144,7 @@ export const BodyComponent = React.memo( return ( <> - + ); + } else if (fieldType === GEO_FIELD_TYPE) { + return <>{value}; } else if (fieldType === DATE_FIELD_TYPE) { return ( + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx index dcaedb90e7252..6593abf71e368 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx @@ -15,12 +15,12 @@ import { getDirection, SortIndicator } from './sort_indicator'; describe('SortIndicator', () => { describe('rendering', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('it renders the expected sort indicator when direction is ascending', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( 'sortUp' @@ -28,7 +28,7 @@ describe('SortIndicator', () => { }); test('it renders the expected sort indicator when direction is descending', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( 'sortDown' @@ -36,7 +36,7 @@ describe('SortIndicator', () => { }); test('it renders the expected sort indicator when direction is `none`', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( 'empty' @@ -60,7 +60,7 @@ describe('SortIndicator', () => { describe('sort indicator tooltip', () => { test('it returns the expected tooltip when the direction is ascending', () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content @@ -68,7 +68,7 @@ describe('SortIndicator', () => { }); test('it returns the expected tooltip when the direction is descending', () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content @@ -76,7 +76,7 @@ describe('SortIndicator', () => { }); test('it does NOT render a tooltip when sort direction is `none`', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sort-indicator-tooltip"]').exists()).toBe(false); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx index 8b842dfa2197e..518103e8cb643 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { Direction } from '../../../../../graphql/types'; import * as i18n from '../translations'; +import { SortNumber } from './sort_number'; import { SortDirection } from '.'; @@ -35,10 +36,11 @@ export const getDirection = (sortDirection: SortDirection): SortDirectionIndicat interface Props { sortDirection: SortDirection; + sortNumber: number; } /** Renders a sort indicator */ -export const SortIndicator = React.memo(({ sortDirection }) => { +export const SortIndicator = React.memo(({ sortDirection, sortNumber }) => { const direction = getDirection(sortDirection); if (direction != null) { @@ -51,7 +53,10 @@ export const SortIndicator = React.memo(({ sortDirection }) => { } data-test-subj="sort-indicator-tooltip" > - + <> + + + ); } else { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx new file mode 100644 index 0000000000000..48dd70a16e70a --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIcon, EuiNotificationBadge } from '@elastic/eui'; +import React from 'react'; + +interface Props { + sortNumber: number; +} + +export const SortNumber = React.memo(({ sortNumber }) => { + if (sortNumber >= 0) { + return ( + + {sortNumber + 1} + + ); + } else { + return ; + } +}); + +SortNumber.displayName = 'SortNumber'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts index 54755fbc84277..11bc3da8c05bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts @@ -38,6 +38,10 @@ export type OnFilterChange = (filter: { columnId: ColumnId; filter: string }) => /** Invoked when a column is sorted */ export type OnColumnSorted = (sorted: { columnId: ColumnId; sortDirection: SortDirection }) => void; +export type OnColumnsSorted = ( + sorted: Array<{ columnId: ColumnId; sortDirection: SortDirection }> +) => void; + export type OnColumnRemoved = (columnId: ColumnId) => void; export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 085a9bf8cba3f..59a7b936dfbac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -20,6 +20,7 @@ import { import { StatefulTimeline, Props as StatefulTimelineOwnProps } from './index'; import { useTimelineEvents } from '../../containers/index'; +import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from './styles'; jest.mock('../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -56,7 +57,7 @@ jest.mock('../../../common/containers/sourcerer', () => { }); describe('StatefulTimeline', () => { const props: StatefulTimelineOwnProps = { - timelineId: 'id', + timelineId: 'timeline-test', }; beforeEach(() => { @@ -71,4 +72,18 @@ describe('StatefulTimeline', () => { ); expect(wrapper.find('[data-test-subj="timeline"]')).toBeTruthy(); }); + + test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`, () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`) + .first() + .exists() + ).toEqual(true); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 37145b9348ac1..4e6bca7fd9625 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -21,13 +21,7 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/h import { activeTimeline } from '../../containers/active_timeline_context'; import * as i18n from './translations'; import { TabsContent } from './tabs_content'; - -const TimelineContainer = styled.div` - height: 100%; - display: flex; - flex-direction: column; - position: relative; -`; +import { TimelineContainer } from './styles'; const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; @@ -78,7 +72,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { }, []); return ( - + {timelineType === TimelineType.template && ( {i18n.TIMELINE_TEMPLATE} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index c726e92455f25..c9355797193a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -276,10 +276,12 @@ In other use cases the message field can be used to concatenate different values showCallOutUnauthorizedMsg={false} showEventDetails={false} sort={ - Object { - "columnId": "@timestamp", - "sortDirection": "desc", - } + Array [ + Object { + "columnId": "@timestamp", + "sortDirection": "desc", + }, + ] } start="2018-03-23T18:49:23.132Z" status="active" diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 4019f46b8c07b..7e60461a01574 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -64,10 +64,12 @@ jest.mock('../../../../common/lib/kibana', () => { describe('Timeline', () => { let props = {} as QueryTabContentComponentProps; - const sort: Sort = { - columnId: '@timestamp', - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ]; const startDate = '2018-03-23T18:49:23.132Z'; const endDate = '2018-03-24T03:33:52.253Z'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 8186ee8b77628..69a7299b9833d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -214,11 +214,12 @@ export const QueryTabContentComponent: React.FC = ({ }, [columns]); const timelineQuerySortField = useMemo( - () => ({ - field: sort.columnId, - direction: sort.sortDirection as Direction, - }), - [sort.columnId, sort.sortDirection] + () => + sort.map(({ columnId, sortDirection }) => ({ + field: columnId, + direction: sortDirection as Direction, + })), + [sort] ); const { initializeTimeline, setIsTimelineLoading } = useManageTimeline(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index 9f9940203960c..ef7c821bd652d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -11,6 +11,19 @@ import styled, { createGlobalStyle } from 'styled-components'; import { TimelineEventsType } from '../../../../common/types/timeline'; import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '../../../common/components/drag_and_drop/helpers'; +/** + * TIMELINE BODY + */ +export const SELECTOR_TIMELINE_GLOBAL_CONTAINER = 'securitySolutionTimeline__container'; +export const TimelineContainer = styled.div.attrs(({ className = '' }) => ({ + className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`, +}))` + height: 100%; + display: flex; + flex-direction: column; + position: relative; +`; + /** * TIMELINE BODY */ @@ -99,6 +112,9 @@ export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({ min-width: 0; padding-left: ${({ isEventViewer }) => !isEventViewer ? '4px;' : '0;'}; // match timeline event border + button { + color: ${({ theme }) => theme.eui.euiColorPrimary}; + } `; export const EventsThGroupData = styled.div.attrs(({ className = '' }) => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts index a439699d27f6d..7e2a6fa1c15cf 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts @@ -73,10 +73,12 @@ const timelineData = { end: 1591084965409, }, savedQueryId: null, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.active, }; const mockPatchTimelineResponse = { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 8f1644550d147..ebc86b3c5cf5e 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -85,6 +85,9 @@ export const useTimelineEventsDetails = ({ } }, error: () => { + if (!didCancel) { + setLoading(false); + } notifications.toasts.addDanger('Failed to run search'); }, }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index a168e814208e7..3baab2024558f 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -56,7 +56,7 @@ export interface UseTimelineEventsProps { fields: string[]; indexNames: string[]; limit: number; - sort: SortField; + sort: SortField[]; startDate: string; timerangeKind?: 'absolute' | 'relative'; } @@ -65,10 +65,12 @@ const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] => timelineEdges.map((e: TimelineEdges) => e.node); const ID = 'timelineEventsQuery'; -export const initSortDefault = { - field: '@timestamp', - direction: Direction.asc, -}; +export const initSortDefault = [ + { + field: '@timestamp', + direction: Direction.asc, + }, +]; function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) { const ref = useRef(value); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx index 1a09868da7771..604767bcde26c 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx @@ -32,7 +32,12 @@ export const getTimelinesInStorageByIds = (storage: Storage, timelineIds: Timeli return { ...acc, - [timelineId]: timelineModel, + [timelineId]: { + ...timelineModel, + ...(timelineModel.sort != null && !Array.isArray(timelineModel.sort) + ? { sort: [timelineModel.sort] } + : {}), + }, }; }, {} as { [K in TimelineIdLiteral]: TimelineModel }); }; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts index fa0ecb349f9c8..9e34d3470d296 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts @@ -138,10 +138,7 @@ export const oneTimelineQuery = gql` templateTimelineId templateTimelineVersion savedQueryId - sort { - columnId - sortDirection - } + sort created createdBy updated diff --git a/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts index 12d3e6bfd7172..e255ac5bdda5b 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts @@ -102,10 +102,7 @@ export const persistTimelineMutation = gql` end } savedQueryId - sort { - columnId - sortDirection - } + sort created createdBy updated diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index b8dfa698a9307..479c289cdd21d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -72,7 +72,7 @@ export interface TimelineInput { filterQuery: SerializedFilterQuery | null; }; show?: boolean; - sort?: Sort; + sort?: Sort[]; showCheckboxes?: boolean; timelineType?: TimelineTypeLiteral; templateTimelineId?: string | null; @@ -216,7 +216,7 @@ export const updateRange = actionCreator<{ id: string; start: string; end: strin 'UPDATE_RANGE' ); -export const updateSort = actionCreator<{ id: string; sort: Sort }>('UPDATE_SORT'); +export const updateSort = actionCreator<{ id: string; sort: Sort[] }>('UPDATE_SORT'); export const updateAutoSaveMsg = actionCreator<{ timelineId: string | null; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index 84551de9ec628..211bba3cc47d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -52,10 +52,12 @@ export const timelineDefaults: SubsetTimelineModel & Pick { selectedEventIds: {}, show: true, showCheckboxes: false, - sort: { columnId: '@timestamp', sortDirection: Direction.desc }, + sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }], status: TimelineStatus.active, version: 'WzM4LDFd', id: '11169110-fc22-11e9-8ca9-072f15ce2685', @@ -286,10 +286,12 @@ describe('Epic Timeline', () => { }, }, savedQueryId: 'my endgame timeline query', - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], templateTimelineId: null, templateTimelineVersion: null, timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index a2bccaddb309e..5fcbcf434d3ee 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -58,10 +58,12 @@ describe('epicLocalStorage', () => { ); let props = {} as QueryTabContentComponentProps; - const sort: Sort = { - columnId: '@timestamp', - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ]; const startDate = '2018-03-23T18:49:23.132Z'; const endDate = '2018-03-24T03:33:52.253Z'; @@ -159,10 +161,12 @@ describe('epicLocalStorage', () => { store.dispatch( updateSort({ id: 'test', - sort: { - columnId: 'event.severity', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: 'event.severity', + sortDirection: Direction.desc, + }, + ], }) ); await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 1122b7a94e0e0..f9f4622c9d63c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -179,7 +179,7 @@ interface AddNewTimelineParams { filterQuery: SerializedFilterQuery | null; }; show?: boolean; - sort?: Sort; + sort?: Sort[]; showCheckboxes?: boolean; timelineById: TimelineById; timelineType: TimelineTypeLiteral; @@ -762,7 +762,7 @@ export const updateTimelineRange = ({ interface UpdateTimelineSortParams { id: string; - sort: Sort; + sort: Sort[]; timelineById: TimelineById; } diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index e4d1a6b512689..9c71fabfffac5 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -124,7 +124,7 @@ export interface TimelineModel { /** When true, shows checkboxes enabling selection. Selected events store in selectedEventIds **/ showCheckboxes: boolean; /** Specifies which column the timeline is sorted on, and the direction (ascending / descending) */ - sort: Sort; + sort: Sort[]; /** status: active | draft */ status: TimelineStatus; /** updated saved object timestamp */ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 2ca34742affef..59d5800271b8a 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -101,10 +101,12 @@ const basicTimeline: TimelineModel = { selectedEventIds: {}, show: true, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ], status: TimelineStatus.active, templateTimelineId: null, templateTimelineVersion: null, @@ -953,10 +955,12 @@ describe('Timeline', () => { beforeAll(() => { update = updateTimelineSort({ id: 'foo', - sort: { - columnId: 'some column', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: 'some column', + sortDirection: Direction.desc, + }, + ], timelineById: timelineByIdMock, }); }); @@ -964,8 +968,8 @@ describe('Timeline', () => { expect(update).not.toBe(timelineByIdMock); }); - test('should update the timeline range', () => { - expect(update.foo.sort).toEqual({ columnId: 'some column', sortDirection: Direction.desc }); + test('should update the sort attribute', () => { + expect(update.foo.sort).toEqual([{ columnId: 'some column', sortDirection: Direction.desc }]); }); }); diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index 2358e78b044ed..ca6c57f025faf 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts @@ -174,7 +174,7 @@ export const timelineSchema = gql` timelineType: TimelineType dateRange: DateRangePickerInput savedQueryId: String - sort: SortTimelineInput + sort: [SortTimelineInput!] status: TimelineStatus } @@ -238,10 +238,6 @@ export const timelineSchema = gql` ${favoriteTimeline} } - type SortTimelineResult { - ${sortTimeline} - } - type FilterMetaTimelineResult { ${filtersMetaTimeline} } @@ -277,7 +273,7 @@ export const timelineSchema = gql` pinnedEventsSaveObject: [PinnedEvent!] savedQueryId: String savedObjectId: String! - sort: SortTimelineResult + sort: ToAny status: TimelineStatus title: String templateTimelineId: String diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index bda0fed494a6f..3ea964c0ee01f 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -105,7 +105,7 @@ export interface TimelineInput { savedQueryId?: Maybe; - sort?: Maybe; + sort?: Maybe; status?: Maybe; } @@ -634,7 +634,7 @@ export interface TimelineResult { savedObjectId: string; - sort?: Maybe; + sort?: Maybe; status?: Maybe; @@ -777,12 +777,6 @@ export interface KueryFilterQueryResult { expression?: Maybe; } -export interface SortTimelineResult { - columnId?: Maybe; - - sortDirection?: Maybe; -} - export interface ResponseTimelines { timeline: (Maybe)[]; @@ -2336,7 +2330,6 @@ export namespace AgentFieldsResolvers { > = Resolver; } - export namespace CloudFieldsResolvers { export interface Resolvers { instance?: InstanceResolver, TypeParent, TContext>; @@ -2665,7 +2658,7 @@ export namespace TimelineResultResolvers { savedObjectId?: SavedObjectIdResolver; - sort?: SortResolver, TypeParent, TContext>; + sort?: SortResolver, TypeParent, TContext>; status?: StatusResolver, TypeParent, TContext>; @@ -2785,7 +2778,7 @@ export namespace TimelineResultResolvers { TContext = SiemContext > = Resolver; export type SortResolver< - R = Maybe, + R = Maybe, Parent = TimelineResult, TContext = SiemContext > = Resolver; @@ -3245,25 +3238,6 @@ export namespace KueryFilterQueryResultResolvers { > = Resolver; } -export namespace SortTimelineResultResolvers { - export interface Resolvers { - columnId?: ColumnIdResolver, TypeParent, TContext>; - - sortDirection?: SortDirectionResolver, TypeParent, TContext>; - } - - export type ColumnIdResolver< - R = Maybe, - Parent = SortTimelineResult, - TContext = SiemContext - > = Resolver; - export type SortDirectionResolver< - R = Maybe, - Parent = SortTimelineResult, - TContext = SiemContext - > = Resolver; -} - export namespace ResponseTimelinesResolvers { export interface Resolvers { timeline?: TimelineResolver<(Maybe)[], TypeParent, TContext>; @@ -6091,7 +6065,6 @@ export type IResolvers = { SerializedFilterQueryResult?: SerializedFilterQueryResultResolvers.Resolvers; SerializedKueryQueryResult?: SerializedKueryQueryResultResolvers.Resolvers; KueryFilterQueryResult?: KueryFilterQueryResultResolvers.Resolvers; - SortTimelineResult?: SortTimelineResultResolvers.Resolvers; ResponseTimelines?: ResponseTimelinesResolvers.Resolvers; Mutation?: MutationResolvers.Resolvers; ResponseNote?: ResponseNoteResolvers.Resolvers; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts index f888675b60410..271e53d4e6c9b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts @@ -60,6 +60,12 @@ export const convertSavedObjectToSavedTimeline = (savedObject: unknown): Timelin savedTimeline.attributes.timelineType, savedTimeline.attributes.status ), + sort: + savedTimeline.attributes.sort != null + ? Array.isArray(savedTimeline.attributes.sort) + ? savedTimeline.attributes.sort + : [savedTimeline.attributes.sort] + : [], }; return { savedObjectId: savedTimeline.id, diff --git a/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts b/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts index 1aba6660677cd..9fd371c6f1cca 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts @@ -7,5 +7,37 @@ export const toArray = (value: T | T[] | null): T[] => Array.isArray(value) ? value : value == null ? [] : [value]; -export const toStringArray = (value: T | T[] | null): T[] | string[] => - Array.isArray(value) ? value : value == null ? [] : [`${value}`]; +export const toStringArray = (value: T | T[] | null): string[] => { + if (Array.isArray(value)) { + return value.reduce((acc, v) => { + if (v != null) { + switch (typeof v) { + case 'number': + case 'boolean': + return [...acc, v.toString()]; + case 'object': + try { + return [...acc, JSON.stringify(value)]; + } catch { + return [...acc, 'Invalid Object']; + } + case 'string': + return [...acc, v]; + default: + return [...acc, `${v}`]; + } + } + return acc; + }, []); + } else if (value == null) { + return []; + } else if (!Array.isArray(value) && typeof value === 'object') { + try { + return [JSON.stringify(value)]; + } catch { + return ['Invalid Object']; + } + } else { + return [`${value}`]; + } +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts new file mode 100644 index 0000000000000..b62ddc00f2e30 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventHit } from '../../../../../../common/search_strategy'; +import { TIMELINE_EVENTS_FIELDS } from './constants'; +import { formatTimelineData } from './helpers'; + +describe('#formatTimelineData', () => { + it('happy path', () => { + const response: EventHit = { + _index: 'auditbeat-7.8.0-2020.11.05-000003', + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _score: 0, + _type: '', + fields: { + 'event.category': ['process'], + 'process.ppid': [3977], + 'user.name': ['jenkins'], + 'process.args': ['go', 'vet', './...'], + message: ['Process go (PID: 4313) by user jenkins STARTED'], + 'process.pid': [4313], + 'process.working_directory': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + 'process.entity_id': ['Z59cIkAAIw8ZoK0H'], + 'host.ip': ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + 'process.name': ['go'], + 'event.action': ['process_started'], + 'agent.type': ['auditbeat'], + '@timestamp': ['2020-11-17T14:48:08.922Z'], + 'event.module': ['system'], + 'event.type': ['start'], + 'host.name': ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + 'process.hash.sha1': ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + 'host.os.family': ['debian'], + 'event.kind': ['event'], + 'host.id': ['e59991e835905c65ed3e455b33e13bd6'], + 'event.dataset': ['process'], + 'process.executable': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + }, + _source: {}, + sort: ['1605624488922', 'beats-ci-immutable-ubuntu-1804-1605624279743236239'], + aggregations: {}, + }; + + expect( + formatTimelineData( + ['@timestamp', 'host.name', 'destination.ip', 'source.ip'], + TIMELINE_EVENTS_FIELDS, + response + ) + ).toEqual({ + cursor: { + tiebreaker: 'beats-ci-immutable-ubuntu-1804-1605624279743236239', + value: '1605624488922', + }, + node: { + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _index: 'auditbeat-7.8.0-2020.11.05-000003', + data: [ + { + field: '@timestamp', + value: ['2020-11-17T14:48:08.922Z'], + }, + { + field: 'host.name', + value: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + }, + ], + ecs: { + '@timestamp': ['2020-11-17T14:48:08.922Z'], + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _index: 'auditbeat-7.8.0-2020.11.05-000003', + agent: { + type: ['auditbeat'], + }, + event: { + action: ['process_started'], + category: ['process'], + dataset: ['process'], + kind: ['event'], + module: ['system'], + type: ['start'], + }, + host: { + id: ['e59991e835905c65ed3e455b33e13bd6'], + ip: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + name: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + os: { + family: ['debian'], + }, + }, + message: ['Process go (PID: 4313) by user jenkins STARTED'], + process: { + args: ['go', 'vet', './...'], + entity_id: ['Z59cIkAAIw8ZoK0H'], + executable: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + hash: { + sha1: ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + }, + name: ['go'], + pid: ['4313'], + ppid: ['3977'], + working_directory: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + }, + timestamp: '2020-11-17T14:48:08.922Z', + user: { + name: ['jenkins'], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts index 8e2bfb5426610..a9aee2175b31d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts @@ -7,6 +7,7 @@ import { get, has, merge, uniq } from 'lodash/fp'; import { EventHit, TimelineEdges } from '../../../../../../common/search_strategy'; import { toStringArray } from '../../../../helpers/to_array'; +import { formatGeoLocation, isGeoField } from '../details/helpers'; export const formatTimelineData = ( dataFields: readonly string[], @@ -18,7 +19,7 @@ export const formatTimelineData = ( flattenedFields.node._id = hit._id; flattenedFields.node._index = hit._index; flattenedFields.node.ecs._id = hit._id; - flattenedFields.node.ecs.timestamp = hit._source['@timestamp']; + flattenedFields.node.ecs.timestamp = (hit.fields['@timestamp'][0] ?? '') as string; flattenedFields.node.ecs._index = hit._index; if (hit.sort && hit.sort.length > 1) { flattenedFields.cursor.value = hit.sort[0]; @@ -40,13 +41,12 @@ const specialFields = ['_id', '_index', '_type', '_score']; const mergeTimelineFieldsWithHit = ( fieldName: string, flattenedFields: T, - hit: { _source: {} }, + hit: { fields: Record }, dataFields: readonly string[], ecsFields: readonly string[] ) => { if (fieldName != null || dataFields.includes(fieldName)) { - const esField = fieldName; - if (has(esField, hit._source) || specialFields.includes(esField)) { + if (has(fieldName, hit.fields) || specialFields.includes(fieldName)) { const objectWithProperty = { node: { ...get('node', flattenedFields), @@ -55,9 +55,11 @@ const mergeTimelineFieldsWithHit = ( ...get('node.data', flattenedFields), { field: fieldName, - value: specialFields.includes(esField) - ? toStringArray(get(esField, hit)) - : toStringArray(get(esField, hit._source)), + value: specialFields.includes(fieldName) + ? toStringArray(get(fieldName, hit)) + : isGeoField(fieldName) + ? formatGeoLocation(hit.fields[fieldName]) + : toStringArray(hit.fields[fieldName]), }, ] : get('node.data', flattenedFields), @@ -68,7 +70,7 @@ const mergeTimelineFieldsWithHit = ( ...fieldName.split('.').reduceRight( // @ts-expect-error (obj, next) => ({ [next]: obj }), - toStringArray(get(esField, hit._source)) + toStringArray(hit.fields[fieldName]) ), } : get('node.ecs', flattenedFields), diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts index 19535fa3dc8a8..de58e7cf44d64 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts @@ -9,11 +9,12 @@ import { cloneDeep, uniq } from 'lodash/fp'; import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { + EventHit, TimelineEventsQueries, TimelineEventsAllStrategyResponse, TimelineEventsAllRequestOptions, TimelineEdges, -} from '../../../../../../common/search_strategy/timeline'; +} from '../../../../../../common/search_strategy'; import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionTimelineFactory } from '../../types'; import { buildTimelineEventsAllQuery } from './query.events_all.dsl'; @@ -39,8 +40,7 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory - // @ts-expect-error - formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit) + formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit as EventHit) ); const inspect = { dsl: [inspectStringifyObject(buildTimelineEventsAllQuery(queryOptions))], diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts index a5a0c877ecdd3..034a2b3c6ea95 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts @@ -44,14 +44,11 @@ export const buildTimelineEventsAllQuery = ({ const filter = [...filterClause, ...getTimerangeFilter(timerange), { match_all: {} }]; - const getSortField = (sortField: SortField) => { - if (sortField.field) { - const field: string = sortField.field === 'timestamp' ? '@timestamp' : sortField.field; - - return [{ [field]: sortField.direction }]; - } - return []; - }; + const getSortField = (sortFields: SortField[]) => + sortFields.map((item) => { + const field: string = item.field === 'timestamp' ? '@timestamp' : item.field; + return { [field]: item.direction }; + }); const dslQuery = { allowNoIndices: true, @@ -68,7 +65,7 @@ export const buildTimelineEventsAllQuery = ({ size: querySize, track_total_hits: true, sort: getSortField(sort), - _source: fields, + fields, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts new file mode 100644 index 0000000000000..34610da7d7aa3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventHit } from '../../../../../../common/search_strategy'; +import { getDataFromHits } from './helpers'; + +describe('#getDataFromHits', () => { + it('happy path', () => { + const response: EventHit = { + _index: 'auditbeat-7.8.0-2020.11.05-000003', + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _score: 0, + _type: '', + fields: { + 'event.category': ['process'], + 'process.ppid': [3977], + 'user.name': ['jenkins'], + 'process.args': ['go', 'vet', './...'], + message: ['Process go (PID: 4313) by user jenkins STARTED'], + 'process.pid': [4313], + 'process.working_directory': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + 'process.entity_id': ['Z59cIkAAIw8ZoK0H'], + 'host.ip': ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + 'process.name': ['go'], + 'event.action': ['process_started'], + 'agent.type': ['auditbeat'], + '@timestamp': ['2020-11-17T14:48:08.922Z'], + 'event.module': ['system'], + 'event.type': ['start'], + 'host.name': ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + 'process.hash.sha1': ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + 'host.os.family': ['debian'], + 'event.kind': ['event'], + 'host.id': ['e59991e835905c65ed3e455b33e13bd6'], + 'event.dataset': ['process'], + 'process.executable': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + }, + _source: {}, + sort: ['1605624488922', 'beats-ci-immutable-ubuntu-1804-1605624279743236239'], + aggregations: {}, + }; + + expect(getDataFromHits(response.fields)).toEqual([ + { + category: 'event', + field: 'event.category', + originalValue: ['process'], + values: ['process'], + }, + { category: 'process', field: 'process.ppid', originalValue: ['3977'], values: ['3977'] }, + { category: 'user', field: 'user.name', originalValue: ['jenkins'], values: ['jenkins'] }, + { + category: 'process', + field: 'process.args', + originalValue: ['go', 'vet', './...'], + values: ['go', 'vet', './...'], + }, + { + category: 'base', + field: 'message', + originalValue: ['Process go (PID: 4313) by user jenkins STARTED'], + values: ['Process go (PID: 4313) by user jenkins STARTED'], + }, + { category: 'process', field: 'process.pid', originalValue: ['4313'], values: ['4313'] }, + { + category: 'process', + field: 'process.working_directory', + originalValue: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + values: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + }, + { + category: 'process', + field: 'process.entity_id', + originalValue: ['Z59cIkAAIw8ZoK0H'], + values: ['Z59cIkAAIw8ZoK0H'], + }, + { + category: 'host', + field: 'host.ip', + originalValue: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + values: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + }, + { category: 'process', field: 'process.name', originalValue: ['go'], values: ['go'] }, + { + category: 'event', + field: 'event.action', + originalValue: ['process_started'], + values: ['process_started'], + }, + { + category: 'agent', + field: 'agent.type', + originalValue: ['auditbeat'], + values: ['auditbeat'], + }, + { + category: 'base', + field: '@timestamp', + originalValue: ['2020-11-17T14:48:08.922Z'], + values: ['2020-11-17T14:48:08.922Z'], + }, + { category: 'event', field: 'event.module', originalValue: ['system'], values: ['system'] }, + { category: 'event', field: 'event.type', originalValue: ['start'], values: ['start'] }, + { + category: 'host', + field: 'host.name', + originalValue: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + values: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + }, + { + category: 'process', + field: 'process.hash.sha1', + originalValue: ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + values: ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + }, + { category: 'host', field: 'host.os.family', originalValue: ['debian'], values: ['debian'] }, + { category: 'event', field: 'event.kind', originalValue: ['event'], values: ['event'] }, + { + category: 'host', + field: 'host.id', + originalValue: ['e59991e835905c65ed3e455b33e13bd6'], + values: ['e59991e835905c65ed3e455b33e13bd6'], + }, + { + category: 'event', + field: 'event.dataset', + originalValue: ['process'], + values: ['process'], + }, + { + category: 'process', + field: 'process.executable', + originalValue: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + values: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts index 2dd406ffaa450..68bef2e8c656a 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get, isEmpty, isNumber, isObject, isString } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy/timeline'; +import { toStringArray } from '../../../../helpers/to_array'; export const baseCategoryFields = ['@timestamp', 'labels', 'message', 'tags']; @@ -18,39 +19,32 @@ export const getFieldCategory = (field: string): string => { return fieldCategory; }; -export const getDataFromHits = ( - sources: EventSource, - category?: string, - path?: string -): TimelineEventsDetailsItem[] => - Object.keys(sources).reduce((accumulator, source) => { - const item: EventSource = get(source, sources); - if (Array.isArray(item) || isString(item) || isNumber(item)) { - const field = path ? `${path}.${source}` : source; - const fieldCategory = getFieldCategory(field); +export const formatGeoLocation = (item: unknown[]) => { + const itemGeo = item.length > 0 ? (item[0] as { coordinates: number[] }) : null; + if (itemGeo != null && !isEmpty(itemGeo.coordinates)) { + try { + return toStringArray({ long: itemGeo.coordinates[0], lat: itemGeo.coordinates[1] }); + } catch { + return toStringArray(item); + } + } + return toStringArray(item); +}; - return [ - ...accumulator, - { - category: fieldCategory, - field, - values: Array.isArray(item) - ? item.map((value) => { - if (isObject(value)) { - return JSON.stringify(value); - } +export const isGeoField = (field: string) => + field.includes('geo.location') || field.includes('geoip.location'); - return value; - }) - : [item], - originalValue: item, - } as TimelineEventsDetailsItem, - ]; - } else if (isObject(item)) { - return [ - ...accumulator, - ...getDataFromHits(item, category || source, path ? `${path}.${source}` : source), - ]; - } - return accumulator; +export const getDataFromHits = (fields: Record): TimelineEventsDetailsItem[] => + Object.keys(fields).reduce((accumulator, field) => { + const item: unknown[] = fields[field]; + const fieldCategory = getFieldCategory(field); + return [ + ...accumulator, + { + category: fieldCategory, + field, + values: isGeoField(field) ? formatGeoLocation(item) : toStringArray(item), + originalValue: toStringArray(item), + } as TimelineEventsDetailsItem, + ]; }, []); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts index 54e138c1e9d6f..0a011d2bfe878 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr, merge } from 'lodash/fp'; +import { cloneDeep, merge } from 'lodash/fp'; import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { @@ -27,13 +27,14 @@ export const timelineEventsDetails: SecuritySolutionTimelineFactory ): Promise => { const { indexName, eventId, docValueFields = [] } = options; - const sourceData = getOr({}, 'hits.hits.0._source', response.rawResponse); - const hitsData = getOr({}, 'hits.hits.0', response.rawResponse); + const fieldsData = cloneDeep(response.rawResponse.hits.hits[0].fields ?? {}); + const hitsData = cloneDeep(response.rawResponse.hits.hits[0] ?? {}); delete hitsData._source; + delete hitsData.fields; const inspect = { dsl: [inspectStringifyObject(buildTimelineDetailsQuery(indexName, eventId, docValueFields))], }; - const data = getDataFromHits(merge(sourceData, hitsData)); + const data = getDataFromHits(merge(fieldsData, hitsData)); return { ...response, diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts index 216e8f947d261..8d70a08c214d8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts @@ -21,6 +21,7 @@ export const buildTimelineDetailsQuery = ( _id: [id], }, }, + fields: ['*'], }, size: 1, }); diff --git a/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts b/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts index a399c07e31065..07e7cad89c24a 100644 --- a/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts +++ b/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts @@ -171,7 +171,7 @@ export default function ({ getService }: FtrProviderContext) { expect(kqlMode).to.be(timelineObject.kqlMode); expect(kqlQuery).to.eql(timelineObject.kqlQuery); expect(savedObjectId).to.not.be.empty(); - expect(sort).to.eql(timelineObject.sort); + expect(sort).to.eql([timelineObject.sort]); expect(title).to.be(timelineObject.title); expect(version).to.not.be.empty(); }); diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts index d3f40188aa6d3..a04c2fef92329 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts @@ -15,250 +15,547 @@ const INDEX_NAME = 'filebeat-7.0.0-iot-2019.06'; const ID = 'QRhG1WgBqd-n62SwZYDT'; const EXPECTED_DATA = [ { - category: 'base', - field: '@timestamp', - values: ['2019-02-10T02:39:44.107Z'], - originalValue: '2019-02-10T02:39:44.107Z', + category: 'file', + field: 'file.path', + values: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + originalValue: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, - { category: '@version', field: '@version', values: ['1'], originalValue: '1' }, { - category: 'agent', - field: 'agent.ephemeral_id', - values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], - originalValue: '909cd6a1-527d-41a5-9585-a7fb5386f851', + category: 'traefik', + field: 'traefik.access.geoip.region_iso_code', + values: ['US-WA'], + originalValue: ['US-WA'], }, { - category: 'agent', - field: 'agent.hostname', + category: 'host', + field: 'host.hostname', values: ['raspberrypi'], - originalValue: 'raspberrypi', + originalValue: ['raspberrypi'], }, { - category: 'agent', - field: 'agent.id', - values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], - originalValue: '4d3ea604-27e5-4ec7-ab64-44f82285d776', + category: 'traefik', + field: 'traefik.access.geoip.location', + values: ['{"long":-122.3341,"lat":47.6103}'], + originalValue: ['[{"coordinates":[-122.3341,47.6103],"type":"Point"}]'], + }, + { + category: 'suricata', + field: 'suricata.eve.src_port', + values: ['80'], + originalValue: ['80'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.city_name', + values: ['Seattle'], + originalValue: ['Seattle'], + }, + { + category: 'service', + field: 'service.type', + values: ['suricata'], + originalValue: ['suricata'], + }, + { + category: 'http', + field: 'http.request.method', + values: ['get'], + originalValue: ['get'], + }, + { + category: 'host', + field: 'host.os.version', + values: ['9 (stretch)'], + originalValue: ['9 (stretch)'], + }, + { + category: 'source', + field: 'source.geo.region_name', + values: ['Washington'], + originalValue: ['Washington'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.protocol', + values: ['HTTP/1.1'], + originalValue: ['HTTP/1.1'], + }, + { + category: 'host', + field: 'host.os.name', + values: ['Raspbian GNU/Linux'], + originalValue: ['Raspbian GNU/Linux'], + }, + { + category: 'source', + field: 'source.ip', + values: ['54.239.219.210'], + originalValue: ['54.239.219.210'], + }, + { + category: 'host', + field: 'host.name', + values: ['raspberrypi'], + originalValue: ['raspberrypi'], + }, + { + category: 'source', + field: 'source.geo.region_iso_code', + values: ['US-WA'], + originalValue: ['US-WA'], + }, + { + category: 'http', + field: 'http.response.status_code', + values: ['206'], + originalValue: ['206'], + }, + { + category: 'event', + field: 'event.kind', + values: ['event'], + originalValue: ['event'], + }, + { + category: 'suricata', + field: 'suricata.eve.flow_id', + values: ['196625917175466'], + originalValue: ['196625917175466'], + }, + { + category: 'source', + field: 'source.geo.city_name', + values: ['Seattle'], + originalValue: ['Seattle'], + }, + { + category: 'suricata', + field: 'suricata.eve.proto', + values: ['tcp'], + originalValue: ['tcp'], + }, + { + category: 'flow', + field: 'flow.locality', + values: ['public'], + originalValue: ['public'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.country_iso_code', + values: ['US'], + originalValue: ['US'], + }, + { + category: 'fileset', + field: 'fileset.name', + values: ['eve'], + originalValue: ['eve'], + }, + { + category: 'input', + field: 'input.type', + values: ['log'], + originalValue: ['log'], + }, + { + category: 'log', + field: 'log.offset', + values: ['1856288115'], + originalValue: ['1856288115'], }, - { category: 'agent', field: 'agent.type', values: ['filebeat'], originalValue: 'filebeat' }, - { category: 'agent', field: 'agent.version', values: ['7.0.0'], originalValue: '7.0.0' }, { category: 'destination', field: 'destination.domain', values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], }, { - category: 'destination', - field: 'destination.ip', - values: ['10.100.7.196'], - originalValue: '10.100.7.196', + category: 'agent', + field: 'agent.hostname', + values: ['raspberrypi'], + originalValue: ['raspberrypi'], }, - { category: 'destination', field: 'destination.port', values: [40684], originalValue: 40684 }, - { category: 'ecs', field: 'ecs.version', values: ['1.0.0-beta2'], originalValue: '1.0.0-beta2' }, { - category: 'event', - field: 'event.dataset', - values: ['suricata.eve'], - originalValue: 'suricata.eve', + category: 'suricata', + field: 'suricata.eve.http.hostname', + values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], }, { - category: 'event', - field: 'event.end', - values: ['2019-02-10T02:39:44.107Z'], - originalValue: '2019-02-10T02:39:44.107Z', + category: 'suricata', + field: 'suricata.eve.in_iface', + values: ['eth0'], + originalValue: ['eth0'], }, - { category: 'event', field: 'event.kind', values: ['event'], originalValue: 'event' }, - { category: 'event', field: 'event.module', values: ['suricata'], originalValue: 'suricata' }, - { category: 'event', field: 'event.type', values: ['fileinfo'], originalValue: 'fileinfo' }, { - category: 'file', - field: 'file.path', + category: 'base', + field: 'tags', + values: ['suricata'], + originalValue: ['suricata'], + }, + { + category: 'host', + field: 'host.architecture', + values: ['armv7l'], + originalValue: ['armv7l'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.status', + values: ['206'], + originalValue: ['206'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.url', values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, - { category: 'file', field: 'file.size', values: [48277], originalValue: 48277 }, - { category: 'fileset', field: 'fileset.name', values: ['eve'], originalValue: 'eve' }, - { category: 'flow', field: 'flow.locality', values: ['public'], originalValue: 'public' }, - { category: 'host', field: 'host.architecture', values: ['armv7l'], originalValue: 'armv7l' }, { - category: 'host', - field: 'host.hostname', - values: ['raspberrypi'], - originalValue: 'raspberrypi', + category: 'url', + field: 'url.path', + values: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + originalValue: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, { - category: 'host', - field: 'host.id', - values: ['b19a781f683541a7a25ee345133aa399'], - originalValue: 'b19a781f683541a7a25ee345133aa399', + category: 'source', + field: 'source.port', + values: ['80'], + originalValue: ['80'], }, - { category: 'host', field: 'host.name', values: ['raspberrypi'], originalValue: 'raspberrypi' }, - { category: 'host', field: 'host.os.codename', values: ['stretch'], originalValue: 'stretch' }, - { category: 'host', field: 'host.os.family', values: [''], originalValue: '' }, { - category: 'host', - field: 'host.os.kernel', - values: ['4.14.50-v7+'], - originalValue: '4.14.50-v7+', + category: 'agent', + field: 'agent.id', + values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], + originalValue: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], }, { category: 'host', - field: 'host.os.name', - values: ['Raspbian GNU/Linux'], - originalValue: 'Raspbian GNU/Linux', + field: 'host.containerized', + values: ['false'], + originalValue: ['false'], + }, + { + category: 'ecs', + field: 'ecs.version', + values: ['1.0.0-beta2'], + originalValue: ['1.0.0-beta2'], + }, + { + category: 'agent', + field: 'agent.version', + values: ['7.0.0'], + originalValue: ['7.0.0'], + }, + { + category: 'suricata', + field: 'suricata.eve.fileinfo.stored', + values: ['false'], + originalValue: ['false'], }, - { category: 'host', field: 'host.os.platform', values: ['raspbian'], originalValue: 'raspbian' }, { category: 'host', - field: 'host.os.version', - values: ['9 (stretch)'], - originalValue: '9 (stretch)', + field: 'host.os.family', + values: [''], + originalValue: [''], }, - { category: 'http', field: 'http.request.method', values: ['get'], originalValue: 'get' }, - { category: 'http', field: 'http.response.body.bytes', values: [48277], originalValue: 48277 }, - { category: 'http', field: 'http.response.status_code', values: [206], originalValue: 206 }, - { category: 'input', field: 'input.type', values: ['log'], originalValue: 'log' }, { category: 'base', field: 'labels.pipeline', values: ['filebeat-7.0.0-suricata-eve-pipeline'], - originalValue: 'filebeat-7.0.0-suricata-eve-pipeline', + originalValue: ['filebeat-7.0.0-suricata-eve-pipeline'], }, { - category: 'log', - field: 'log.file.path', - values: ['/var/log/suricata/eve.json'], - originalValue: '/var/log/suricata/eve.json', + category: 'suricata', + field: 'suricata.eve.src_ip', + values: ['54.239.219.210'], + originalValue: ['54.239.219.210'], + }, + { + category: 'suricata', + field: 'suricata.eve.fileinfo.state', + values: ['CLOSED'], + originalValue: ['CLOSED'], + }, + { + category: 'destination', + field: 'destination.port', + values: ['40684'], + originalValue: ['40684'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.region_name', + values: ['Washington'], + originalValue: ['Washington'], }, - { category: 'log', field: 'log.offset', values: [1856288115], originalValue: 1856288115 }, - { category: 'network', field: 'network.name', values: ['iot'], originalValue: 'iot' }, - { category: 'network', field: 'network.protocol', values: ['http'], originalValue: 'http' }, - { category: 'network', field: 'network.transport', values: ['tcp'], originalValue: 'tcp' }, - { category: 'service', field: 'service.type', values: ['suricata'], originalValue: 'suricata' }, - { category: 'source', field: 'source.as.num', values: [16509], originalValue: 16509 }, { category: 'source', - field: 'source.as.org', - values: ['Amazon.com, Inc.'], - originalValue: 'Amazon.com, Inc.', + field: 'source.as.num', + values: ['16509'], + originalValue: ['16509'], + }, + { + category: 'event', + field: 'event.end', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: ['2019-02-10T02:39:44.107Z'], + }, + { + category: 'source', + field: 'source.geo.location', + values: ['{"long":-122.3341,"lat":47.6103}'], + originalValue: ['[{"coordinates":[-122.3341,47.6103],"type":"Point"}]'], }, { category: 'source', field: 'source.domain', values: ['server-54-239-219-210.jfk51.r.cloudfront.net'], - originalValue: 'server-54-239-219-210.jfk51.r.cloudfront.net', + originalValue: ['server-54-239-219-210.jfk51.r.cloudfront.net'], }, { - category: 'source', - field: 'source.geo.city_name', - values: ['Seattle'], - originalValue: 'Seattle', + category: 'suricata', + field: 'suricata.eve.fileinfo.size', + values: ['48277'], + originalValue: ['48277'], }, { - category: 'source', - field: 'source.geo.continent_name', - values: ['North America'], - originalValue: 'North America', + category: 'suricata', + field: 'suricata.eve.app_proto', + values: ['http'], + originalValue: ['http'], }, - { category: 'source', field: 'source.geo.country_iso_code', values: ['US'], originalValue: 'US' }, { - category: 'source', - field: 'source.geo.location.lat', - values: [47.6103], - originalValue: 47.6103, + category: 'agent', + field: 'agent.type', + values: ['filebeat'], + originalValue: ['filebeat'], }, { - category: 'source', - field: 'source.geo.location.lon', - values: [-122.3341], - originalValue: -122.3341, + category: 'suricata', + field: 'suricata.eve.fileinfo.tx_id', + values: ['301'], + originalValue: ['301'], }, { - category: 'source', - field: 'source.geo.region_iso_code', - values: ['US-WA'], - originalValue: 'US-WA', + category: 'event', + field: 'event.module', + values: ['suricata'], + originalValue: ['suricata'], + }, + { + category: 'network', + field: 'network.protocol', + values: ['http'], + originalValue: ['http'], + }, + { + category: 'host', + field: 'host.os.kernel', + values: ['4.14.50-v7+'], + originalValue: ['4.14.50-v7+'], }, { category: 'source', - field: 'source.geo.region_name', - values: ['Washington'], - originalValue: 'Washington', + field: 'source.geo.country_iso_code', + values: ['US'], + originalValue: ['US'], + }, + { + category: '@version', + field: '@version', + values: ['1'], + originalValue: ['1'], + }, + { + category: 'host', + field: 'host.id', + values: ['b19a781f683541a7a25ee345133aa399'], + originalValue: ['b19a781f683541a7a25ee345133aa399'], }, { category: 'source', - field: 'source.ip', - values: ['54.239.219.210'], - originalValue: '54.239.219.210', + field: 'source.as.org', + values: ['Amazon.com, Inc.'], + originalValue: ['Amazon.com, Inc.'], }, - { category: 'source', field: 'source.port', values: [80], originalValue: 80 }, { category: 'suricata', - field: 'suricata.eve.fileinfo.state', - values: ['CLOSED'], - originalValue: 'CLOSED', + field: 'suricata.eve.timestamp', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: ['2019-02-10T02:39:44.107Z'], }, - { category: 'suricata', field: 'suricata.eve.fileinfo.tx_id', values: [301], originalValue: 301 }, { - category: 'suricata', - field: 'suricata.eve.flow_id', - values: [196625917175466], - originalValue: 196625917175466, + category: 'host', + field: 'host.os.codename', + values: ['stretch'], + originalValue: ['stretch'], + }, + { + category: 'source', + field: 'source.geo.continent_name', + values: ['North America'], + originalValue: ['North America'], + }, + { + category: 'network', + field: 'network.name', + values: ['iot'], + originalValue: ['iot'], }, { category: 'suricata', - field: 'suricata.eve.http.http_content_type', - values: ['video/mp4'], - originalValue: 'video/mp4', + field: 'suricata.eve.http.http_method', + values: ['get'], + originalValue: ['get'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.continent_name', + values: ['North America'], + originalValue: ['North America'], + }, + { + category: 'file', + field: 'file.size', + values: ['48277'], + originalValue: ['48277'], + }, + { + category: 'destination', + field: 'destination.ip', + values: ['10.100.7.196'], + originalValue: ['10.100.7.196'], }, { category: 'suricata', - field: 'suricata.eve.http.protocol', - values: ['HTTP/1.1'], - originalValue: 'HTTP/1.1', + field: 'suricata.eve.http.length', + values: ['48277'], + originalValue: ['48277'], }, - { category: 'suricata', field: 'suricata.eve.in_iface', values: ['eth0'], originalValue: 'eth0' }, - { category: 'base', field: 'tags', values: ['suricata'], originalValue: ['suricata'] }, { - category: 'url', - field: 'url.domain', - values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', + category: 'http', + field: 'http.response.body.bytes', + values: ['48277'], + originalValue: ['48277'], }, { - category: 'url', - field: 'url.original', + category: 'suricata', + field: 'suricata.eve.fileinfo.filename', values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + }, + { + category: 'suricata', + field: 'suricata.eve.dest_ip', + values: ['10.100.7.196'], + originalValue: ['10.100.7.196'], + }, + { + category: 'network', + field: 'network.transport', + values: ['tcp'], + originalValue: ['tcp'], }, { category: 'url', - field: 'url.path', + field: 'url.original', values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + }, + { + category: 'base', + field: '@timestamp', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: ['2019-02-10T02:39:44.107Z'], + }, + { + category: 'host', + field: 'host.os.platform', + values: ['raspbian'], + originalValue: ['raspbian'], + }, + { + category: 'suricata', + field: 'suricata.eve.dest_port', + values: ['40684'], + originalValue: ['40684'], + }, + { + category: 'event', + field: 'event.type', + values: ['fileinfo'], + originalValue: ['fileinfo'], + }, + { + category: 'log', + field: 'log.file.path', + values: ['/var/log/suricata/eve.json'], + originalValue: ['/var/log/suricata/eve.json'], + }, + { + category: 'url', + field: 'url.domain', + values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + }, + { + category: 'agent', + field: 'agent.ephemeral_id', + values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], + originalValue: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.http_content_type', + values: ['video/mp4'], + originalValue: ['video/mp4'], + }, + { + category: 'event', + field: 'event.dataset', + values: ['suricata.eve'], + originalValue: ['suricata.eve'], }, { category: '_index', field: '_index', values: ['filebeat-7.0.0-iot-2019.06'], - originalValue: 'filebeat-7.0.0-iot-2019.06', + originalValue: ['filebeat-7.0.0-iot-2019.06'], }, { category: '_id', field: '_id', values: ['QRhG1WgBqd-n62SwZYDT'], - originalValue: 'QRhG1WgBqd-n62SwZYDT', + originalValue: ['QRhG1WgBqd-n62SwZYDT'], + }, + { + category: '_score', + field: '_score', + values: ['1'], + originalValue: ['1'], }, - { category: '_score', field: '_score', values: [1], originalValue: 1 }, ]; export default function ({ getService }: FtrProviderContext) {