From 1ebf7feb4061af18216438184142c141a9e066fb Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Wed, 12 Jan 2022 13:45:04 -0500 Subject: [PATCH 01/29] [APM] Sets useLatestPackageVersion: false as a workaround for bug (#122743) (#122744) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/apm/public/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index d62cca4e07d450..77c52e1afeec35 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -370,7 +370,7 @@ export class ApmPlugin implements Plugin { fleet.registerExtension({ package: 'apm', view: 'package-policy-edit', - useLatestPackageVersion: true, + useLatestPackageVersion: false, Component: getLazyAPMPolicyEditExtension(), }); From 015f7242ff314a7c176a1a6d273879f7d76400d7 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 12 Jan 2022 11:48:05 -0700 Subject: [PATCH 02/29] unskip reporting mgmt test (#122471) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../reporting_management/report_listing.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/x-pack/test/functional/apps/reporting_management/report_listing.ts b/x-pack/test/functional/apps/reporting_management/report_listing.ts index 9cb00bb78d07ee..4e5aaaeba538fe 100644 --- a/x-pack/test/functional/apps/reporting_management/report_listing.ts +++ b/x-pack/test/functional/apps/reporting_management/report_listing.ts @@ -18,8 +18,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const esArchiver = getService('esArchiver'); - // FLAKY: https://github.com/elastic/kibana/issues/75044 - describe.skip('Listing of Reports', function () { + describe('Listing of Reports', function () { before(async () => { await security.testUser.setRoles([ 'kibana_admin', // to access stack management @@ -47,8 +46,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('Confirm single report deletion works', async () => { log.debug('Checking for reports.'); await retry.try(async () => { - await testSubjects.click('checkboxSelectRow-krb7arhe164k0763b50bjm29'); + await testSubjects.click('checkboxSelectRow-krazcyw4156m0763b503j7f9'); }); + const deleteButton = await testSubjects.find('deleteReportButton'); await retry.waitFor('delete button to become enabled', async () => { return await deleteButton.isEnabled(); @@ -57,7 +57,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.exists('confirmModalBodyText'); await testSubjects.click('confirmModalConfirmButton'); await retry.try(async () => { - await testSubjects.waitForDeleted('checkboxSelectRow-krb7arhe164k0763b50bjm29'); + await testSubjects.waitForDeleted('checkboxSelectRow-krazcyw4156m0763b503j7f9'); }); }); @@ -66,13 +66,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const previousButton = await testSubjects.find('pagination-button-previous'); expect(await previousButton.getAttribute('disabled')).to.be('true'); - await testSubjects.find('checkboxSelectRow-krb7arhe164k0763b50bjm29'); // find first row of page 1 + await testSubjects.find('checkboxSelectRow-krazcyw4156m0763b503j7f9'); // find first row of page 1 await testSubjects.click('pagination-button-1'); // click page 2 - await testSubjects.find('checkboxSelectRow-kraz0qle154g0763b569zz83'); // wait for first row of page 2 - - await testSubjects.click('pagination-button-2'); // click page 3 - await testSubjects.find('checkboxSelectRow-k9a9p1840gpe1457b1ghfxw5'); // wait for first row of page 3 + await testSubjects.find('checkboxSelectRow-k9a9xj3i0gpe1457b16qaduc'); // wait for first row of page 2 // previous CAN be clicked expect(await previousButton.getAttribute('disabled')).to.be(null); @@ -82,12 +79,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const list = await pageObjects.reporting.getManagementList(); expectSnapshot(list).toMatchInline(` Array [ - Object { - "actions": "", - "createdAt": "2021-07-19 @ 10:29 PM", - "report": "Automated report", - "status": "Done, warnings detected", - }, Object { "actions": "", "createdAt": "2021-07-19 @ 06:47 PM", @@ -142,6 +133,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { "report": "[Flights] Global Flight Dashboard", "status": "Done", }, + Object { + "actions": "", + "createdAt": "2021-07-19 @ 02:41 PM", + "report": "[Flights] Global Flight Dashboard", + "status": "Failed", + }, ] `); }); From 01de1442188a0e1dc7a128594a5de792f04fea9a Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 12 Jan 2022 14:05:41 -0500 Subject: [PATCH 03/29] [Cases] Adding abstract classes and refactoring (#122458) * Adding abstract classes and refactoring * Addressing feedback --- .../client/metrics/actions/actions.test.ts | 14 ++-- .../server/client/metrics/actions/actions.ts | 45 ++++-------- .../client/metrics/aggregation_handler.ts | 31 ++++++++ .../server/client/metrics/alerts/count.ts | 29 ++++---- .../client/metrics/alerts/details.test.ts | 72 ++++++++++--------- .../server/client/metrics/alerts/details.ts | 51 +++++-------- .../server/client/metrics/base_handler.ts | 22 ++++++ .../cases/server/client/metrics/connectors.ts | 9 +-- .../client/metrics/get_case_metrics.test.ts | 12 ++-- .../server/client/metrics/get_case_metrics.ts | 10 +-- .../cases/server/client/metrics/lifespan.ts | 29 ++++---- .../cases/server/client/metrics/types.ts | 8 +++ 12 files changed, 183 insertions(+), 149 deletions(-) create mode 100644 x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts create mode 100644 x-pack/plugins/cases/server/client/metrics/base_handler.ts diff --git a/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts b/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts index 44a582deed397f..f8336424be17d4 100644 --- a/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts @@ -25,6 +25,8 @@ const clientArgs = { authorization: { getAuthorizationFilter }, } as unknown as CasesClientArgs; +const constructorOptions = { caseId: 'test-id', casesClient: clientMock, clientArgs }; + describe('Actions', () => { beforeAll(() => { getAuthorizationFilter.mockResolvedValue({}); @@ -37,14 +39,14 @@ describe('Actions', () => { it('returns empty values when no features set up', async () => { attachmentService.executeCaseActionsAggregations.mockResolvedValue(undefined); - const handler = new Actions('', clientMock, clientArgs); + const handler = new Actions(constructorOptions); expect(await handler.compute()).toEqual({}); }); it('returns zero values when aggregation returns undefined', async () => { attachmentService.executeCaseActionsAggregations.mockResolvedValue(undefined); - const handler = new Actions('', clientMock, clientArgs); + const handler = new Actions(constructorOptions); handler.setupFeature('actions.isolateHost'); expect(await handler.compute()).toEqual({ @@ -60,7 +62,7 @@ describe('Actions', () => { it('returns zero values when aggregation returns empty object', async () => { attachmentService.executeCaseActionsAggregations.mockResolvedValue({}); - const handler = new Actions('', clientMock, clientArgs); + const handler = new Actions(constructorOptions); handler.setupFeature('actions.isolateHost'); expect(await handler.compute()).toEqual({ @@ -78,7 +80,7 @@ describe('Actions', () => { actions: { buckets: [] }, }); - const handler = new Actions('', clientMock, clientArgs); + const handler = new Actions(constructorOptions); handler.setupFeature('actions.isolateHost'); expect(await handler.compute()).toEqual({ @@ -96,7 +98,7 @@ describe('Actions', () => { actions: { buckets: [{ key: 'otherAction', doc_count: 10 }] }, }); - const handler = new Actions('', clientMock, clientArgs); + const handler = new Actions(constructorOptions); handler.setupFeature('actions.isolateHost'); expect(await handler.compute()).toEqual({ @@ -117,7 +119,7 @@ describe('Actions', () => { }, }); - const handler = new Actions('', clientMock, clientArgs); + const handler = new Actions(constructorOptions); handler.setupFeature('actions.isolateHost'); expect(await handler.compute()).toEqual({ diff --git a/x-pack/plugins/cases/server/client/metrics/actions/actions.ts b/x-pack/plugins/cases/server/client/metrics/actions/actions.ts index afbbd024f7b4ab..d104ae212b83ec 100644 --- a/x-pack/plugins/cases/server/client/metrics/actions/actions.ts +++ b/x-pack/plugins/cases/server/client/metrics/actions/actions.ts @@ -9,42 +9,27 @@ import { merge } from 'lodash'; import { CaseMetricsResponse } from '../../../../common/api'; import { Operations } from '../../../authorization'; import { createCaseError } from '../../../common/error'; -import { CasesClient } from '../../client'; -import { CasesClientArgs } from '../../types'; -import { AggregationBuilder, MetricsHandler } from '../types'; +import { AggregationHandler } from '../aggregation_handler'; +import { AggregationBuilder, BaseHandlerCommonOptions } from '../types'; import { IsolateHostActions } from './aggregations/isolate_host'; -export class Actions implements MetricsHandler { - private aggregators: AggregationBuilder[] = []; - private readonly featureAggregations = new Map([ - ['actions.isolateHost', new IsolateHostActions()], - ]); - - constructor( - private readonly caseId: string, - private readonly casesClient: CasesClient, - private readonly clientArgs: CasesClientArgs - ) {} - - public getFeatures(): Set { - return new Set(this.featureAggregations.keys()); - } - - public setupFeature(feature: string) { - const aggregation = this.featureAggregations.get(feature); - if (aggregation) { - this.aggregators.push(aggregation); - } +export class Actions extends AggregationHandler { + constructor(options: BaseHandlerCommonOptions) { + super( + options, + new Map([['actions.isolateHost', new IsolateHostActions()]]) + ); } public async compute(): Promise { const { unsecuredSavedObjectsClient, authorization, attachmentService, logger } = - this.clientArgs; + this.options.clientArgs; + const { caseId, casesClient } = this.options; try { // This will perform an authorization check to ensure the user has access to the parent case - const theCase = await this.casesClient.cases.get({ - id: this.caseId, + const theCase = await casesClient.cases.get({ + id: caseId, includeComments: false, includeSubCaseComments: false, }); @@ -53,7 +38,7 @@ export class Actions implements MetricsHandler { Operations.getAttachmentMetrics ); - const aggregations = this.aggregators.reduce((aggs, aggregator) => { + const aggregations = this.aggregationBuilders.reduce((aggs, aggregator) => { return { ...aggs, ...aggregator.build() }; }, {}); @@ -64,13 +49,13 @@ export class Actions implements MetricsHandler { aggregations, }); - return this.aggregators.reduce( + return this.aggregationBuilders.reduce( (acc, aggregator) => merge(acc, aggregator.formatResponse(response)), {} ); } catch (error) { throw createCaseError({ - message: `Failed to compute actions attached case id: ${this.caseId}: ${error}`, + message: `Failed to compute actions attached case id: ${caseId}: ${error}`, error, logger, }); diff --git a/x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts b/x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts new file mode 100644 index 00000000000000..382faa354db59d --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BaseHandler } from './base_handler'; +import { AggregationBuilder, BaseHandlerCommonOptions } from './types'; + +export abstract class AggregationHandler extends BaseHandler { + protected aggregationBuilders: AggregationBuilder[] = []; + + constructor( + options: BaseHandlerCommonOptions, + private readonly aggregations: Map + ) { + super(options); + } + + getFeatures(): Set { + return new Set(this.aggregations.keys()); + } + + public setupFeature(feature: string) { + const aggregation = this.aggregations.get(feature); + if (aggregation) { + this.aggregationBuilders.push(aggregation); + } + } +} diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/count.ts b/x-pack/plugins/cases/server/client/metrics/alerts/count.ts index 8113f305ad4ba7..8e04b5fc42c858 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/count.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/count.ts @@ -8,29 +8,24 @@ import { CaseMetricsResponse } from '../../../../common/api'; import { Operations } from '../../../authorization'; import { createCaseError } from '../../../common/error'; -import { CasesClient } from '../../client'; -import { CasesClientArgs } from '../../types'; -import { MetricsHandler } from '../types'; - -export class AlertsCount implements MetricsHandler { - constructor( - private readonly caseId: string, - private readonly casesClient: CasesClient, - private readonly clientArgs: CasesClientArgs - ) {} - - public getFeatures(): Set { - return new Set(['alerts.count']); +import { BaseHandler } from '../base_handler'; +import { BaseHandlerCommonOptions } from '../types'; + +export class AlertsCount extends BaseHandler { + constructor(options: BaseHandlerCommonOptions) { + super(options, ['alerts.count']); } public async compute(): Promise { const { unsecuredSavedObjectsClient, authorization, attachmentService, logger } = - this.clientArgs; + this.options.clientArgs; + + const { caseId, casesClient } = this.options; try { // This will perform an authorization check to ensure the user has access to the parent case - const theCase = await this.casesClient.cases.get({ - id: this.caseId, + const theCase = await casesClient.cases.get({ + id: caseId, includeComments: false, includeSubCaseComments: false, }); @@ -52,7 +47,7 @@ export class AlertsCount implements MetricsHandler { }; } catch (error) { throw createCaseError({ - message: `Failed to count alerts attached case id: ${this.caseId}: ${error}`, + message: `Failed to count alerts attached case id: ${caseId}: ${error}`, error, logger, }); diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts b/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts index 0dfcc04c765da0..529590bfbc7dcb 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts @@ -5,35 +5,53 @@ * 2.0. */ -import { createCasesClientMock } from '../../mocks'; +import { CasesClientMock, createCasesClientMock } from '../../mocks'; import { CasesClientArgs } from '../../types'; import { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; import { AlertDetails } from './details'; import { mockAlertsService } from '../test_utils/alerts'; +import { BaseHandlerCommonOptions } from '../types'; describe('AlertDetails', () => { + let client: CasesClientMock; + let mockServices: ReturnType['mockServices']; + let clientArgs: ReturnType['clientArgs']; + let constructorOptions: BaseHandlerCommonOptions; + beforeEach(() => { + client = createMockClient(); + ({ mockServices, clientArgs } = createMockClientArgs()); + constructorOptions = { caseId: '', casesClient: client, clientArgs }; + }); + + afterEach(() => { jest.clearAllMocks(); }); it('returns empty alert details metrics when there are no alerts', async () => { - const client = createCasesClientMock(); client.attachments.getAllAlertsAttachToCase.mockImplementation(async () => { return []; }); - const handler = new AlertDetails('', client, {} as CasesClientArgs); + const handler = new AlertDetails({ + caseId: '', + casesClient: client, + clientArgs: {} as CasesClientArgs, + }); expect(await handler.compute()).toEqual({}); }); it('returns the default zero values when there are no alerts but features are requested', async () => { - const client = createCasesClientMock(); client.attachments.getAllAlertsAttachToCase.mockImplementation(async () => { return []; }); - const handler = new AlertDetails('', client, {} as CasesClientArgs); + const handler = new AlertDetails({ + caseId: '', + casesClient: client, + clientArgs: {} as CasesClientArgs, + }); handler.setupFeature('alerts.hosts'); expect(await handler.compute()).toEqual({ @@ -47,11 +65,9 @@ describe('AlertDetails', () => { }); it('returns the default zero values for hosts when the count aggregation returns undefined', async () => { - const client = createMockClient(); - const { mockServices, clientArgs } = createMockClientArgs(); mockServices.alertsService.executeAggregations.mockImplementation(async () => ({})); - const handler = new AlertDetails('', client, clientArgs); + const handler = new AlertDetails(constructorOptions); handler.setupFeature('alerts.hosts'); expect(await handler.compute()).toEqual({ @@ -65,11 +81,9 @@ describe('AlertDetails', () => { }); it('returns the default zero values for users when the count aggregation returns undefined', async () => { - const client = createMockClient(); - const { mockServices, clientArgs } = createMockClientArgs(); mockServices.alertsService.executeAggregations.mockImplementation(async () => ({})); - const handler = new AlertDetails('', client, clientArgs); + const handler = new AlertDetails(constructorOptions); handler.setupFeature('alerts.users'); expect(await handler.compute()).toEqual({ @@ -83,11 +97,9 @@ describe('AlertDetails', () => { }); it('returns the default zero values for hosts when the top hits aggregation returns undefined', async () => { - const client = createMockClient(); - const { mockServices, clientArgs } = createMockClientArgs(); mockServices.alertsService.executeAggregations.mockImplementation(async () => ({})); - const handler = new AlertDetails('', client, clientArgs); + const handler = new AlertDetails(constructorOptions); handler.setupFeature('alerts.hosts'); expect(await handler.compute()).toEqual({ @@ -101,11 +113,9 @@ describe('AlertDetails', () => { }); it('returns the default zero values for users when the top hits aggregation returns undefined', async () => { - const client = createMockClient(); - const { mockServices, clientArgs } = createMockClientArgs(); mockServices.alertsService.executeAggregations.mockImplementation(async () => ({})); - const handler = new AlertDetails('', client, clientArgs); + const handler = new AlertDetails(constructorOptions); handler.setupFeature('alerts.users'); expect(await handler.compute()).toEqual({ @@ -119,30 +129,34 @@ describe('AlertDetails', () => { }); it('returns empty alert details metrics when no features were setup', async () => { - const client = createCasesClientMock(); client.attachments.getAllAlertsAttachToCase.mockImplementation(async () => { return [{ id: '1', index: '2', attached_at: '3' }]; }); - const handler = new AlertDetails('', client, {} as CasesClientArgs); + const handler = new AlertDetails({ + caseId: '', + casesClient: client, + clientArgs: {} as CasesClientArgs, + }); expect(await handler.compute()).toEqual({}); }); it('returns empty alert details metrics when no features were setup when called twice', async () => { - const client = createCasesClientMock(); client.attachments.getAllAlertsAttachToCase.mockImplementation(async () => { return [{ id: '1', index: '2', attached_at: '3' }]; }); - const handler = new AlertDetails('', client, {} as CasesClientArgs); + const handler = new AlertDetails({ + caseId: '', + casesClient: client, + clientArgs: {} as CasesClientArgs, + }); expect(await handler.compute()).toEqual({}); expect(await handler.compute()).toEqual({}); }); it('returns host details when the host feature is setup', async () => { - const client = createMockClient(); - const { clientArgs } = createMockClientArgs(); - const handler = new AlertDetails('', client, clientArgs); + const handler = new AlertDetails(constructorOptions); handler.setupFeature('alerts.hosts'); @@ -157,10 +171,7 @@ describe('AlertDetails', () => { }); it('returns user details when the user feature is setup', async () => { - const client = createMockClient(); - const { clientArgs } = createMockClientArgs(); - - const handler = new AlertDetails('', client, clientArgs); + const handler = new AlertDetails(constructorOptions); handler.setupFeature('alerts.users'); @@ -175,10 +186,7 @@ describe('AlertDetails', () => { }); it('returns user and host details when the user and host features are setup', async () => { - const client = createMockClient(); - const { clientArgs } = createMockClientArgs(); - - const handler = new AlertDetails('', client, clientArgs); + const handler = new AlertDetails(constructorOptions); handler.setupFeature('alerts.users'); handler.setupFeature('alerts.hosts'); diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/details.ts b/x-pack/plugins/cases/server/client/metrics/alerts/details.ts index 8155fe60961c05..eec21d23c4639e 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/details.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/details.ts @@ -10,56 +10,43 @@ import { merge } from 'lodash'; import { CaseMetricsResponse } from '../../../../common/api'; import { createCaseError } from '../../../common/error'; -import { CasesClient } from '../../client'; -import { CasesClientArgs } from '../../types'; -import { MetricsHandler, AggregationBuilder, AggregationResponse } from '../types'; +import { AggregationHandler } from '../aggregation_handler'; +import { AggregationBuilder, AggregationResponse, BaseHandlerCommonOptions } from '../types'; import { AlertHosts, AlertUsers } from './aggregations'; -export class AlertDetails implements MetricsHandler { - private aggregationsToBuild: AggregationBuilder[] = []; - private readonly aggregations = new Map([ - ['alerts.hosts', new AlertHosts()], - ['alerts.users', new AlertUsers()], - ]); - - constructor( - private readonly caseId: string, - private readonly casesClient: CasesClient, - private readonly clientArgs: CasesClientArgs - ) {} - - public getFeatures(): Set { - return new Set(this.aggregations.keys()); - } - - public setupFeature(feature: string) { - const aggregation = this.aggregations.get(feature); - if (aggregation) { - this.aggregationsToBuild.push(aggregation); - } +export class AlertDetails extends AggregationHandler { + constructor(options: BaseHandlerCommonOptions) { + super( + options, + new Map([ + ['alerts.hosts', new AlertHosts()], + ['alerts.users', new AlertUsers()], + ]) + ); } public async compute(): Promise { - const { alertsService, logger } = this.clientArgs; + const { alertsService, logger } = this.options.clientArgs; + const { caseId, casesClient } = this.options; try { - const alerts = await this.casesClient.attachments.getAllAlertsAttachToCase({ - caseId: this.caseId, + const alerts = await casesClient.attachments.getAllAlertsAttachToCase({ + caseId, }); - if (alerts.length <= 0 || this.aggregationsToBuild.length <= 0) { + if (alerts.length <= 0 || this.aggregationBuilders.length <= 0) { return this.formatResponse(); } const aggregationsResponse = await alertsService.executeAggregations({ - aggregationBuilders: this.aggregationsToBuild, + aggregationBuilders: this.aggregationBuilders, alerts, }); return this.formatResponse(aggregationsResponse); } catch (error) { throw createCaseError({ - message: `Failed to retrieve alerts details attached case id: ${this.caseId}: ${error}`, + message: `Failed to retrieve alerts details attached case id: ${caseId}: ${error}`, error, logger, }); @@ -67,7 +54,7 @@ export class AlertDetails implements MetricsHandler { } private formatResponse(aggregationsResponse?: AggregationResponse): CaseMetricsResponse { - return this.aggregationsToBuild.reduce( + return this.aggregationBuilders.reduce( (acc, feature) => merge(acc, feature.formatResponse(aggregationsResponse)), {} ); diff --git a/x-pack/plugins/cases/server/client/metrics/base_handler.ts b/x-pack/plugins/cases/server/client/metrics/base_handler.ts new file mode 100644 index 00000000000000..bf76be05f58b37 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/base_handler.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseMetricsResponse } from '../../../common/api'; +import { BaseHandlerCommonOptions, MetricsHandler } from './types'; + +export abstract class BaseHandler implements MetricsHandler { + constructor( + protected readonly options: BaseHandlerCommonOptions, + private readonly features?: string[] + ) {} + + getFeatures(): Set { + return new Set(this.features); + } + + abstract compute(): Promise; +} diff --git a/x-pack/plugins/cases/server/client/metrics/connectors.ts b/x-pack/plugins/cases/server/client/metrics/connectors.ts index 83e1270baf8467..137bcdd61cdec9 100644 --- a/x-pack/plugins/cases/server/client/metrics/connectors.ts +++ b/x-pack/plugins/cases/server/client/metrics/connectors.ts @@ -6,11 +6,12 @@ */ import { CaseMetricsResponse } from '../../../common/api'; -import { MetricsHandler } from './types'; +import { BaseHandler } from './base_handler'; +import { BaseHandlerCommonOptions } from './types'; -export class Connectors implements MetricsHandler { - public getFeatures(): Set { - return new Set(['connectors']); +export class Connectors extends BaseHandler { + constructor(options: BaseHandlerCommonOptions) { + super(options, ['connectors']); } public async compute(): Promise { diff --git a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts index 3d503e50feb819..64870b29a385d8 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts @@ -7,7 +7,7 @@ import { getCaseMetrics } from './get_case_metrics'; import { CaseAttributes, CaseResponse, CaseStatuses } from '../../../common/api'; -import { createCasesClientMock } from '../mocks'; +import { CasesClientMock, createCasesClientMock } from '../mocks'; import { CasesClientArgs } from '../types'; import { createAuthorizationMock } from '../../authorization/mock'; import { loggingSystemMock, savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; @@ -29,14 +29,18 @@ describe('getMetrics', () => { closed_at: '2021-11-23T19:59:44Z', }; + let client: CasesClientMock; + let mockServices: ReturnType['mockServices']; + let clientArgs: ReturnType['clientArgs']; + const openDuration = inProgressStatusChangeTimestamp.getTime() - new Date(mockCreateCloseInfo.created_at).getTime(); const inProgressDuration = currentTime.getTime() - inProgressStatusChangeTimestamp.getTime(); - const client = createMockClient(); - const { mockServices, clientArgs } = createMockClientArgs(); - beforeEach(() => { + client = createMockClient(); + ({ mockServices, clientArgs } = createMockClientArgs()); + jest.clearAllMocks(); jest.useFakeTimers('modern'); jest.setSystemTime(currentTime); diff --git a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts index 554e33e290d3c4..57755c17e65eb9 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts @@ -66,13 +66,9 @@ const buildHandlers = ( casesClient: CasesClient, clientArgs: CasesClientArgs ): Set => { - const handlers: MetricsHandler[] = [ - new Lifespan(params.caseId, casesClient, clientArgs), - new AlertsCount(params.caseId, casesClient, clientArgs), - new AlertDetails(params.caseId, casesClient, clientArgs), - new Actions(params.caseId, casesClient, clientArgs), - new Connectors(), - ]; + const handlers: MetricsHandler[] = [AlertsCount, AlertDetails, Actions, Connectors, Lifespan].map( + (ClassName) => new ClassName({ caseId: params.caseId, casesClient, clientArgs }) + ); const uniqueFeatures = new Set(params.features); const handlerFeatures = new Set(); diff --git a/x-pack/plugins/cases/server/client/metrics/lifespan.ts b/x-pack/plugins/cases/server/client/metrics/lifespan.ts index b622be44c4ded4..1af9e38050d012 100644 --- a/x-pack/plugins/cases/server/client/metrics/lifespan.ts +++ b/x-pack/plugins/cases/server/client/metrics/lifespan.ts @@ -17,27 +17,22 @@ import { } from '../../../common/api'; import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; -import { CasesClient } from '../client'; -import { CasesClientArgs } from '../types'; -import { MetricsHandler } from './types'; - -export class Lifespan implements MetricsHandler { - constructor( - private readonly caseId: string, - private readonly casesClient: CasesClient, - private readonly clientArgs: CasesClientArgs - ) {} - - public getFeatures(): Set { - return new Set(['lifespan']); +import { BaseHandler } from './base_handler'; +import { BaseHandlerCommonOptions } from './types'; + +export class Lifespan extends BaseHandler { + constructor(options: BaseHandlerCommonOptions) { + super(options, ['lifespan']); } public async compute(): Promise { const { unsecuredSavedObjectsClient, authorization, userActionService, logger } = - this.clientArgs; + this.options.clientArgs; + + const { caseId, casesClient } = this.options; try { - const caseInfo = await this.casesClient.cases.get({ id: this.caseId }); + const caseInfo = await casesClient.cases.get({ id: caseId }); const caseOpenTimestamp = new Date(caseInfo.created_at); if (!isDateValid(caseOpenTimestamp)) { @@ -52,7 +47,7 @@ export class Lifespan implements MetricsHandler { const statusUserActions = await userActionService.findStatusChanges({ unsecuredSavedObjectsClient, - caseId: this.caseId, + caseId, filter: authorizationFilter, }); @@ -67,7 +62,7 @@ export class Lifespan implements MetricsHandler { }; } catch (error) { throw createCaseError({ - message: `Failed to retrieve lifespan metrics for case id: ${this.caseId}: ${error}`, + message: `Failed to retrieve lifespan metrics for case id: ${caseId}: ${error}`, error, logger, }); diff --git a/x-pack/plugins/cases/server/client/metrics/types.ts b/x-pack/plugins/cases/server/client/metrics/types.ts index 68f7a9b58ed93a..6773ab59b0b02f 100644 --- a/x-pack/plugins/cases/server/client/metrics/types.ts +++ b/x-pack/plugins/cases/server/client/metrics/types.ts @@ -7,6 +7,8 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { CaseMetricsResponse } from '../../../common/api'; +import { CasesClient } from '../client'; +import { CasesClientArgs } from '../types'; export interface MetricsHandler { getFeatures(): Set; @@ -21,3 +23,9 @@ export interface AggregationBuilder { } export type AggregationResponse = Record | undefined; + +export interface BaseHandlerCommonOptions { + caseId: string; + casesClient: CasesClient; + clientArgs: CasesClientArgs; +} From 9127318d29058e06fcb5ef59c06d82f8c9895613 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 12 Jan 2022 19:10:36 +0000 Subject: [PATCH 04/29] chore(NA): splits types from code on @elastic/safer-lodash-set (#122697) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/BUILD.bazel | 1 + packages/elastic-safer-lodash-set/BUILD.bazel | 12 ++++++++++++ packages/kbn-apm-config-loader/BUILD.bazel | 2 +- packages/kbn-config/BUILD.bazel | 2 +- packages/kbn-ui-shared-deps-src/BUILD.bazel | 2 +- 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 3580bcfbf65711..dc66d0a7561b76 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -77,6 +77,7 @@ filegroup( srcs = [ "//packages/elastic-apm-synthtrace:build_types", "//packages/elastic-datemath:build_types", + "//packages/elastic-safer-lodash-set:build_types", "//packages/kbn-ace:build_types", "//packages/kbn-alerts:build_types", "//packages/kbn-analytics:build_types", diff --git a/packages/elastic-safer-lodash-set/BUILD.bazel b/packages/elastic-safer-lodash-set/BUILD.bazel index cba719ee4f0eff..4a1c8b4290f334 100644 --- a/packages/elastic-safer-lodash-set/BUILD.bazel +++ b/packages/elastic-safer-lodash-set/BUILD.bazel @@ -63,3 +63,15 @@ filegroup( ], visibility = ["//visibility:public"], ) + +alias( + name = "npm_module_types", + actual = PKG_BASE_NAME, + visibility = ["//visibility:public"], +) + +alias( + name = "build_types", + actual = "build", + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-apm-config-loader/BUILD.bazel b/packages/kbn-apm-config-loader/BUILD.bazel index 0e3bc444acd241..a18a5e973d3a0f 100644 --- a/packages/kbn-apm-config-loader/BUILD.bazel +++ b/packages/kbn-apm-config-loader/BUILD.bazel @@ -35,7 +35,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/elastic-safer-lodash-set", + "//packages/elastic-safer-lodash-set:npm_module_types", "//packages/kbn-utils:npm_module_types", "@npm//@elastic/apm-rum", "@npm//@types/jest", diff --git a/packages/kbn-config/BUILD.bazel b/packages/kbn-config/BUILD.bazel index da4532f7d61c41..d7046a26ff92f2 100644 --- a/packages/kbn-config/BUILD.bazel +++ b/packages/kbn-config/BUILD.bazel @@ -45,7 +45,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ - "//packages/elastic-safer-lodash-set", + "//packages/elastic-safer-lodash-set:npm_module_types", "//packages/kbn-config-schema:npm_module_types", "//packages/kbn-logging", "//packages/kbn-std:npm_module_types", diff --git a/packages/kbn-ui-shared-deps-src/BUILD.bazel b/packages/kbn-ui-shared-deps-src/BUILD.bazel index e32834b3f2e8f9..5f605ca2b59b93 100644 --- a/packages/kbn-ui-shared-deps-src/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-src/BUILD.bazel @@ -43,7 +43,7 @@ RUNTIME_DEPS = [ TYPES_DEPS = [ "//packages/elastic-datemath:npm_module_types", - "//packages/elastic-safer-lodash-set", + "//packages/elastic-safer-lodash-set:npm_module_types", "//packages/kbn-analytics:npm_module_types", "//packages/kbn-i18n:npm_module_types", "//packages/kbn-i18n-react:npm_module_types", From 116d74ac7549a67a3ed0236685e9fc4e9eff2a1a Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 12 Jan 2022 14:12:35 -0500 Subject: [PATCH 05/29] Fix import bugs (#121046) --- .../import/import_saved_objects.test.mock.ts | 60 ++ .../import/import_saved_objects.test.ts | 245 ++++--- .../import/import_saved_objects.ts | 38 +- .../import/lib/check_conflicts.test.ts | 24 +- .../import/lib/check_conflicts.ts | 9 +- ...ts => check_origin_conflicts.test.mock.ts} | 10 +- .../import/lib/check_origin_conflicts.test.ts | 246 +++---- .../import/lib/check_origin_conflicts.ts | 98 +-- .../lib/check_reference_origins.test.mock.ts | 14 + .../lib/check_reference_origins.test.ts | 182 +++++ .../import/lib/check_reference_origins.ts | 91 +++ .../import/lib/collect_saved_objects.test.ts | 61 +- .../import/lib/collect_saved_objects.ts | 13 +- .../import/lib/create_saved_objects.test.ts | 20 +- .../import/lib/create_saved_objects.ts | 30 +- .../import/lib/execute_import_hooks.ts | 2 +- .../get_import_state_map_for_retries.test.ts | 68 ++ .../lib/get_import_state_map_for_retries.ts | 43 ++ .../server/saved_objects/import/lib/index.ts | 7 +- .../import/lib/regenerate_ids.test.ts | 38 +- .../import/lib/regenerate_ids.ts | 12 +- .../server/saved_objects/import/lib/types.ts | 35 + .../saved_objects/import/lib/utils.test.ts | 26 + .../server/saved_objects/import/lib/utils.ts | 26 + .../import/lib/validate_references.test.ts | 685 +++++------------- .../import/lib/validate_references.ts | 57 +- .../import/resolve_import_errors.test.mock.ts | 78 ++ .../import/resolve_import_errors.test.ts | 303 ++++---- .../import/resolve_import_errors.ts | 70 +- .../routes/integration_tests/import.test.ts | 10 +- .../resolve_import_errors.test.ts | 9 +- .../saved_objects/spaces/data.json | 57 ++ .../common/suites/import.ts | 133 +++- .../common/suites/resolve_import_errors.ts | 104 ++- .../security_and_spaces/apis/import.ts | 211 ++++-- .../apis/resolve_import_errors.ts | 129 ++-- .../spaces_only/apis/import.ts | 91 ++- .../spaces_only/apis/resolve_import_errors.ts | 43 +- .../saved_objects/spaces/data.json | 106 ++- .../common/suites/copy_to_space.ts | 104 ++- .../common/suites/delete.ts | 6 +- .../suites/resolve_copy_to_space_conflicts.ts | 75 +- 42 files changed, 2306 insertions(+), 1363 deletions(-) create mode 100644 src/core/server/saved_objects/import/import_saved_objects.test.mock.ts rename src/core/server/saved_objects/import/lib/{__mocks__/index.ts => check_origin_conflicts.test.mock.ts} (62%) create mode 100644 src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.ts create mode 100644 src/core/server/saved_objects/import/lib/check_reference_origins.test.ts create mode 100644 src/core/server/saved_objects/import/lib/check_reference_origins.ts create mode 100644 src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.test.ts create mode 100644 src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.ts create mode 100644 src/core/server/saved_objects/import/lib/types.ts create mode 100644 src/core/server/saved_objects/import/lib/utils.test.ts create mode 100644 src/core/server/saved_objects/import/lib/utils.ts create mode 100644 src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts b/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts new file mode 100644 index 00000000000000..82e5aa4a5d77f1 --- /dev/null +++ b/src/core/server/saved_objects/import/import_saved_objects.test.mock.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { collectSavedObjects } from './lib/collect_saved_objects'; +import type { checkReferenceOrigins } from './lib/check_reference_origins'; +import type { regenerateIds } from './lib/regenerate_ids'; +import type { validateReferences } from './lib/validate_references'; +import type { checkConflicts } from './lib/check_conflicts'; +import type { checkOriginConflicts } from './lib/check_origin_conflicts'; +import type { createSavedObjects } from './lib/create_saved_objects'; +import type { executeImportHooks } from './lib/execute_import_hooks'; + +export const mockCollectSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/collect_saved_objects', () => ({ + collectSavedObjects: mockCollectSavedObjects, +})); + +export const mockCheckReferenceOrigins = jest.fn() as jest.MockedFunction< + typeof checkReferenceOrigins +>; +jest.mock('./lib/check_reference_origins', () => ({ + checkReferenceOrigins: mockCheckReferenceOrigins, +})); + +export const mockRegenerateIds = jest.fn() as jest.MockedFunction; +jest.mock('./lib/regenerate_ids', () => ({ + regenerateIds: mockRegenerateIds, +})); + +export const mockValidateReferences = jest.fn() as jest.MockedFunction; +jest.mock('./lib/validate_references', () => ({ + validateReferences: mockValidateReferences, +})); + +export const mockCheckConflicts = jest.fn() as jest.MockedFunction; +jest.mock('./lib/check_conflicts', () => ({ + checkConflicts: mockCheckConflicts, +})); + +export const mockCheckOriginConflicts = jest.fn() as jest.MockedFunction< + typeof checkOriginConflicts +>; +jest.mock('./lib/check_origin_conflicts', () => ({ + checkOriginConflicts: mockCheckOriginConflicts, +})); + +export const mockCreateSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/create_saved_objects', () => ({ + createSavedObjects: mockCreateSavedObjects, +})); + +export const mockExecuteImportHooks = jest.fn() as jest.MockedFunction; +jest.mock('./lib/execute_import_hooks', () => ({ + executeImportHooks: mockExecuteImportHooks, +})); diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index cf30d6c803933c..2f31b4cf3ead3a 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -6,6 +6,17 @@ * Side Public License, v 1. */ +import { + mockCollectSavedObjects, + mockCheckReferenceOrigins, + mockRegenerateIds, + mockValidateReferences, + mockCheckConflicts, + mockCheckOriginConflicts, + mockCreateSavedObjects, + mockExecuteImportHooks, +} from './import_saved_objects.test.mock'; + import { Readable } from 'stream'; import { v4 as uuidv4 } from 'uuid'; import { @@ -19,52 +30,33 @@ import { ISavedObjectTypeRegistry } from '..'; import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { importSavedObjectsFromStream, ImportSavedObjectsOptions } from './import_saved_objects'; import { SavedObjectsImportHook, SavedObjectsImportWarning } from './types'; - -import { - collectSavedObjects, - regenerateIds, - validateReferences, - checkConflicts, - checkOriginConflicts, - createSavedObjects, - executeImportHooks, -} from './lib'; - -jest.mock('./lib/collect_saved_objects'); -jest.mock('./lib/regenerate_ids'); -jest.mock('./lib/validate_references'); -jest.mock('./lib/check_conflicts'); -jest.mock('./lib/check_origin_conflicts'); -jest.mock('./lib/create_saved_objects'); -jest.mock('./lib/execute_import_hooks'); - -const getMockFn = any, U>(fn: (...args: Parameters) => U) => - fn as jest.MockedFunction<(...args: Parameters) => U>; +import type { ImportStateMap } from './lib'; describe('#importSavedObjectsFromStream', () => { beforeEach(() => { jest.clearAllMocks(); // mock empty output of each of these mocked modules so the import doesn't throw an error - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), }); - getMockFn(regenerateIds).mockReturnValue(new Map()); - getMockFn(validateReferences).mockResolvedValue([]); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckReferenceOrigins.mockResolvedValue({ importStateMap: new Map() }); + mockRegenerateIds.mockReturnValue(new Map()); + mockValidateReferences.mockResolvedValue([]); + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(checkOriginConflicts).mockResolvedValue({ + mockCheckOriginConflicts.mockResolvedValue({ errors: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); - getMockFn(executeImportHooks).mockResolvedValue([]); + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [] }); + mockExecuteImportHooks.mockResolvedValue([]); }); let readStream: Readable; @@ -143,24 +135,57 @@ describe('#importSavedObjectsFromStream', () => { await importSavedObjectsFromStream(options); expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); const collectSavedObjectsOptions = { readStream, objectLimit, supportedTypes }; - expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); + expect(mockCollectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); }); - test('validates references', async () => { + test('checks reference origins', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + const importStateMap = new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]); + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap, }); await importSavedObjectsFromStream(options); - expect(validateReferences).toHaveBeenCalledWith( + expect(mockCheckReferenceOrigins).toHaveBeenCalledWith({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + }); + + test('validates references', async () => { + const options = setupOptions(); + const collectedObjects = [createObject()]; + mockCollectSavedObjects.mockResolvedValue({ + errors: [], collectedObjects, + importStateMap: new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]), + }); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([[`foo:bar`, { isOnlyReference: true, id: 'baz' }]]), + }); + + await importSavedObjectsFromStream(options); + expect(mockValidateReferences).toHaveBeenCalledWith({ + objects: collectedObjects, savedObjectsClient, - namespace - ); + namespace, + importStateMap: new Map([ + // This importStateMap is a combination of the other two + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true, id: 'baz' }], + ]), + }); }); test('executes import hooks', async () => { @@ -170,19 +195,19 @@ describe('#importSavedObjectsFromStream', () => { const options = setupOptions({ importHooks }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValue({ + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: collectedObjects, }); await importSavedObjectsFromStream(options); - expect(executeImportHooks).toHaveBeenCalledWith({ + expect(mockExecuteImportHooks).toHaveBeenCalledWith({ objects: collectedObjects, importHooks, }); @@ -192,23 +217,23 @@ describe('#importSavedObjectsFromStream', () => { test('does not regenerate object IDs', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); await importSavedObjectsFromStream(options); - expect(regenerateIds).not.toHaveBeenCalled(); + expect(mockRegenerateIds).not.toHaveBeenCalled(); }); test('checks conflicts', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); await importSavedObjectsFromStream(options); @@ -218,18 +243,19 @@ describe('#importSavedObjectsFromStream', () => { namespace, ignoreRegularConflicts: overwrite, }; - expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); + expect(mockCheckConflicts).toHaveBeenCalledWith(checkConflictsParams); }); test('checks origin conflicts', async () => { const options = setupOptions(); const filteredObjects = [createObject()]; - const importIdMap = new Map(); - getMockFn(checkConflicts).mockResolvedValue({ + const importStateMap = new Map(); + const pendingOverwrites = new Set(); + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects, - importIdMap, - pendingOverwrites: new Set(), + importStateMap, + pendingOverwrites, }); await importSavedObjectsFromStream(options); @@ -239,9 +265,10 @@ describe('#importSavedObjectsFromStream', () => { typeRegistry, namespace, ignoreRegularConflicts: overwrite, - importIdMap, + importStateMap, + pendingOverwrites, }; - expect(checkOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); + expect(mockCheckOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); }); test('creates saved objects', async () => { @@ -249,43 +276,47 @@ describe('#importSavedObjectsFromStream', () => { const collectedObjects = [createObject()]; const filteredObjects = [createObject()]; const errors = [createError(), createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects, - importIdMap: new Map([ + importStateMap: new Map([ ['foo', {}], ['bar', {}], - ['baz', {}], + ['baz', { isOnlyReference: true }], ]), }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([['baz', { isOnlyReference: true, destinationId: 'newId1' }]]), + }); + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects, - importIdMap: new Map([['bar', { id: 'newId1' }]]), + importStateMap: new Map([['foo', { destinationId: 'newId2' }]]), pendingOverwrites: new Set(), }); - getMockFn(checkOriginConflicts).mockResolvedValue({ + mockCheckOriginConflicts.mockResolvedValue({ errors: [errors[3]], - importIdMap: new Map([['baz', { id: 'newId2' }]]), + importStateMap: new Map([['bar', { destinationId: 'newId3' }]]), pendingOverwrites: new Set(), }); await importSavedObjectsFromStream(options); - const importIdMap = new Map([ - ['foo', {}], - ['bar', { id: 'newId1' }], - ['baz', { id: 'newId2' }], + // assert that the importStateMap is correctly composed of the results from the four modules + const importStateMap = new Map([ + ['foo', { destinationId: 'newId2' }], + ['bar', { destinationId: 'newId3' }], + ['baz', { isOnlyReference: true, destinationId: 'newId1' }], ]); const createSavedObjectsParams = { objects: collectedObjects, accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, overwrite, namespace, }; - expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + expect(mockCreateSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); }); }); @@ -293,52 +324,58 @@ describe('#importSavedObjectsFromStream', () => { test('regenerates object IDs', async () => { const options = setupOptions({ createNewCopies: true }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await importSavedObjectsFromStream(options); - expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + expect(mockRegenerateIds).toHaveBeenCalledWith(collectedObjects); }); test('does not check conflicts or check origin conflicts', async () => { const options = setupOptions({ createNewCopies: true }); - getMockFn(validateReferences).mockResolvedValue([]); + mockValidateReferences.mockResolvedValue([]); await importSavedObjectsFromStream(options); - expect(checkConflicts).not.toHaveBeenCalled(); - expect(checkOriginConflicts).not.toHaveBeenCalled(); + expect(mockCheckConflicts).not.toHaveBeenCalled(); + expect(mockCheckOriginConflicts).not.toHaveBeenCalled(); }); test('creates saved objects', async () => { const options = setupOptions({ createNewCopies: true }); const collectedObjects = [createObject()]; const errors = [createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects, - importIdMap: new Map([ + importStateMap: new Map([ ['foo', {}], - ['bar', {}], + ['bar', { isOnlyReference: true }], ]), }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - // this importIdMap is not composed with the one obtained from `collectSavedObjects` - const importIdMap = new Map().set(`id1`, { id: `newId1` }); - getMockFn(regenerateIds).mockReturnValue(importIdMap); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([['bar', { isOnlyReference: true, destinationId: 'newId' }]]), + }); + mockValidateReferences.mockResolvedValue([errors[1]]); + mockRegenerateIds.mockReturnValue(new Map([['foo', { destinationId: `randomId1` }]])); await importSavedObjectsFromStream(options); + // assert that the importStateMap is correctly composed of the results from the three modules + const importStateMap: ImportStateMap = new Map([ + ['foo', { destinationId: `randomId1` }], + ['bar', { isOnlyReference: true, destinationId: 'newId' }], + ]); const createSavedObjectsParams = { objects: collectedObjects, accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, overwrite, namespace, }; - expect(createSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); + expect(mockCreateSavedObjects).toHaveBeenCalledWith(createSavedObjectsParams); }); }); }); @@ -353,10 +390,10 @@ describe('#importSavedObjectsFromStream', () => { test('returns success=false if an error occurred', async () => { const options = setupOptions(); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [createError()], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); const result = await importSavedObjectsFromStream(options); @@ -371,18 +408,18 @@ describe('#importSavedObjectsFromStream', () => { test('returns warnings from the import hooks', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValue({ + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: collectedObjects, }); const warnings: SavedObjectsImportWarning[] = [{ type: 'simple', message: 'foo' }]; - getMockFn(executeImportHooks).mockResolvedValue(warnings); + mockExecuteImportHooks.mockResolvedValue(warnings); const result = await importSavedObjectsFromStream(options); @@ -419,16 +456,16 @@ describe('#importSavedObjectsFromStream', () => { test('with createNewCopies disabled', async () => { const options = setupOptions(); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set([ `${success2.type}:${success2.id}`, // the success2 object was overwritten `${error2.type}:${error2.id}`, // an attempt was made to overwrite the error2 object ]), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); + mockCreateSavedObjects.mockResolvedValue({ errors, createdObjects }); const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) @@ -457,7 +494,7 @@ describe('#importSavedObjectsFromStream', () => { test('with createNewCopies enabled', async () => { // however, we include it here for posterity const options = setupOptions({ createNewCopies: true }); - getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); + mockCreateSavedObjects.mockResolvedValue({ errors, createdObjects }); const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) @@ -495,13 +532,13 @@ describe('#importSavedObjectsFromStream', () => { }, }); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [obj1, obj2] }); + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [obj1, obj2] }); const result = await importSavedObjectsFromStream(options); // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) @@ -529,24 +566,24 @@ describe('#importSavedObjectsFromStream', () => { test('accumulates multiple errors', async () => { const options = setupOptions(); const errors = [createError(), createError(), createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(checkConflicts).mockResolvedValue({ + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter pendingOverwrites: new Set(), }); - getMockFn(checkOriginConflicts).mockResolvedValue({ + mockCheckOriginConflicts.mockResolvedValue({ errors: [errors[3]], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); + mockCreateSavedObjects.mockResolvedValue({ errors: [errors[4]], createdObjects: [] }); const result = await importSavedObjectsFromStream(options); const expectedErrors = errors.map(({ type, id }) => expect.objectContaining({ type, id })); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 4fc8f04a40270e..0631d97b58a72b 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -15,6 +15,7 @@ import { SavedObjectsImportHook, } from './types'; import { + checkReferenceOrigins, validateReferences, checkOriginConflicts, createSavedObjects, @@ -72,20 +73,34 @@ export async function importSavedObjectsFromStream({ supportedTypes, }); errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; - /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ - let importIdMap = collectSavedObjectsResult.importIdMap; + // Map of all IDs for objects that we are attempting to import, and any references that are not included in the read stream; + // each value is empty by default + let importStateMap = collectSavedObjectsResult.importStateMap; let pendingOverwrites = new Set(); + // Check any references that aren't included in the import file and retries, to see if they have a match with a different origin + const checkReferenceOriginsResult = await checkReferenceOrigins({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + importStateMap = new Map([...importStateMap, ...checkReferenceOriginsResult.importStateMap]); + // Validate references - const validateReferencesResult = await validateReferences( - collectSavedObjectsResult.collectedObjects, + const validateReferencesResult = await validateReferences({ + objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, - namespace - ); + namespace, + importStateMap, + }); errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { - importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); + importStateMap = new Map([ + ...importStateMap, // preserve any entries for references that aren't included in collectedObjects + ...regenerateIds(collectSavedObjectsResult.collectedObjects), + ]); } else { // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { @@ -96,7 +111,7 @@ export async function importSavedObjectsFromStream({ }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; - importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); + importStateMap = new Map([...importStateMap, ...checkConflictsResult.importStateMap]); pendingOverwrites = checkConflictsResult.pendingOverwrites; // Check multi-namespace object types for origin conflicts in this namespace @@ -106,11 +121,12 @@ export async function importSavedObjectsFromStream({ typeRegistry, namespace, ignoreRegularConflicts: overwrite, - importIdMap, + importStateMap, + pendingOverwrites, }; const checkOriginConflictsResult = await checkOriginConflicts(checkOriginConflictsParams); errorAccumulator = [...errorAccumulator, ...checkOriginConflictsResult.errors]; - importIdMap = new Map([...importIdMap, ...checkOriginConflictsResult.importIdMap]); + importStateMap = new Map([...importStateMap, ...checkOriginConflictsResult.importStateMap]); pendingOverwrites = new Set([ ...pendingOverwrites, ...checkOriginConflictsResult.pendingOverwrites, @@ -122,7 +138,7 @@ export async function importSavedObjectsFromStream({ objects: collectSavedObjectsResult.collectedObjects, accumulatedErrors: errorAccumulator, savedObjectsClient, - importIdMap, + importStateMap, overwrite, namespace, }; diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts index 6ab37b0122e4bc..b2de6f11d5cb89 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts @@ -6,13 +6,16 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from './__mocks__'; import { savedObjectsClientMock } from '../../../mocks'; import { SavedObjectReference, SavedObjectsImportRetry } from 'kibana/public'; import { SavedObjectsClientContract, SavedObject } from '../../types'; import { SavedObjectsErrorHelpers } from '../../service'; import { checkConflicts } from './check_conflicts'; +jest.mock('uuid', () => ({ + v4: () => 'uuidv4', +})); + type SavedObjectType = SavedObject<{ title?: string }>; type CheckConflictsParams = Parameters[0]; @@ -71,11 +74,6 @@ describe('#checkConflicts', () => { return { ...partial, savedObjectsClient }; }; - beforeEach(() => { - mockUuidv4.mockReset(); - mockUuidv4.mockReturnValueOnce(`new-object-id`); - }); - it('exits early if there are no objects to check', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects: [], namespace }); @@ -85,7 +83,7 @@ describe('#checkConflicts', () => { expect(checkConflictsResult).toEqual({ filteredObjects: [], errors: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); }); @@ -121,7 +119,7 @@ describe('#checkConflicts', () => { error: { ...obj4Error.error, type: 'unknown' }, }, ], - importIdMap: new Map([[`${obj3.type}:${obj3.id}`, { id: `new-object-id` }]]), + importStateMap: new Map([[`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4' }]]), pendingOverwrites: new Set(), }); }); @@ -187,14 +185,14 @@ describe('#checkConflicts', () => { error: { ...obj4Error.error, type: 'unknown' }, }, ], - importIdMap: new Map([ - [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], + importStateMap: new Map([ + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), }); }); - it('adds `omitOriginId` field to `importIdMap` entries when createNewCopies=true', async () => { + it('adds `omitOriginId` field to `importStateMap` entries when createNewCopies=true', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects, namespace, createNewCopies: true }); socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); @@ -202,8 +200,8 @@ describe('#checkConflicts', () => { const checkConflictsResult = await checkConflicts(params); expect(checkConflictsResult).toEqual( expect.objectContaining({ - importIdMap: new Map([ - [`${obj3.type}:${obj3.id}`, { id: `new-object-id`, omitOriginId: true }], + importStateMap: new Map([ + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), }) ); diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.ts b/src/core/server/saved_objects/import/lib/check_conflicts.ts index d5e37f21fc84a4..c15c4302491b48 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.ts @@ -14,6 +14,7 @@ import { SavedObjectError, SavedObjectsImportRetry, } from '../../types'; +import type { ImportStateMap } from './types'; interface CheckConflictsParams { objects: Array>; @@ -37,12 +38,12 @@ export async function checkConflicts({ }: CheckConflictsParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportFailure[] = []; - const importIdMap = new Map(); + const importStateMap: ImportStateMap = new Map(); const pendingOverwrites = new Set(); // exit early if there are no objects to check if (objects.length === 0) { - return { filteredObjects, errors, importIdMap, pendingOverwrites }; + return { filteredObjects, errors, importStateMap, pendingOverwrites }; } const retryMap = retries.reduce( @@ -76,7 +77,7 @@ export async function checkConflicts({ // This code path should not be triggered for a retry, but in case the consumer is using the import APIs incorrectly and attempting to // retry an object with a destinationId that would result in an unresolvable conflict, we regenerate the ID here as a fail-safe. const omitOriginId = createNewCopies || createNewCopy; - importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId }); + importStateMap.set(`${type}:${id}`, { destinationId: uuidv4(), omitOriginId }); filteredObjects.push(object); } else if (errorObj && errorObj.statusCode !== 409) { errors.push({ type, id, title, meta: { title }, error: { ...errorObj, type: 'unknown' } }); @@ -90,5 +91,5 @@ export async function checkConflicts({ } } }); - return { filteredObjects, errors, importIdMap, pendingOverwrites }; + return { filteredObjects, errors, importStateMap, pendingOverwrites }; } diff --git a/src/core/server/saved_objects/import/lib/__mocks__/index.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts similarity index 62% rename from src/core/server/saved_objects/import/lib/__mocks__/index.ts rename to src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts index c53fc78c8e8870..8fb5704af9d821 100644 --- a/src/core/server/saved_objects/import/lib/__mocks__/index.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -const mockUuidv4 = jest.fn().mockReturnValue('uuidv4'); -jest.mock('uuid', () => ({ - v4: mockUuidv4, -})); +import type { createOriginQuery } from './utils'; -export { mockUuidv4 }; +export const mockCreateOriginQuery = jest.fn() as jest.MockedFunction; +jest.mock('./utils', () => ({ + createOriginQuery: mockCreateOriginQuery, +})); diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts index 03d94492e7ec89..6c633b1a119d1a 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.ts @@ -6,18 +6,23 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from './__mocks__'; +import { mockCreateOriginQuery } from './check_reference_origins.test.mock'; + import { SavedObjectsClientContract, SavedObjectReference, SavedObject, - SavedObjectsImportRetry, SavedObjectsImportFailure, } from '../../types'; -import { checkOriginConflicts, getImportIdMapForRetries } from './check_origin_conflicts'; +import { checkOriginConflicts } from './check_origin_conflicts'; import { savedObjectsClientMock } from '../../../mocks'; import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap } from './types'; + +jest.mock('uuid', () => ({ + v4: () => 'uuidv4', +})); type SavedObjectType = SavedObject<{ title?: string }>; type CheckOriginConflictsParams = Parameters[0]; @@ -42,10 +47,6 @@ const createObject = ( const MULTI_NS_TYPE = 'multi'; const OTHER_TYPE = 'other'; -beforeEach(() => { - mockUuidv4.mockClear(); -}); - describe('#checkOriginConflicts', () => { let savedObjectsClient: jest.Mocked; let typeRegistry: jest.Mocked; @@ -61,8 +62,9 @@ describe('#checkOriginConflicts', () => { const setupParams = (partial: { objects: SavedObjectType[]; namespace?: string; - importIdMap?: Map; ignoreRegularConflicts?: boolean; + importStateMap?: ImportStateMap; + pendingOverwrites?: Set; }): CheckOriginConflictsParams => { savedObjectsClient = savedObjectsClientMock.create(); find = savedObjectsClient.find; @@ -70,7 +72,8 @@ describe('#checkOriginConflicts', () => { typeRegistry = typeRegistryMock.create(); typeRegistry.isMultiNamespace.mockImplementation((type) => type === MULTI_NS_TYPE); return { - importIdMap: new Map(), // empty by default + importStateMap: new Map(), // empty by default + pendingOverwrites: new Set(), // empty by default ...partial, savedObjectsClient, typeRegistry, @@ -82,19 +85,21 @@ describe('#checkOriginConflicts', () => { }; describe('cluster calls', () => { + beforeEach(() => { + mockCreateOriginQuery.mockClear(); + }); + const multiNsObj = createObject(MULTI_NS_TYPE, 'id-1'); const multiNsObjWithOriginId = createObject(MULTI_NS_TYPE, 'id-2', 'originId-foo'); const otherObj = createObject(OTHER_TYPE, 'id-3'); // non-multi-namespace types shouldn't have origin IDs, but we include a test case to ensure it's handled gracefully const otherObjWithOriginId = createObject(OTHER_TYPE, 'id-4', 'originId-bar'); - const expectFindArgs = (n: number, object: SavedObject, rawIdPrefix: string) => { - const { type, id, originId } = object; - const search = `"${rawIdPrefix}${type}:${originId || id}" | "${originId || id}"`; // this template works for our basic test cases - const expectedArgs = expect.objectContaining({ type, search }); - // exclude rootSearchFields, page, perPage, and fields attributes from assertion -- these are constant + const expectFindArgs = (n: number, object: SavedObject) => { + const idToCheck = object.originId || object.id; + expect(mockCreateOriginQuery).toHaveBeenNthCalledWith(n, object.type, idToCheck); // exclude namespace from assertion -- a separate test covers that - expect(find).toHaveBeenNthCalledWith(n, expectedArgs); + expect(find).toHaveBeenNthCalledWith(n, expect.objectContaining({ type: object.type })); }; test('does not execute searches for non-multi-namespace objects', async () => { @@ -105,21 +110,26 @@ describe('#checkOriginConflicts', () => { expect(find).not.toHaveBeenCalled(); }); + test('does not execute searches for multi-namespace objects that already have pending overwrites (exact match conflicts)', async () => { + const objects = [multiNsObj, multiNsObjWithOriginId]; + const pendingOverwrites = new Set([ + `${multiNsObj.type}:${multiNsObj.id}`, + `${multiNsObjWithOriginId.type}:${multiNsObjWithOriginId.id}`, + ]); + const params = setupParams({ objects, pendingOverwrites }); + + await checkOriginConflicts(params); + expect(find).not.toHaveBeenCalled(); + }); + test('executes searches for multi-namespace objects', async () => { const objects = [multiNsObj, otherObj, multiNsObjWithOriginId, otherObjWithOriginId]; const params1 = setupParams({ objects }); await checkOriginConflicts(params1); expect(find).toHaveBeenCalledTimes(2); - expectFindArgs(1, multiNsObj, ''); - expectFindArgs(2, multiNsObjWithOriginId, ''); - - find.mockClear(); - const params2 = setupParams({ objects, namespace: 'some-namespace' }); - await checkOriginConflicts(params2); - expect(find).toHaveBeenCalledTimes(2); - expectFindArgs(1, multiNsObj, 'some-namespace:'); - expectFindArgs(2, multiNsObjWithOriginId, 'some-namespace:'); + expectFindArgs(1, multiNsObj); + expectFindArgs(2, multiNsObjWithOriginId); }); test('searches within the current `namespace`', async () => { @@ -131,22 +141,6 @@ describe('#checkOriginConflicts', () => { expect(find).toHaveBeenCalledTimes(1); expect(find).toHaveBeenCalledWith(expect.objectContaining({ namespaces: [namespace] })); }); - - test('search query escapes quote and backslash characters in `id` and/or `originId`', async () => { - const weirdId = `some"weird\\id`; - const objects = [ - createObject(MULTI_NS_TYPE, weirdId), - createObject(MULTI_NS_TYPE, 'some-id', weirdId), - ]; - const params = setupParams({ objects }); - - await checkOriginConflicts(params); - const escapedId = `some\\"weird\\\\id`; - const expectedQuery = `"${MULTI_NS_TYPE}:${escapedId}" | "${escapedId}"`; - expect(find).toHaveBeenCalledTimes(2); - expect(find).toHaveBeenNthCalledWith(1, expect.objectContaining({ search: expectedQuery })); - expect(find).toHaveBeenNthCalledWith(2, expect.objectContaining({ search: expectedQuery })); - }); }); describe('results', () => { @@ -183,7 +177,35 @@ describe('#checkOriginConflicts', () => { }, }); - describe('object result without a `importIdMap` entry (no match or exact match)', () => { + test('filters inexact matches of other objects that are being imported, but does not filter inexact matches of references that are not being imported', async () => { + // obj1, obj2, and obj3 exist in this space, and obj1 has references to both obj2 and obj3 + // try to import obj1, obj2, and obj4; simulating a scenario where obj1 and obj2 were filtered out during `checkConflicts`, so we only call `checkOriginConflicts` with the remainder + const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); + const obj2 = createObject(MULTI_NS_TYPE, 'id-2', 'some-originId'); + const obj3 = createObject(MULTI_NS_TYPE, 'id-3', 'some-originId'); + const obj4 = createObject(MULTI_NS_TYPE, 'id-4', 'some-originId'); + const objects = [obj4]; + const params = setupParams({ + objects, + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, {}], + [`${obj2.type}:${obj2.id}`, {}], + [`${obj3.type}:${obj3.id}`, { isOnlyReference: true }], // this attribute signifies that there is a reference to this object, but it is not present in the collected objects from the import file + [`${obj4.type}:${obj4.id}`, {}], + ]), + }); + mockFindResult(obj2, obj3); // find for obj4: the result is an inexact match with two destinations, one of which is exactly matched by obj2 -- accordingly, obj4 has an inexact match to obj3 + + const checkOriginConflictsResult = await checkOriginConflicts(params); + const expectedResult = { + importStateMap: new Map(), + errors: [createConflictError(obj4, obj3.id)], + pendingOverwrites: new Set(), + }; + expect(checkOriginConflictsResult).toEqual(expectedResult); + }); + + describe('object result without a `importStateMap` entry (no match or exact match)', () => { test('returns object when no match is detected (0 hits)', async () => { // no objects exist in this space // try to import obj1, obj2, obj3, and obj4 @@ -198,7 +220,7 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [], pendingOverwrites: new Set(), }; @@ -215,7 +237,7 @@ describe('#checkOriginConflicts', () => { const objects = [obj2, obj4]; const params = setupParams({ objects, - importIdMap: new Map([ + importStateMap: new Map([ [`${obj1.type}:${obj1.id}`, {}], [`${obj2.type}:${obj2.id}`, {}], [`${obj3.type}:${obj3.id}`, {}], @@ -227,7 +249,7 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [], pendingOverwrites: new Set(), }; @@ -243,7 +265,7 @@ describe('#checkOriginConflicts', () => { const objects = [obj3]; const params = setupParams({ objects, - importIdMap: new Map([ + importStateMap: new Map([ [`${obj1.type}:${obj1.id}`, {}], [`${obj2.type}:${obj2.id}`, {}], [`${obj3.type}:${obj3.id}`, {}], @@ -253,7 +275,7 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [], pendingOverwrites: new Set(), }; @@ -261,7 +283,7 @@ describe('#checkOriginConflicts', () => { }); }); - describe('object result with a `importIdMap` entry (partial match with a single destination)', () => { + describe('object result with a `importStateMap` entry (partial match with a single destination)', () => { describe('when an inexact match is detected (1 hit)', () => { // objA and objB exist in this space // try to import obj1 and obj2 @@ -282,20 +304,20 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [createConflictError(obj1, objA.id), createConflictError(obj2, objB.id)], pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); - test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + test('returns object with a `importStateMap` entry when ignoreRegularConflicts=true', async () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj1.type}:${obj1.id}`, { id: objA.id }], - [`${obj2.type}:${obj2.id}`, { id: objB.id }], + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, { destinationId: objA.id }], + [`${obj2.type}:${obj2.id}`, { destinationId: objB.id }], ]), errors: [], pendingOverwrites: new Set([`${obj1.type}:${obj1.id}`, `${obj2.type}:${obj2.id}`]), @@ -319,7 +341,7 @@ describe('#checkOriginConflicts', () => { const params = setupParams({ objects, ignoreRegularConflicts, - importIdMap: new Map([ + importStateMap: new Map([ [`${obj1.type}:${obj1.id}`, {}], [`${obj2.type}:${obj2.id}`, {}], [`${obj3.type}:${obj3.id}`, {}], @@ -335,20 +357,20 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [createConflictError(obj2, objA.id), createConflictError(obj4, objB.id)], pendingOverwrites: new Set(), }; expect(checkOriginConflictsResult).toEqual(expectedResult); }); - test('returns object with a `importIdMap` entry when ignoreRegularConflicts=true', async () => { + test('returns object with a `importStateMap` entry when ignoreRegularConflicts=true', async () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj2.type}:${obj2.id}`, { id: objA.id }], - [`${obj4.type}:${obj4.id}`, { id: objB.id }], + importStateMap: new Map([ + [`${obj2.type}:${obj2.id}`, { destinationId: objA.id }], + [`${obj4.type}:${obj4.id}`, { destinationId: objB.id }], ]), errors: [], pendingOverwrites: new Set([`${obj2.type}:${obj2.id}`, `${obj4.type}:${obj4.id}`]), @@ -359,7 +381,7 @@ describe('#checkOriginConflicts', () => { }); describe('ambiguous conflicts', () => { - test('returns object with a `importIdMap` entry when multiple inexact matches are detected that target the same single destination', async () => { + test('returns object with a `importStateMap` entry when multiple inexact matches are detected that target the same single destination', async () => { // objA and objB exist in this space // try to import obj1, obj2, obj3, and obj4 const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); @@ -377,16 +399,15 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj2.type}:${obj2.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [], pendingOverwrites: new Set(), }; - expect(mockUuidv4).toHaveBeenCalledTimes(4); expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -406,18 +427,17 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map(), + importStateMap: new Map(), errors: [ createAmbiguousConflictError(obj1, [objB, objA]), // Assert that these have been sorted by updatedAt in descending order createAmbiguousConflictError(obj2, [objC, objD]), // Assert that these have been sorted by ID in ascending order (since their updatedAt values are the same) ], pendingOverwrites: new Set(), }; - expect(mockUuidv4).not.toHaveBeenCalled(); expect(checkOriginConflictsResult).toEqual(expectedResult); }); - test('returns object with a `importIdMap` entry when multiple inexact matches are detected that target the same multiple destinations', async () => { + test('returns object with a `importStateMap` entry when multiple inexact matches are detected that target the same multiple destinations', async () => { // objA, objB, objC, and objD exist in this space // try to import obj1, obj2, obj3, and obj4 const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); @@ -437,16 +457,15 @@ describe('#checkOriginConflicts', () => { const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj1.type}:${obj1.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj2.type}:${obj2.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj3.type}:${obj3.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj4.type}:${obj4.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj1.type}:${obj1.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj2.type}:${obj2.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj3.type}:${obj3.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [], pendingOverwrites: new Set(), }; - expect(mockUuidv4).toHaveBeenCalledTimes(4); expect(checkOriginConflictsResult).toEqual(expectedResult); }); }); @@ -470,10 +489,12 @@ describe('#checkOriginConflicts', () => { const objE = createObject(MULTI_NS_TYPE, 'id-E', obj7.id); const objects = [obj1, obj2, obj4, obj5, obj6, obj7, obj8]; - const importIdMap = new Map([...objects, obj3].map(({ type, id }) => [`${type}:${id}`, {}])); + const importStateMap = new Map( + [...objects, obj3].map(({ type, id }) => [`${type}:${id}`, {}]) + ); const setup = (ignoreRegularConflicts: boolean) => { - const params = setupParams({ objects, importIdMap, ignoreRegularConflicts }); + const params = setupParams({ objects, importStateMap, ignoreRegularConflicts }); // obj1 is a non-multi-namespace type, so it is skipped while searching mockFindResult(); // find for obj2: the result is no match mockFindResult(obj3); // find for obj4: the result is an inexact match with one destination that is exactly matched by obj3 so it is ignored -- accordingly, obj4 has no match @@ -488,9 +509,9 @@ describe('#checkOriginConflicts', () => { const params = setup(false); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj7.type}:${obj7.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj8.type}:${obj8.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [ createConflictError(obj5, objA.id), @@ -498,7 +519,6 @@ describe('#checkOriginConflicts', () => { ], pendingOverwrites: new Set(), }; - expect(mockUuidv4).toHaveBeenCalledTimes(2); expect(checkOriginConflictsResult).toEqual(expectedResult); }); @@ -506,74 +526,16 @@ describe('#checkOriginConflicts', () => { const params = setup(true); const checkOriginConflictsResult = await checkOriginConflicts(params); const expectedResult = { - importIdMap: new Map([ - [`${obj5.type}:${obj5.id}`, { id: objA.id }], - [`${obj7.type}:${obj7.id}`, { id: 'uuidv4', omitOriginId: true }], - [`${obj8.type}:${obj8.id}`, { id: 'uuidv4', omitOriginId: true }], + importStateMap: new Map([ + [`${obj5.type}:${obj5.id}`, { destinationId: objA.id }], + [`${obj7.type}:${obj7.id}`, { destinationId: 'uuidv4', omitOriginId: true }], + [`${obj8.type}:${obj8.id}`, { destinationId: 'uuidv4', omitOriginId: true }], ]), errors: [createAmbiguousConflictError(obj6, [objB, objC])], pendingOverwrites: new Set([`${obj5.type}:${obj5.id}`]), }; - expect(mockUuidv4).toHaveBeenCalledTimes(2); expect(checkOriginConflictsResult).toEqual(expectedResult); }); }); }); }); - -describe('#getImportIdMapForRetries', () => { - const createRetry = ( - { type, id }: { type: string; id: string }, - params: { destinationId?: string; createNewCopy?: boolean } = {} - ): SavedObjectsImportRetry => { - const { destinationId, createNewCopy } = params; - return { type, id, overwrite: false, destinationId, replaceReferences: [], createNewCopy }; - }; - - test('throws an error if retry is not found for an object', async () => { - const obj1 = createObject(MULTI_NS_TYPE, 'id-1'); - const obj2 = createObject(MULTI_NS_TYPE, 'id-2'); - const objects = [obj1, obj2]; - const retries = [createRetry(obj1)]; - const params = { objects, retries, createNewCopies: false }; - - expect(() => getImportIdMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( - `"Retry was expected for \\"multi:id-2\\" but not found"` - ); - }); - - test('returns expected results', async () => { - const obj1 = createObject('type-1', 'id-1'); - const obj2 = createObject('type-2', 'id-2'); - const obj3 = createObject('type-3', 'id-3'); - const obj4 = createObject('type-4', 'id-4'); - const objects = [obj1, obj2, obj3, obj4]; - const retries = [ - createRetry(obj1), // retries that do not have `destinationId` specified are ignored - createRetry(obj2, { destinationId: obj2.id }), // retries that have `id` that matches `destinationId` are ignored - createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importIdMap`! - createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importIdMap`! - ]; - const params = { objects, retries, createNewCopies: false }; - - const checkOriginConflictsResult = await getImportIdMapForRetries(params); - expect(checkOriginConflictsResult).toEqual( - new Map([ - [`${obj3.type}:${obj3.id}`, { id: 'id-X', omitOriginId: false }], - [`${obj4.type}:${obj4.id}`, { id: 'id-Y', omitOriginId: true }], - ]) - ); - }); - - test('omits origin ID in `importIdMap` entries when createNewCopies=true', async () => { - const obj = createObject('type-1', 'id-1'); - const objects = [obj]; - const retries = [createRetry(obj, { destinationId: 'id-X' })]; - const params = { objects, retries, createNewCopies: true }; - - const checkOriginConflictsResult = await getImportIdMapForRetries(params); - expect(checkOriginConflictsResult).toEqual( - new Map([[`${obj.type}:${obj.id}`, { id: 'id-X', omitOriginId: true }]]) - ); - }); -}); diff --git a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts index d689f37f5ad260..f1bb1afb2e3e4a 100644 --- a/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.ts @@ -8,13 +8,10 @@ import pMap from 'p-map'; import { v4 as uuidv4 } from 'uuid'; -import { - SavedObject, - SavedObjectsClientContract, - SavedObjectsImportFailure, - SavedObjectsImportRetry, -} from '../../types'; +import { SavedObject, SavedObjectsClientContract, SavedObjectsImportFailure } from '../../types'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap } from './types'; +import { createOriginQuery } from './utils'; interface CheckOriginConflictsParams { objects: Array>; @@ -22,19 +19,15 @@ interface CheckOriginConflictsParams { typeRegistry: ISavedObjectTypeRegistry; namespace?: string; ignoreRegularConflicts?: boolean; - importIdMap: Map; + importStateMap: ImportStateMap; + pendingOverwrites: Set; } -type CheckOriginConflictParams = Omit & { +type CheckOriginConflictParams = Omit & { object: SavedObject<{ title?: string }>; + objectIdsBeingImported: Set; }; -interface GetImportIdMapForRetriesParams { - objects: SavedObject[]; - retries: SavedObjectsImportRetry[]; - createNewCopies: boolean; -} - interface InexactMatch { object: SavedObject; destinations: Array<{ id: string; title?: string; updatedAt?: string }>; @@ -52,9 +45,6 @@ const isLeft = (object: Either): object is Left => object.tag === 'left const MAX_CONCURRENT_SEARCHES = 10; -const createQueryTerm = (input: string) => input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); -const createQuery = (type: string, id: string, rawIdPrefix: string) => - `"${createQueryTerm(`${rawIdPrefix}${type}:${id}`)}" | "${createQueryTerm(id)}"`; const transformObjectsToAmbiguousConflictFields = ( objects: Array> ) => @@ -81,25 +71,32 @@ const getAmbiguousConflictSourceKey = ({ object }: InexactMatch) => * specified namespace: * - A `Right` result indicates that no conflict destinations were found in this namespace ("no match"). * - A `Left` result indicates that one or more conflict destinations exist in this namespace, none of which exactly match this object's ID - * ("inexact match"). We can make this assumption because any "exact match" results would have been obtained and filtered out by the - * `checkConflicts` submodule, which is called before this. + * ("inexact match"). We can make this assumption because any "exact match" conflict errors would have been obtained and filtered out by + * the `checkConflicts` submodule, which is called before this, *or* if `overwrite: true` is used, we explicitly filter out any pending + * overwrites for exact matches. */ const checkOriginConflict = async ( params: CheckOriginConflictParams ): Promise> => { - const { object, savedObjectsClient, typeRegistry, namespace, importIdMap } = params; - const importIds = new Set(importIdMap.keys()); - const { type, originId } = object; - - if (!typeRegistry.isMultiNamespace(type)) { + const { + object, + savedObjectsClient, + typeRegistry, + namespace, + objectIdsBeingImported, + pendingOverwrites, + } = params; + const { type, originId, id } = object; + + if (!typeRegistry.isMultiNamespace(type) || pendingOverwrites.has(`${type}:${id}`)) { // Skip the search request for non-multi-namespace types, since by definition they cannot have inexact matches or ambiguous conflicts. + // Also skip the search request for objects that we've already determined have an "exact match" conflict. return { tag: 'right', value: object }; } - const search = createQuery(type, originId || object.id, namespace ? `${namespace}:` : ''); const findOptions = { type, - search, + search: createOriginQuery(type, originId || id), rootSearchFields: ['_id', 'originId'], page: 1, perPage: 10, @@ -114,7 +111,9 @@ const checkOriginConflict = async ( return { tag: 'right', value: object }; } // This is an "inexact match" so far; filter the conflict destination(s) to exclude any that exactly match other objects we are importing. - const objects = savedObjects.filter((obj) => !importIds.has(`${obj.type}:${obj.id}`)); + const objects = savedObjects.filter( + (obj) => !objectIdsBeingImported.has(`${obj.type}:${obj.id}`) + ); const destinations = transformObjectsToAmbiguousConflictFields(objects); if (destinations.length === 0) { // No conflict destinations remain after filtering, so this is a "no match" result. @@ -137,14 +136,20 @@ const checkOriginConflict = async ( * that match this object's `originId` or `id` exist in the specified namespace: * - If this is a `Right` result; return the import object and allow `createSavedObjects` to handle the conflict (if any). * - If this is a `Left` "partial match" result: - * A. If there is a single source and destination match, add the destination to the importIdMap and return the import object, which + * A. If there is a single source and destination match, add the destination to the importStateMap and return the import object, which * will allow `createSavedObjects` to modify the ID before creating the object (thus ensuring a conflict during). * B. Otherwise, this is an "ambiguous conflict" result; return an error. */ export async function checkOriginConflicts({ objects, ...params }: CheckOriginConflictsParams) { + const objectIdsBeingImported = new Set(); + for (const [key, { isOnlyReference }] of params.importStateMap.entries()) { + if (!isOnlyReference) { + objectIdsBeingImported.add(key); + } + } // Check each object for possible destination conflicts, ensuring we don't too many concurrent searches running. const mapper = async (object: SavedObject<{ title?: string }>) => - checkOriginConflict({ object, ...params }); + checkOriginConflict({ object, objectIdsBeingImported, ...params }); const checkOriginConflictResults = await pMap(objects, mapper, { concurrency: MAX_CONCURRENT_SEARCHES, }); @@ -159,7 +164,7 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo }, new Map>>()); const errors: SavedObjectsImportFailure[] = []; - const importIdMap = new Map(); + const importStateMap: ImportStateMap = new Map(); const pendingOverwrites = new Set(); checkOriginConflictResults.forEach((result) => { if (!isLeft(result)) { @@ -174,7 +179,7 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo if (sources.length === 1 && destinations.length === 1) { // This is a simple "inexact match" result -- a single import object has a single destination conflict. if (params.ignoreRegularConflicts) { - importIdMap.set(`${type}:${id}`, { id: destinations[0].id }); + importStateMap.set(`${type}:${id}`, { destinationId: destinations[0].id }); pendingOverwrites.add(`${type}:${id}`); } else { const { title } = attributes; @@ -198,7 +203,7 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo if (sources.length > 1) { // In the case of ambiguous source conflicts, don't treat them as errors; instead, regenerate the object ID and reset its origin // (e.g., the same outcome as if `createNewCopies` was enabled for the entire import operation). - importIdMap.set(`${type}:${id}`, { id: uuidv4(), omitOriginId: true }); + importStateMap.set(`${type}:${id}`, { destinationId: uuidv4(), omitOriginId: true }); return; } const { title } = attributes; @@ -214,32 +219,5 @@ export async function checkOriginConflicts({ objects, ...params }: CheckOriginCo }); }); - return { errors, importIdMap, pendingOverwrites }; -} - -/** - * Assume that all objects exist in the `retries` map (due to filtering at the beginning of `resolveSavedObjectsImportErrors`). - */ -export function getImportIdMapForRetries(params: GetImportIdMapForRetriesParams) { - const { objects, retries, createNewCopies } = params; - - const retryMap = retries.reduce( - (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), - new Map() - ); - const importIdMap = new Map(); - - objects.forEach(({ type, id }) => { - const retry = retryMap.get(`${type}:${id}`); - if (!retry) { - throw new Error(`Retry was expected for "${type}:${id}" but not found`); - } - const { destinationId } = retry; - const omitOriginId = createNewCopies || Boolean(retry.createNewCopy); - if (destinationId && destinationId !== id) { - importIdMap.set(`${type}:${id}`, { id: destinationId, omitOriginId }); - } - }); - - return importIdMap; + return { errors, importStateMap, pendingOverwrites }; } diff --git a/src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.ts b/src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.ts new file mode 100644 index 00000000000000..8fb5704af9d821 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_reference_origins.test.mock.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { createOriginQuery } from './utils'; + +export const mockCreateOriginQuery = jest.fn() as jest.MockedFunction; +jest.mock('./utils', () => ({ + createOriginQuery: mockCreateOriginQuery, +})); diff --git a/src/core/server/saved_objects/import/lib/check_reference_origins.test.ts b/src/core/server/saved_objects/import/lib/check_reference_origins.test.ts new file mode 100644 index 00000000000000..de162856b9873e --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_reference_origins.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mockCreateOriginQuery } from './check_reference_origins.test.mock'; + +import type { SavedObjectsFindResult } from '../../service'; +import type { SavedObjectsClientContract } from '../../types'; +import { checkReferenceOrigins, CheckReferenceOriginsParams } from './check_reference_origins'; +import { savedObjectsClientMock } from '../../../mocks'; +import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; +import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap } from './types'; + +const MULTI_NS_TYPE = 'multi'; +const OTHER_TYPE = 'other'; + +describe('checkReferenceOrigins', () => { + let savedObjectsClient: jest.Mocked; + let typeRegistry: jest.Mocked; + let find: typeof savedObjectsClient['find']; + + const getResultMock = (...objectIds: string[]) => ({ + page: 1, + per_page: 1, + total: objectIds.length, + saved_objects: objectIds.map((id) => ({ id, score: 0 } as unknown as SavedObjectsFindResult)), + }); + + const setupParams = (partial: { + namespace?: string; + importStateMap: ImportStateMap; + }): CheckReferenceOriginsParams => { + savedObjectsClient = savedObjectsClientMock.create(); + find = savedObjectsClient.find; + find.mockResolvedValue(getResultMock()); // mock zero hits response by default + typeRegistry = typeRegistryMock.create(); + typeRegistry.isMultiNamespace.mockImplementation((type) => type === MULTI_NS_TYPE); + return { + ...partial, + savedObjectsClient, + typeRegistry, + }; + }; + + const mockFindResult = (...objectIds: string[]) => { + // doesn't matter if the mocked result is a "realistic" object, it just needs an `id` field + find.mockResolvedValueOnce(getResultMock(...objectIds)); + }; + + describe('cluster calls', () => { + beforeEach(() => { + mockCreateOriginQuery.mockClear(); + }); + + const expectFindArgs = (n: number, type: string, id: string) => { + expect(mockCreateOriginQuery).toHaveBeenNthCalledWith(n, type, id); + // exclude namespace from assertion -- a separate test covers that + expect(find).toHaveBeenNthCalledWith(n, expect.objectContaining({ type })); + }; + + test('does not execute searches for non-multi-namespace objects', async () => { + const params = setupParams({ + importStateMap: new Map([[`${OTHER_TYPE}:1`, { isOnlyReference: true }]]), + }); + + await checkReferenceOrigins(params); + expect(find).not.toHaveBeenCalled(); + }); + + test('does not execute searches for multi-namespace objects without the isOnlyReference attribute', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, {}]]), + }); + + await checkReferenceOrigins(params); + expect(find).not.toHaveBeenCalled(); + }); + + test('executes searches for multi-namespace objects with the isOnlyReference attribute', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + + await checkReferenceOrigins(params); + expect(find).toHaveBeenCalledTimes(1); + expectFindArgs(1, MULTI_NS_TYPE, '1'); + }); + + test('executes mixed searches', async () => { + const params = setupParams({ + importStateMap: new Map([ + [`${MULTI_NS_TYPE}:1`, {}], + [`${MULTI_NS_TYPE}:2`, { isOnlyReference: true }], + [`${OTHER_TYPE}:3`, { isOnlyReference: true }], + [`${MULTI_NS_TYPE}:4`, { isOnlyReference: true }], + ]), + }); + + await checkReferenceOrigins(params); + expect(find).toHaveBeenCalledTimes(2); + expectFindArgs(1, MULTI_NS_TYPE, '2'); + expectFindArgs(2, MULTI_NS_TYPE, '4'); + }); + + test('searches within the current `namespace`', async () => { + const namespace = 'some-namespace'; + const params = setupParams({ + namespace, + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + + await checkReferenceOrigins(params); + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith(expect.objectContaining({ namespaces: [namespace] })); + }); + }); + + describe('results', () => { + test('does not return an entry if search resulted in 0 matches', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + // mock find returns an empty search result by default + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map(), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + + test('does not return an entry if search resulted in 2+ matches', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + mockFindResult('2', '3'); + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map(), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + + test('does not return an entry if search resulted in 1 match with the same ID', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + mockFindResult('1'); + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map(), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + + test('returns an entry if search resulted in 1 match with a different ID', async () => { + const params = setupParams({ + importStateMap: new Map([[`${MULTI_NS_TYPE}:1`, { isOnlyReference: true }]]), + }); + mockFindResult('2'); + + const checkReferenceOriginsResult = await checkReferenceOrigins(params); + + const expectedResult = { + importStateMap: new Map([ + [`${MULTI_NS_TYPE}:1`, { isOnlyReference: true, destinationId: '2' }], + ]), + }; + expect(checkReferenceOriginsResult).toEqual(expectedResult); + }); + }); +}); diff --git a/src/core/server/saved_objects/import/lib/check_reference_origins.ts b/src/core/server/saved_objects/import/lib/check_reference_origins.ts new file mode 100644 index 00000000000000..6ee4c615f3fed7 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_reference_origins.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import pMap from 'p-map'; +import { SavedObjectsClientContract } from '../../types'; +import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { ImportStateMap, ImportStateValue } from './types'; +import { getObjectKey, parseObjectKey } from '../../service/lib/internal_utils'; +import { createOriginQuery } from './utils'; + +export interface CheckReferenceOriginsParams { + savedObjectsClient: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + namespace?: string; + importStateMap: ImportStateMap; +} + +interface Reference { + type: string; + id: string; +} + +const MAX_CONCURRENT_SEARCHES = 10; + +/** + * Searches for any existing object(s) for the given reference; if there is exactly one object with a matching origin *and* its ID is + * different than this reference ID, then this returns the different ID. Otherwise, it returns null. + */ +async function checkOrigin( + type: string, + id: string, + savedObjectsClient: SavedObjectsClientContract, + namespace: string | undefined +) { + const findOptions = { + type, + search: createOriginQuery(type, id), + rootSearchFields: ['_id', 'originId'], + page: 1, + perPage: 1, // we only need one result for now + fields: ['title'], // we don't actually need the object's title, we just specify one field so we don't fetch *all* fields + sortField: 'updated_at', + sortOrder: 'desc' as const, + ...(namespace && { namespaces: [namespace] }), + }; + const findResult = await savedObjectsClient.find<{ title?: string }>(findOptions); + const { total, saved_objects: savedObjects } = findResult; + if (total === 1) { + const [object] = savedObjects; + if (id !== object.id) { + return { + key: getObjectKey({ type, id }), + value: { isOnlyReference: true, destinationId: object.id } as ImportStateValue, + }; + } + } + // TODO: if the total is 2+, return an "ambiguous reference origin match" to the consumer (#120313) + return null; +} + +export async function checkReferenceOrigins(params: CheckReferenceOriginsParams) { + const { savedObjectsClient, namespace } = params; + const referencesToCheck: Reference[] = []; + for (const [key, { isOnlyReference }] of params.importStateMap.entries()) { + const { type, id } = parseObjectKey(key); + if (params.typeRegistry.isMultiNamespace(type) && isOnlyReference) { + referencesToCheck.push({ type, id }); + } + } + // Check each object for possible destination conflicts, ensuring we don't too many concurrent searches running. + const mapper = async ({ type, id }: Reference) => + checkOrigin(type, id, savedObjectsClient, namespace); + const checkOriginResults = await pMap(referencesToCheck, mapper, { + concurrency: MAX_CONCURRENT_SEARCHES, + }); + + const importStateMap: ImportStateMap = new Map(); + for (const result of checkOriginResults) { + if (result) { + const { key, value } = result; + importStateMap.set(key, value); + } + } + + return { importStateMap }; +} diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts index c6307070d92315..b401d71ffe4980 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.test.ts @@ -39,8 +39,18 @@ describe('collectSavedObjects()', () => { }, }); - const obj1 = { type: 'a', id: '1', attributes: { title: 'my title 1' } }; - const obj2 = { type: 'b', id: '2', attributes: { title: 'my title 2' } }; + const obj1 = { + type: 'a', + id: '1', + attributes: { title: 'my title 1' }, + references: [{ type: 'b', id: '2', name: 'b2' }], + }; + const obj2 = { + type: 'b', + id: '2', + attributes: { title: 'my title 2' }, + references: [{ type: 'c', id: '3', name: 'c3' }], + }; describe('module calls', () => { test('limit stream with empty input stream is called with null', async () => { @@ -120,17 +130,24 @@ describe('collectSavedObjects()', () => { const readStream = createReadStream(); const result = await collectSavedObjects({ readStream, supportedTypes: [], objectLimit }); - expect(result).toEqual({ collectedObjects: [], errors: [], importIdMap: new Map() }); + expect(result).toEqual({ collectedObjects: [], errors: [], importStateMap: new Map() }); }); test('collects objects from stream', async () => { - const readStream = createReadStream(obj1); - const supportedTypes = [obj1.type]; + const readStream = createReadStream(obj1, obj2); + const supportedTypes = [obj1.type, obj2.type]; const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); - const collectedObjects = [{ ...obj1, migrationVersion: {} }]; - const importIdMap = new Map([[`${obj1.type}:${obj1.id}`, {}]]); - expect(result).toEqual({ collectedObjects, errors: [], importIdMap }); + const collectedObjects = [ + { ...obj1, migrationVersion: {} }, + { ...obj2, migrationVersion: {} }, + ]; + const importStateMap = new Map([ + [`a:1`, {}], // a:1 is included because it is present in the collected objects + [`b:2`, {}], // b:2 is included because it is present in the collected objects + [`c:3`, { isOnlyReference: true }], // c:3 is included because b:2 has a reference to c:3, but this is marked as `isOnlyReference` because c:3 is not present in the collected objects + ]); + expect(result).toEqual({ collectedObjects, errors: [], importStateMap }); }); test('unsupported types return as import errors', async () => { @@ -141,20 +158,24 @@ describe('collectSavedObjects()', () => { const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); + expect(result).toEqual({ collectedObjects: [], errors, importStateMap: new Map() }); }); test('returns mixed results', async () => { const readStream = createReadStream(obj1, obj2); - const supportedTypes = [obj2.type]; + const supportedTypes = [obj1.type]; const result = await collectSavedObjects({ readStream, supportedTypes, objectLimit }); - const collectedObjects = [{ ...obj2, migrationVersion: {} }]; - const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const collectedObjects = [{ ...obj1, migrationVersion: {} }]; + const importStateMap = new Map([ + [`a:1`, {}], // a:1 is included because it is present in the collected objects + [`b:2`, { isOnlyReference: true }], // b:2 was filtered out due to an unsupported type; b:2 is included because a:1 has a reference to b:2, but this is marked as `isOnlyReference` because b:2 is not present in the collected objects + // c:3 is not included at all, because b:2 was filtered out and there are no other references to c:3 + ]); const error = { type: 'unsupported_type' }; - const { title } = obj1.attributes; - const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects, errors, importIdMap }); + const { title } = obj2.attributes; + const errors = [{ error, type: obj2.type, id: obj2.id, title, meta: { title } }]; + expect(result).toEqual({ collectedObjects, errors, importStateMap }); }); describe('with optional filter', () => { @@ -172,7 +193,7 @@ describe('collectSavedObjects()', () => { const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects: [], errors, importIdMap: new Map() }); + expect(result).toEqual({ collectedObjects: [], errors, importStateMap: new Map() }); }); test('does not filter out objects when result === true', async () => { @@ -187,11 +208,15 @@ describe('collectSavedObjects()', () => { }); const collectedObjects = [{ ...obj2, migrationVersion: {} }]; - const importIdMap = new Map([[`${obj2.type}:${obj2.id}`, {}]]); + const importStateMap = new Map([ + // a:1 was filtered out due to an unsupported type; a:1 is not included because there are no other references to a:1 + [`b:2`, {}], // b:2 is included because it is present in the collected objects + [`c:3`, { isOnlyReference: true }], // c:3 is included because b:2 has a reference to c:3, but this is marked as `isOnlyReference` because c:3 is not present in the collected objects + ]); const error = { type: 'unsupported_type' }; const { title } = obj1.attributes; const errors = [{ error, type: obj1.type, id: obj1.id, title, meta: { title } }]; - expect(result).toEqual({ collectedObjects, errors, importIdMap }); + expect(result).toEqual({ collectedObjects, errors, importStateMap }); }); }); }); diff --git a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts index 58c7a759cf0bbb..209ae5ecf283e4 100644 --- a/src/core/server/saved_objects/import/lib/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/collect_saved_objects.ts @@ -19,6 +19,7 @@ import { SavedObjectsImportFailure } from '../types'; import { SavedObjectsImportError } from '../errors'; import { getNonUniqueEntries } from './get_non_unique_entries'; import { createLimitStream } from './create_limit_stream'; +import type { ImportStateMap } from './types'; interface CollectSavedObjectsOptions { readStream: Readable; @@ -35,7 +36,7 @@ export async function collectSavedObjects({ }: CollectSavedObjectsOptions) { const errors: SavedObjectsImportFailure[] = []; const entries: Array<{ type: string; id: string }> = []; - const importIdMap = new Map(); + const importStateMap: ImportStateMap = new Map(); const collectedObjects: Array> = await createPromiseFromStreams([ readStream, createLimitStream(objectLimit), @@ -58,7 +59,13 @@ export async function collectSavedObjects({ }), createFilterStream((obj) => (filter ? filter(obj) : true)), createMapStream((obj: SavedObject) => { - importIdMap.set(`${obj.type}:${obj.id}`, {}); + importStateMap.set(`${obj.type}:${obj.id}`, {}); + for (const ref of obj.references ?? []) { + const key = `${ref.type}:${ref.id}`; + if (!importStateMap.has(key)) { + importStateMap.set(key, { isOnlyReference: true }); + } + } // Ensure migrations execute on every saved object return Object.assign({ migrationVersion: {} }, obj); }), @@ -74,6 +81,6 @@ export async function collectSavedObjects({ return { errors, collectedObjects, - importIdMap, + importStateMap, }; } diff --git a/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts b/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts index 38372e8fad6fd4..7f8b67406773ef 100644 --- a/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/lib/create_saved_objects.test.ts @@ -23,8 +23,8 @@ const createObject = (type: string, id: string, originId?: string): SavedObject attributes: {}, references: [ { name: 'name-1', type: 'other-type', id: 'other-id' }, // object that is not present - { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importIdMap entry - { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-3' }, // object that is present and has an importIdMap entry + { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importStateMap entry + { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-3' }, // object that is present and has an importStateMap entry ], ...(originId && { originId }), }); @@ -52,10 +52,10 @@ const obj13 = createObject(OTHER_TYPE, 'id-13'); // -> conflict const importId3 = 'id-foo'; const importId4 = 'id-bar'; const importId8 = 'id-baz'; -const importIdMap = new Map([ - [`${obj3.type}:${obj3.id}`, { id: importId3, omitOriginId: true }], - [`${obj4.type}:${obj4.id}`, { id: importId4 }], - [`${obj8.type}:${obj8.id}`, { id: importId8 }], +const importStateMap = new Map([ + [`${obj3.type}:${obj3.id}`, { destinationId: importId3, omitOriginId: true }], + [`${obj4.type}:${obj4.id}`, { destinationId: importId4 }], + [`${obj8.type}:${obj8.id}`, { destinationId: importId8 }], ]); describe('#createSavedObjects', () => { @@ -74,7 +74,7 @@ describe('#createSavedObjects', () => { }): CreateSavedObjectsParams => { savedObjectsClient = savedObjectsClientMock.create(); bulkCreate = savedObjectsClient.bulkCreate; - return { accumulatedErrors: [], ...partial, savedObjectsClient, importIdMap }; + return { accumulatedErrors: [], ...partial, savedObjectsClient, importStateMap }; }; const getExpectedBulkCreateArgsObjects = (objects: SavedObject[], retry?: boolean) => @@ -84,8 +84,8 @@ describe('#createSavedObjects', () => { attributes, references: [ { name: 'name-1', type: 'other-type', id: 'other-id' }, // object that is not present - { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importIdMap entry - { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-foo' }, // object that is present and has an importIdMap entry + { name: 'name-2', type: MULTI_NS_TYPE, id: 'id-1' }, // object that is present, but does not have an importStateMap entry + { name: 'name-3', type: MULTI_NS_TYPE, id: 'id-foo' }, // object that is present and has an importStateMap entry ], // if the import object had an originId, and/or if we regenerated the id, expect an originId to be included in the create args ...((originId || retry) && { originId: originId || id }), @@ -245,7 +245,7 @@ describe('#createSavedObjects', () => { await createSavedObjects(options); expect(bulkCreate).toHaveBeenCalledTimes(1); - // these three objects are transformed before being created, because they are included in the `importIdMap` + // these three objects are transformed before being created, because they are included in the `importStateMap` const x3 = { ...obj3, id: importId3, originId: undefined }; // this import object already has an originId, but the entry has omitOriginId=true const x4 = { ...obj4, id: importId4 }; // this import object already has an originId const x8 = { ...obj8, id: importId8, originId: obj8.id }; // this import object doesn't have an originId, so it is set before create diff --git a/src/core/server/saved_objects/import/lib/create_saved_objects.ts b/src/core/server/saved_objects/import/lib/create_saved_objects.ts index 66792642ea24e3..bf58b2bb4b00e0 100644 --- a/src/core/server/saved_objects/import/lib/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/create_saved_objects.ts @@ -9,16 +9,17 @@ import { SavedObject, SavedObjectsClientContract, SavedObjectsImportFailure } from '../../types'; import { extractErrors } from './extract_errors'; import { CreatedObject } from '../types'; +import type { ImportStateMap } from './types'; -interface CreateSavedObjectsParams { +export interface CreateSavedObjectsParams { objects: Array>; accumulatedErrors: SavedObjectsImportFailure[]; savedObjectsClient: SavedObjectsClientContract; - importIdMap: Map; + importStateMap: ImportStateMap; namespace?: string; overwrite?: boolean; } -interface CreateSavedObjectsResult { +export interface CreateSavedObjectsResult { createdObjects: Array>; errors: SavedObjectsImportFailure[]; } @@ -31,7 +32,7 @@ export const createSavedObjects = async ({ objects, accumulatedErrors, savedObjectsClient, - importIdMap, + importStateMap, namespace, overwrite, }: CreateSavedObjectsParams): Promise> => { @@ -58,19 +59,24 @@ export const createSavedObjects = async ({ // use the import ID map to ensure that each reference is being created with the correct ID const references = object.references?.map((reference) => { const { type, id } = reference; - const importIdEntry = importIdMap.get(`${type}:${id}`); - if (importIdEntry?.id) { - return { ...reference, id: importIdEntry.id }; + const importStateValue = importStateMap.get(`${type}:${id}`); + if (importStateValue?.destinationId) { + return { ...reference, id: importStateValue.destinationId }; } return reference; }); // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on // the created object if it did not have one (or is omitted if specified) - const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); - if (importIdEntry?.id) { - objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); - const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; - return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; + const importStateValue = importStateMap.get(`${object.type}:${object.id}`); + if (importStateValue?.destinationId) { + objectIdMap.set(`${object.type}:${importStateValue.destinationId}`, object); + const originId = importStateValue.omitOriginId ? undefined : object.originId ?? object.id; + return { + ...object, + id: importStateValue.destinationId, + originId, + ...(references && { references }), + }; } return { ...object, ...(references && { references }) }; }); diff --git a/src/core/server/saved_objects/import/lib/execute_import_hooks.ts b/src/core/server/saved_objects/import/lib/execute_import_hooks.ts index 1595d52ca8c0ed..c0b5ae0437b2c8 100644 --- a/src/core/server/saved_objects/import/lib/execute_import_hooks.ts +++ b/src/core/server/saved_objects/import/lib/execute_import_hooks.ts @@ -9,7 +9,7 @@ import { SavedObject } from '../../types'; import { SavedObjectsImportHook, SavedObjectsImportWarning } from '../types'; -interface ExecuteImportHooksOptions { +export interface ExecuteImportHooksOptions { objects: SavedObject[]; importHooks: Record; } diff --git a/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.test.ts b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.test.ts new file mode 100644 index 00000000000000..af5aca10ba289d --- /dev/null +++ b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.test.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObject } from '../../types'; +import type { SavedObjectsImportRetry } from '../types'; +import { getImportStateMapForRetries } from './get_import_state_map_for_retries'; + +describe('#getImportStateMapForRetries', () => { + const createRetry = ( + { type, id }: { type: string; id: string }, + params: { destinationId?: string; createNewCopy?: boolean } = {} + ): SavedObjectsImportRetry => { + const { destinationId, createNewCopy } = params; + return { type, id, overwrite: false, destinationId, replaceReferences: [], createNewCopy }; + }; + + test('throws an error if retry is not found for an object', async () => { + const obj1 = { type: 'type-1', id: 'id-1' }; + const obj2 = { type: 'type-2', id: 'id-2' }; + const objects = [obj1, obj2] as SavedObject[]; + const retries = [createRetry(obj1)]; + const params = { objects, retries, createNewCopies: false }; + + expect(() => getImportStateMapForRetries(params)).toThrowErrorMatchingInlineSnapshot( + `"Retry was expected for \\"type-2:id-2\\" but not found"` + ); + }); + + test('returns expected results', async () => { + const obj1 = { type: 'type-1', id: 'id-1' }; + const obj2 = { type: 'type-2', id: 'id-2' }; + const obj3 = { type: 'type-3', id: 'id-3' }; + const obj4 = { type: 'type-4', id: 'id-4' }; + const objects = [obj1, obj2, obj3, obj4] as SavedObject[]; + const retries = [ + createRetry(obj1), // retries that do not have `destinationId` specified are ignored + createRetry(obj2, { destinationId: obj2.id }), // retries that have `id` that matches `destinationId` are ignored + createRetry(obj3, { destinationId: 'id-X' }), // this retry will get added to the `importStateMap`! + createRetry(obj4, { destinationId: 'id-Y', createNewCopy: true }), // this retry will get added to the `importStateMap`! + ]; + const params = { objects, retries, createNewCopies: false }; + + const result = await getImportStateMapForRetries(params); + expect(result).toEqual( + new Map([ + [`${obj3.type}:${obj3.id}`, { destinationId: 'id-X', omitOriginId: false }], + [`${obj4.type}:${obj4.id}`, { destinationId: 'id-Y', omitOriginId: true }], + ]) + ); + }); + + test('omits origin ID in `importStateMap` entries when createNewCopies=true', async () => { + const obj1 = { type: 'type-1', id: 'id-1' }; + const objects = [obj1] as SavedObject[]; + const retries = [createRetry(obj1, { destinationId: 'id-X' })]; + const params = { objects, retries, createNewCopies: true }; + + const result = await getImportStateMapForRetries(params); + expect(result).toEqual( + new Map([[`${obj1.type}:${obj1.id}`, { destinationId: 'id-X', omitOriginId: true }]]) + ); + }); +}); diff --git a/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.ts b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.ts new file mode 100644 index 00000000000000..3066ae72738a48 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/get_import_state_map_for_retries.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObject, SavedObjectsImportRetry } from '../../types'; +import type { ImportStateMap } from './types'; + +interface GetImportStateMapForRetriesParams { + objects: SavedObject[]; + retries: SavedObjectsImportRetry[]; + createNewCopies: boolean; +} + +/** + * Assume that all objects exist in the `retries` map (due to filtering at the beginning of `resolveSavedObjectsImportErrors`). + */ +export function getImportStateMapForRetries(params: GetImportStateMapForRetriesParams) { + const { objects, retries, createNewCopies } = params; + + const retryMap = retries.reduce( + (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), + new Map() + ); + const importStateMap: ImportStateMap = new Map(); + + objects.forEach(({ type, id }) => { + const retry = retryMap.get(`${type}:${id}`); + if (!retry) { + throw new Error(`Retry was expected for "${type}:${id}" but not found`); + } + const { destinationId } = retry; + const omitOriginId = createNewCopies || Boolean(retry.createNewCopy); + if (destinationId && destinationId !== id) { + importStateMap.set(`${type}:${id}`, { destinationId, omitOriginId }); + } + }); + + return importStateMap; +} diff --git a/src/core/server/saved_objects/import/lib/index.ts b/src/core/server/saved_objects/import/lib/index.ts index ab1c34b2032df2..7d0c2fb2147e35 100644 --- a/src/core/server/saved_objects/import/lib/index.ts +++ b/src/core/server/saved_objects/import/lib/index.ts @@ -7,15 +7,18 @@ */ export { checkConflicts } from './check_conflicts'; -export { checkOriginConflicts, getImportIdMapForRetries } from './check_origin_conflicts'; +export { checkReferenceOrigins } from './check_reference_origins'; +export { checkOriginConflicts } from './check_origin_conflicts'; export { collectSavedObjects } from './collect_saved_objects'; export { createLimitStream } from './create_limit_stream'; export { createObjectsFilter } from './create_objects_filter'; export { createSavedObjects } from './create_saved_objects'; export { extractErrors } from './extract_errors'; +export { getImportStateMapForRetries } from './get_import_state_map_for_retries'; export { getNonUniqueEntries } from './get_non_unique_entries'; export { regenerateIds } from './regenerate_ids'; export { splitOverwrites } from './split_overwrites'; -export { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; +export { validateReferences } from './validate_references'; export { validateRetries } from './validate_retries'; export { executeImportHooks } from './execute_import_hooks'; +export type { ImportStateMap, ImportStateValue } from './types'; diff --git a/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts b/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts index 2696a52e0554f3..d22b9431367d46 100644 --- a/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts +++ b/src/core/server/saved_objects/import/lib/regenerate_ids.test.ts @@ -6,37 +6,31 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from './__mocks__'; import { regenerateIds } from './regenerate_ids'; import { SavedObject } from '../../types'; +jest.mock('uuid', () => ({ + v4: jest + .fn() + .mockReturnValueOnce('uuidv4 #1') + .mockReturnValueOnce('uuidv4 #2') + .mockReturnValueOnce('uuidv4 #3'), +})); + describe('#regenerateIds', () => { const objects = [ { type: 'foo', id: '1' }, { type: 'bar', id: '2' }, { type: 'baz', id: '3' }, - ] as any as SavedObject[]; + ] as SavedObject[]; test('returns expected values', () => { - mockUuidv4 - .mockReturnValueOnce('uuidv4 #1') - .mockReturnValueOnce('uuidv4 #2') - .mockReturnValueOnce('uuidv4 #3'); - expect(regenerateIds(objects)).toMatchInlineSnapshot(` - Map { - "foo:1" => Object { - "id": "uuidv4 #1", - "omitOriginId": true, - }, - "bar:2" => Object { - "id": "uuidv4 #2", - "omitOriginId": true, - }, - "baz:3" => Object { - "id": "uuidv4 #3", - "omitOriginId": true, - }, - } - `); + expect(regenerateIds(objects)).toEqual( + new Map([ + ['foo:1', { destinationId: 'uuidv4 #1', omitOriginId: true }], + ['bar:2', { destinationId: 'uuidv4 #2', omitOriginId: true }], + ['baz:3', { destinationId: 'uuidv4 #3', omitOriginId: true }], + ]) + ); }); }); diff --git a/src/core/server/saved_objects/import/lib/regenerate_ids.ts b/src/core/server/saved_objects/import/lib/regenerate_ids.ts index 01ce8bd93c01a1..174658555aaf1e 100644 --- a/src/core/server/saved_objects/import/lib/regenerate_ids.ts +++ b/src/core/server/saved_objects/import/lib/regenerate_ids.ts @@ -8,15 +8,17 @@ import { v4 as uuidv4 } from 'uuid'; import { SavedObject } from '../../types'; +import type { ImportStateMap } from './types'; /** - * Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs. + * Takes an array of saved objects and returns an importStateMap of randomly-generated new IDs. * * @param objects The saved objects to generate new IDs for. */ export const regenerateIds = (objects: SavedObject[]) => { - const importIdMap = objects.reduce((acc, object) => { - return acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true }); - }, new Map()); - return importIdMap; + const importStateMap: ImportStateMap = new Map(); + for (const { type, id } of objects) { + importStateMap.set(`${type}:${id}`, { destinationId: uuidv4(), omitOriginId: true }); + } + return importStateMap; }; diff --git a/src/core/server/saved_objects/import/lib/types.ts b/src/core/server/saved_objects/import/lib/types.ts new file mode 100644 index 00000000000000..ccc0373a80de57 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * This map contains entries for objects that are included in the import operation. The entry key is the object's `type:id`, and the entry + * value contains optional attributes which change how that object is created. The initial map that is created by the collectSavedObjects + * module contains one entry with an empty value for each object that is being imported. + * + * This map is meant to function as a sort of accumulator; each module that is called during the import process can emit new entries that + * will override those from the initial map. + */ +export type ImportStateMap = Map; + +/** + * The value of an import state entry, which contains optional attributes that change how the object is created. + */ +export interface ImportStateValue { + /** + * This attribute indicates that the object for this entry is *only* a reference, it does not exist in the import file. + */ + isOnlyReference?: boolean; + /** + * This attribute indicates that the object should have this ID instead of what was specified in the import file. + */ + destinationId?: string; + /** + * This attribute indicates that the object's originId should be cleared. + */ + omitOriginId?: boolean; +} diff --git a/src/core/server/saved_objects/import/lib/utils.test.ts b/src/core/server/saved_objects/import/lib/utils.test.ts new file mode 100644 index 00000000000000..19ecd38283b423 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/utils.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createOriginQuery } from './utils'; + +describe('createOriginQuery', () => { + it('returns expected simple query string', () => { + const result = createOriginQuery('a', 'b'); + expect(result).toEqual('"a:b" | "b"'); + }); + + it('escapes double quotes', () => { + const result = createOriginQuery('a"', 'b"'); + expect(result).toEqual('"a\\":b\\"" | "b\\""'); + }); + + it('escapes backslashes', () => { + const result = createOriginQuery('a\\', 'b\\'); + expect(result).toEqual('"a\\\\:b\\\\" | "b\\\\"'); + }); +}); diff --git a/src/core/server/saved_objects/import/lib/utils.ts b/src/core/server/saved_objects/import/lib/utils.ts new file mode 100644 index 00000000000000..7b4f188f895799 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/utils.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +function createOriginQueryTerm(input: string) { + return input.replace(/\\/g, '\\\\').replace(/\"/g, '\\"'); +} + +/** + * @internal + * Constructs a simple query string for an object that will match any existing objects with the same origin. + * This matches based on the object's raw document ID (_id) or the object's originId. + * + * @param type a saved object type + * @param id a saved object ID to check; this should be the object's originId if present, otherwise it should be the object's ID + * @returns a simple query string + */ +export function createOriginQuery(type: string, id: string) { + // 1st query term will match raw object IDs (_id), 2nd query term will match originId + // we intentionally do not include a namespace prefix for the raw object IDs, because this search is only for multi-namespace object types + return `"${createOriginQueryTerm(`${type}:${id}`)}" | "${createOriginQueryTerm(id)}"`; +} diff --git a/src/core/server/saved_objects/import/lib/validate_references.test.ts b/src/core/server/saved_objects/import/lib/validate_references.test.ts index c6cbc2cacc759d..2e6f1a5e0a9a27 100644 --- a/src/core/server/saved_objects/import/lib/validate_references.test.ts +++ b/src/core/server/saved_objects/import/lib/validate_references.test.ts @@ -6,549 +6,251 @@ * Side Public License, v 1. */ -import { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; +import type { ValidateReferencesParams } from './validate_references'; +import { validateReferences } from './validate_references'; import { savedObjectsClientMock } from '../../../mocks'; import { SavedObjectsErrorHelpers } from '../../service'; -describe('getNonExistingReferenceAsKeys()', () => { +function setup({ + objects = [], + namespace, + importStateMap = new Map(), + retries, +}: Partial> = {}) { const savedObjectsClient = savedObjectsClientMock.create(); + return { objects, savedObjectsClient, namespace, importStateMap, retries }; +} - beforeEach(() => { - jest.resetAllMocks(); - }); - - test('returns empty response when no objects exist', async () => { - const result = await getNonExistingReferenceAsKeys([], savedObjectsClient); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); - }); +function createNotFoundError({ type, id }: { type: string; id: string }) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload; + return { type, id, error, attributes: {}, references: [] }; +} - test('skips objects when ignoreMissingReferences is included in retry', async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], - }, - ]; - const retries = [ - { - type: 'visualization', - id: '2', - overwrite: false, - replaceReferences: [], - ignoreMissingReferences: true, - }, - ]; - const result = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - undefined, - retries - ); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); - }); +describe('validateReferences()', () => { + test('does not call cluster and returns empty when no objects are passed in', async () => { + const params = setup(); - test('removes references that exist within savedObjects', async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test('removes references that exist within es', async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ + test('returns errors when references are missing', async () => { + const params = setup({ + objects: [ { id: '1', - type: 'index-pattern', + type: 'visualization', attributes: {}, references: [], }, + { + id: '2', + type: 'visualization', + attributes: { title: 'My Visualization 2' }, + references: [{ name: 'ref_0', type: 'index-pattern', id: '3' }], + }, + { + id: '4', + type: 'visualization', + attributes: {}, + references: [ + { name: 'ref_0', type: 'index-pattern', id: '5' }, + { name: 'ref_1', type: 'index-pattern', id: '6' }, + { name: 'ref_2', type: 'search', id: '7' }, + { name: 'ref_3', type: 'search', id: '8' }, + ], + }, + ], + }); + params.savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createNotFoundError({ type: 'index-pattern', id: '3' }), + createNotFoundError({ type: 'index-pattern', id: '5' }), + createNotFoundError({ type: 'index-pattern', id: '6' }), + createNotFoundError({ type: 'search', id: '7' }), + { id: '8', type: 'search', attributes: {}, references: [] }, ], }); - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test(`doesn't handle saved object types outside of ENFORCED_TYPES`, async () => { - const savedObjects = [ - { + const result = await validateReferences(params); + expect(result).toEqual([ + expect.objectContaining({ + type: 'visualization', id: '2', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: '3' }], + }, + }), + expect.objectContaining({ type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'foo', - id: '1', - }, - ], - }, - ]; - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + id: '4', + error: { + type: 'missing_references', + references: [ + { type: 'index-pattern', id: '5' }, + { type: 'index-pattern', id: '6' }, + { type: 'search', id: '7' }, + ], + }, + }), + ]); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [ + { type: 'index-pattern', id: '3', fields: ['id'] }, + { type: 'index-pattern', id: '5', fields: ['id'] }, + { type: 'index-pattern', id: '6', fields: ['id'] }, + { type: 'search', id: '7', fields: ['id'] }, + { type: 'search', id: '8', fields: ['id'] }, + ], + { namespace: undefined } + ); }); - test('returns references within ENFORCED_TYPES when they are missing', async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - { - name: 'ref_1', - type: 'search', - id: '3', - }, - { - name: 'ref_2', - type: 'foo', - id: '4', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ + test(`skips checking references when ignoreMissingReferences is included in retry`, async () => { + const params = setup({ + objects: [ { - id: '1', - type: 'index-pattern', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output - .payload, + id: '2', + type: 'visualization', attributes: {}, - references: [], + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], }, + ], + retries: [ { - id: '3', - type: 'search', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '3').output.payload, - attributes: {}, - references: [], + type: 'visualization', + id: '2', + overwrite: false, + replaceReferences: [], + ignoreMissingReferences: true, }, ], }); - const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); - expect(result).toEqual(['index-pattern:1', 'search:3']); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "1", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "3", - "type": "search", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); -}); - -describe('validateReferences()', () => { - const savedObjectsClient = savedObjectsClientMock.create(); - - beforeEach(() => { - jest.resetAllMocks(); - }); - test('returns empty when no objects are passed in', async () => { - const result = await validateReferences([], savedObjectsClient); + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test('returns errors when references are missing', async () => { - savedObjectsClient.bulkGet.mockResolvedValue({ - saved_objects: [ + test(`doesn't return errors when references exist in Elasticsearch`, async () => { + const params = setup({ + objects: [ { - type: 'index-pattern', - id: '3', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '3').output - .payload, + id: '2', + type: 'visualization', attributes: {}, - references: [], - }, - { - type: 'index-pattern', - id: '5', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '5').output - .payload, - attributes: {}, - references: [], + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], }, + ], + }); + params.savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [{ id: '1', type: 'index-pattern', attributes: {}, references: [] }], + }); + + const result = await validateReferences(params); + expect(result).toEqual([]); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ type: 'index-pattern', id: '1', fields: ['id'] }], + { namespace: undefined } + ); + }); + + test(`skips checking references that exist within the saved objects`, async () => { + const params = setup({ + objects: [ { + id: '1', type: 'index-pattern', - id: '6', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '6').output - .payload, - attributes: {}, - references: [], - }, - { - type: 'search', - id: '7', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('search', '7').output.payload, attributes: {}, references: [], }, { - id: '8', - type: 'search', + id: '2', + type: 'visualization', attributes: {}, - references: [], + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], }, ], }); - const savedObjects = [ - { - id: '1', - type: 'visualization', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'visualization', - attributes: { - title: 'My Visualization 2', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '3', - }, - ], - }, - { - id: '4', - type: 'visualization', - attributes: { - title: 'My Visualization 4', - }, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '5', - }, - { - name: 'ref_1', - type: 'index-pattern', - id: '6', - }, - { - name: 'ref_2', - type: 'search', - id: '7', - }, - { - name: 'ref_3', - type: 'search', - id: '8', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "error": Object { - "references": Array [ - Object { - "id": "3", - "type": "index-pattern", - }, - ], - "type": "missing_references", - }, - "id": "2", - "meta": Object { - "title": "My Visualization 2", - }, - "title": "My Visualization 2", - "type": "visualization", - }, - Object { - "error": Object { - "references": Array [ - Object { - "id": "5", - "type": "index-pattern", - }, - Object { - "id": "6", - "type": "index-pattern", - }, - Object { - "id": "7", - "type": "search", - }, - ], - "type": "missing_references", - }, - "id": "4", - "meta": Object { - "title": "My Visualization 4", - }, - "title": "My Visualization 4", - "type": "visualization", - }, - ] - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "3", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "5", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "6", - "type": "index-pattern", - }, - Object { - "fields": Array [ - "id", - ], - "id": "7", - "type": "search", - }, - Object { - "fields": Array [ - "id", - ], - "id": "8", - "type": "search", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - test(`doesn't return errors when ignoreMissingReferences is included in retry`, async () => { - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], - }, - ]; - const retries = [ - { - type: 'visualization', - id: '2', - overwrite: false, - replaceReferences: [], - ignoreMissingReferences: true, - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient, undefined, retries); + const result = await validateReferences(params); + expect(result).toEqual([]); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test(`doesn't return errors when references exist in Elasticsearch`, async () => { - savedObjectsClient.bulkGet.mockResolvedValue({ - saved_objects: [ + test(`skips checking references that are not part of ENFORCED_TYPES`, async () => { + // this test case intentionally includes a mix of references that *will* be checked, and references that *won't* be checked + const params = setup({ + objects: [ { - id: '1', - type: 'index-pattern', + id: '2', + type: 'visualization', attributes: {}, - references: [], + references: [ + { name: 'ref_0', type: 'index-pattern', id: '1' }, + { name: 'ref_2', type: 'foo', id: '2' }, + { name: 'ref_1', type: 'search', id: '3' }, + ], }, ], }); - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient); - expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); - }); + params.savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { type: 'index-pattern', id: '1', attributes: {}, references: [] }, + { type: 'search', id: '3', attributes: {}, references: [] }, + ], + }); - test(`doesn't return errors when references exist within the saved objects`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient); + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(params.savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [ + { type: 'index-pattern', id: '1', fields: ['id'] }, + // foo:2 is not included in the cluster call + { type: 'search', id: '3', fields: ['id'] }, + ], + { namespace: undefined } + ); }); - test(`doesn't validate references on types not part of ENFORCED_TYPES`, async () => { - const savedObjects = [ - { - id: '1', - type: 'dashboard', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'visualization', - id: '2', - }, - { - name: 'ref_1', - type: 'other-type', - id: '3', - }, - ], - }, - ]; - const result = await validateReferences(savedObjects, savedObjectsClient); + test('skips checking references when an importStateMap entry indicates that we have already found an origin match with a different ID', async () => { + const params = setup({ + objects: [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ], + importStateMap: new Map([ + [`index-pattern:1`, { isOnlyReference: true, destinationId: 'not-1' }], + ]), + }); + + const result = await validateReferences(params); expect(result).toEqual([]); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + expect(params.savedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); - test('throws when bulkGet fails', async () => { - savedObjectsClient.bulkGet.mockResolvedValue({ + test('throws when bulkGet encounters an unexpected error', async () => { + const params = setup({ + objects: [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: '1' }], + }, + ], + }); + params.savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ { id: '1', @@ -559,24 +261,9 @@ describe('validateReferences()', () => { }, ], }); - const savedObjects = [ - { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - await expect( - validateReferences(savedObjects, savedObjectsClient) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error fetching references for imported objects"` + + await expect(() => validateReferences(params)).rejects.toThrowError( + 'Error fetching references for imported objects' ); }); }); diff --git a/src/core/server/saved_objects/import/lib/validate_references.ts b/src/core/server/saved_objects/import/lib/validate_references.ts index e4c29a5951c27c..69e036cf77a3ad 100644 --- a/src/core/server/saved_objects/import/lib/validate_references.ts +++ b/src/core/server/saved_objects/import/lib/validate_references.ts @@ -9,6 +9,7 @@ import { SavedObject, SavedObjectsClientContract } from '../../types'; import { SavedObjectsImportFailure, SavedObjectsImportRetry } from '../types'; import { SavedObjectsImportError } from '../errors'; +import type { ImportStateMap } from './types'; const REF_TYPES_TO_VALIDATE = ['index-pattern', 'search']; @@ -22,29 +23,44 @@ const getObjectsToSkip = (retries: SavedObjectsImportRetry[] = []) => new Set() ); -export async function getNonExistingReferenceAsKeys( - savedObjects: SavedObject[], - savedObjectsClient: SavedObjectsClientContract, - namespace?: string, - retries?: SavedObjectsImportRetry[] -) { +export interface ValidateReferencesParams { + objects: Array>; + savedObjectsClient: SavedObjectsClientContract; + namespace: string | undefined; + importStateMap: ImportStateMap; + retries?: SavedObjectsImportRetry[]; +} + +async function getNonExistingReferenceAsKeys({ + objects, + savedObjectsClient, + namespace, + importStateMap, + retries, +}: ValidateReferencesParams) { const objectsToSkip = getObjectsToSkip(retries); const collector = new Map(); // Collect all references within objects - for (const savedObject of savedObjects) { - if (objectsToSkip.has(`${savedObject.type}:${savedObject.id}`)) { - // skip objects with retries that have specified `ignoreMissingReferences` + for (const object of objects) { + if (objectsToSkip.has(`${object.type}:${object.id}`)) { + // skip objects with retries that have specified `ignoreMissingReferences`, or that share an origin with an existing object that has a different ID continue; } - const filteredReferences = (savedObject.references || []).filter(filterReferencesToValidate); + const filteredReferences = (object.references || []).filter(filterReferencesToValidate); for (const { type, id } of filteredReferences) { + const key = `${type}:${id}`; + const { isOnlyReference, destinationId } = importStateMap.get(key) ?? {}; + if (isOnlyReference && destinationId) { + // We previously searched for this reference and found one with a matching origin, skip validating this + continue; + } collector.set(`${type}:${id}`, { type, id }); } } // Remove objects that could be references - for (const savedObject of savedObjects) { - collector.delete(`${savedObject.type}:${savedObject.id}`); + for (const object of objects) { + collector.delete(`${object.type}:${object.id}`); } if (collector.size === 0) { return []; @@ -73,23 +89,14 @@ export async function getNonExistingReferenceAsKeys( return [...collector.keys()]; } -export async function validateReferences( - savedObjects: Array>, - savedObjectsClient: SavedObjectsClientContract, - namespace?: string, - retries?: SavedObjectsImportRetry[] -) { +export async function validateReferences(params: ValidateReferencesParams) { + const { objects, retries } = params; const objectsToSkip = getObjectsToSkip(retries); const errorMap: { [key: string]: SavedObjectsImportFailure } = {}; - const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys( - savedObjects, - savedObjectsClient, - namespace, - retries - ); + const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys(params); // Filter out objects with missing references, add to error object - savedObjects.forEach(({ type, id, references, attributes }) => { + objects.forEach(({ type, id, references, attributes }) => { if (objectsToSkip.has(`${type}:${id}`)) { // skip objects with retries that have specified `ignoreMissingReferences` return; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.ts new file mode 100644 index 00000000000000..3cf4de850f4df7 --- /dev/null +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.mock.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { checkReferenceOrigins } from './lib/check_reference_origins'; +import type { validateRetries } from './lib/validate_retries'; +import type { createObjectsFilter } from './lib/create_objects_filter'; +import type { collectSavedObjects } from './lib/collect_saved_objects'; +import type { regenerateIds } from './lib/regenerate_ids'; +import type { validateReferences } from './lib/validate_references'; +import type { checkConflicts } from './lib/check_conflicts'; +import type { getImportStateMapForRetries } from './lib/get_import_state_map_for_retries'; +import type { splitOverwrites } from './lib/split_overwrites'; +import type { createSavedObjects } from './lib/create_saved_objects'; +import type { executeImportHooks } from './lib/execute_import_hooks'; + +export const mockCheckReferenceOrigins = jest.fn() as jest.MockedFunction< + typeof checkReferenceOrigins +>; +jest.mock('./lib/check_reference_origins', () => ({ + checkReferenceOrigins: mockCheckReferenceOrigins, +})); + +export const mockValidateRetries = jest.fn() as jest.MockedFunction; +jest.mock('./lib/validate_retries', () => ({ + validateRetries: mockValidateRetries, +})); + +export const mockCreateObjectsFilter = jest.fn() as jest.MockedFunction; +jest.mock('./lib/create_objects_filter', () => ({ + createObjectsFilter: mockCreateObjectsFilter, +})); + +export const mockCollectSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/collect_saved_objects', () => ({ + collectSavedObjects: mockCollectSavedObjects, +})); + +export const mockRegenerateIds = jest.fn() as jest.MockedFunction; +jest.mock('./lib/regenerate_ids', () => ({ + regenerateIds: mockRegenerateIds, +})); + +export const mockValidateReferences = jest.fn() as jest.MockedFunction; +jest.mock('./lib/validate_references', () => ({ + validateReferences: mockValidateReferences, +})); + +export const mockCheckConflicts = jest.fn() as jest.MockedFunction; +jest.mock('./lib/check_conflicts', () => ({ + checkConflicts: mockCheckConflicts, +})); + +export const mockGetImportStateMapForRetries = jest.fn() as jest.MockedFunction< + typeof getImportStateMapForRetries +>; +jest.mock('./lib/get_import_state_map_for_retries', () => ({ + getImportStateMapForRetries: mockGetImportStateMapForRetries, +})); + +export const mockSplitOverwrites = jest.fn() as jest.MockedFunction; +jest.mock('./lib/split_overwrites', () => ({ + splitOverwrites: mockSplitOverwrites, +})); + +export const mockCreateSavedObjects = jest.fn() as jest.MockedFunction; +jest.mock('./lib/create_saved_objects', () => ({ + createSavedObjects: mockCreateSavedObjects, +})); + +export const mockExecuteImportHooks = jest.fn() as jest.MockedFunction; +jest.mock('./lib/execute_import_hooks', () => ({ + executeImportHooks: mockExecuteImportHooks, +})); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index d7d7544baafcb5..d950545de54f92 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -6,6 +6,20 @@ * Side Public License, v 1. */ +import { + mockCheckReferenceOrigins, + mockValidateRetries, + mockCreateObjectsFilter, + mockCollectSavedObjects, + mockRegenerateIds, + mockValidateReferences, + mockCheckConflicts, + mockGetImportStateMapForRetries, + mockSplitOverwrites, + mockCreateSavedObjects, + mockExecuteImportHooks, +} from './resolve_import_errors.test.mock'; + import { Readable } from 'stream'; import { v4 as uuidv4 } from 'uuid'; import { @@ -25,58 +39,32 @@ import { ResolveSavedObjectsImportErrorsOptions, } from './resolve_import_errors'; -import { - validateRetries, - collectSavedObjects, - regenerateIds, - validateReferences, - checkConflicts, - getImportIdMapForRetries, - splitOverwrites, - createSavedObjects, - createObjectsFilter, - executeImportHooks, -} from './lib'; - -jest.mock('./lib/validate_retries'); -jest.mock('./lib/create_objects_filter'); -jest.mock('./lib/collect_saved_objects'); -jest.mock('./lib/regenerate_ids'); -jest.mock('./lib/validate_references'); -jest.mock('./lib/check_conflicts'); -jest.mock('./lib/check_origin_conflicts'); -jest.mock('./lib/split_overwrites'); -jest.mock('./lib/create_saved_objects'); -jest.mock('./lib/execute_import_hooks'); - -const getMockFn = any, U>(fn: (...args: Parameters) => U) => - fn as jest.MockedFunction<(...args: Parameters) => U>; - describe('#importSavedObjectsFromStream', () => { beforeEach(() => { jest.clearAllMocks(); // mock empty output of each of these mocked modules so the import doesn't throw an error - getMockFn(createObjectsFilter).mockReturnValue(() => false); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCreateObjectsFilter.mockReturnValue(() => false); + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), }); - getMockFn(regenerateIds).mockReturnValue(new Map()); - getMockFn(validateReferences).mockResolvedValue([]); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckReferenceOrigins.mockResolvedValue({ importStateMap: new Map() }); + mockRegenerateIds.mockReturnValue(new Map()); + mockValidateReferences.mockResolvedValue([]); + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue(new Map()); - getMockFn(splitOverwrites).mockReturnValue({ + mockGetImportStateMapForRetries.mockReturnValue(new Map()); + mockSplitOverwrites.mockReturnValue({ objectsToOverwrite: [], objectsToNotOverwrite: [], }); - getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); - getMockFn(executeImportHooks).mockResolvedValue([]); + mockCreateSavedObjects.mockResolvedValue({ errors: [], createdObjects: [] }); + mockExecuteImportHooks.mockResolvedValue([]); }); let readStream: Readable; @@ -153,7 +141,7 @@ describe('#importSavedObjectsFromStream', () => { /** * These tests use minimal mocks which don't look realistic, but are sufficient to exercise the code paths correctly. For example, for an * object to be imported successfully it would need to be obtained from `collectSavedObjects`, passed to `validateReferences`, passed to - * `getImportIdMapForRetries`, passed to `createSavedObjects`, and returned from that. However, for each of the tests below, we skip the + * `getImportStateMapForRetries`, passed to `createSavedObjects`, and returned from that. However, for each of the tests below, we skip the * intermediate steps in the interest of brevity. */ describe('module calls', () => { @@ -162,7 +150,7 @@ describe('#importSavedObjectsFromStream', () => { const options = setupOptions({ retries: [retry] }); await resolveSavedObjectsImportErrors(options); - expect(validateRetries).toHaveBeenCalledWith([retry]); + expect(mockValidateRetries).toHaveBeenCalledWith([retry]); }); test('creates objects filter', async () => { @@ -170,7 +158,7 @@ describe('#importSavedObjectsFromStream', () => { const options = setupOptions({ retries: [retry] }); await resolveSavedObjectsImportErrors(options); - expect(createObjectsFilter).toHaveBeenCalledWith([retry]); + expect(mockCreateObjectsFilter).toHaveBeenCalledWith([retry]); }); test('collects saved objects from stream', async () => { @@ -182,28 +170,62 @@ describe('#importSavedObjectsFromStream', () => { await resolveSavedObjectsImportErrors(options); expect(typeRegistry.getImportableAndExportableTypes).toHaveBeenCalled(); - const filter = getMockFn(createObjectsFilter).mock.results[0].value; - const collectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; - expect(collectSavedObjects).toHaveBeenCalledWith(collectSavedObjectsOptions); + const filter = mockCreateObjectsFilter.mock.results[0].value; + const mockCollectSavedObjectsOptions = { readStream, objectLimit, filter, supportedTypes }; + expect(mockCollectSavedObjects).toHaveBeenCalledWith(mockCollectSavedObjectsOptions); }); - test('validates references', async () => { + test('checks reference origins', async () => { const retries = [createRetry()]; const options = setupOptions({ retries }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + const importStateMap = new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]); + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap, }); await resolveSavedObjectsImportErrors(options); - expect(validateReferences).toHaveBeenCalledWith( + expect(mockCheckReferenceOrigins).toHaveBeenCalledWith({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + }); + + test('validates references', async () => { + const retries = [createRetry()]; + const options = setupOptions({ retries }); + const collectedObjects = [createObject()]; + mockCollectSavedObjects.mockResolvedValue({ + errors: [], collectedObjects, + importStateMap: new Map([ + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true }], + ]), + }); + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([[`foo:bar`, { isOnlyReference: true, id: 'baz' }]]), + }); + + await resolveSavedObjectsImportErrors(options); + expect(mockValidateReferences).toHaveBeenCalledWith({ + objects: collectedObjects, savedObjectsClient, namespace, - retries - ); + importStateMap: new Map([ + // This importStateMap is a combination of the other two + [`${collectedObjects[0].type}:${collectedObjects[0].id}`, {}], + [`foo:bar`, { isOnlyReference: true, id: 'baz' }], + ]), + retries, + }); }); test('execute import hooks', async () => { @@ -212,19 +234,19 @@ describe('#importSavedObjectsFromStream', () => { }; const options = setupOptions({ importHooks }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [], createdObjects: collectedObjects, }); await resolveSavedObjectsImportErrors(options); - expect(executeImportHooks).toHaveBeenCalledWith({ + expect(mockExecuteImportHooks).toHaveBeenCalledWith({ objects: collectedObjects, importHooks, }); @@ -239,23 +261,25 @@ describe('#importSavedObjectsFromStream', () => { }), ]; const options = setupOptions({ retries }); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects: [object], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); + // mockCheckReferenceOrigins returns an empty importStateMap by default await resolveSavedObjectsImportErrors(options); const objectWithReplacedReferences = { ...object, references: [{ ...object.references[0], id: 'def' }], }; - expect(validateReferences).toHaveBeenCalledWith( - [objectWithReplacedReferences], + expect(mockValidateReferences).toHaveBeenCalledWith({ + objects: [objectWithReplacedReferences], savedObjectsClient, namespace, - retries - ); + importStateMap: new Map(), // doesn't matter + retries, + }); }); test('checks conflicts', async () => { @@ -263,10 +287,10 @@ describe('#importSavedObjectsFromStream', () => { const retries = [createRetry()]; const options = setupOptions({ retries, createNewCopies }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); @@ -277,7 +301,7 @@ describe('#importSavedObjectsFromStream', () => { retries, createNewCopies, }; - expect(checkConflicts).toHaveBeenCalledWith(checkConflictsParams); + expect(mockCheckConflicts).toHaveBeenCalledWith(checkConflictsParams); }); test('gets import ID map for retries', async () => { @@ -285,76 +309,82 @@ describe('#importSavedObjectsFromStream', () => { const createNewCopies = Symbol() as unknown as boolean; const options = setupOptions({ retries, createNewCopies }); const filteredObjects = [createObject()]; - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects, - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); await resolveSavedObjectsImportErrors(options); - const getImportIdMapForRetriesParams = { objects: filteredObjects, retries, createNewCopies }; - expect(getImportIdMapForRetries).toHaveBeenCalledWith(getImportIdMapForRetriesParams); + const getImportStateMapForRetriesParams = { + objects: filteredObjects, + retries, + createNewCopies, + }; + expect(mockGetImportStateMapForRetries).toHaveBeenCalledWith( + getImportStateMapForRetriesParams + ); }); test('splits objects to overwrite from those not to overwrite', async () => { const retries = [createRetry()]; const options = setupOptions({ retries }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(splitOverwrites).toHaveBeenCalledWith(collectedObjects, retries); + expect(mockSplitOverwrites).toHaveBeenCalledWith(collectedObjects, retries); }); describe('with createNewCopies disabled', () => { test('does not regenerate object IDs', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(regenerateIds).not.toHaveBeenCalled(); + expect(mockRegenerateIds).not.toHaveBeenCalled(); }); test('creates saved objects', async () => { const options = setupOptions(); const errors = [createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], // doesn't matter - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(checkConflicts).mockResolvedValue({ + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map([['foo', { id: 'someId' }]]), + importStateMap: new Map([['foo', { destinationId: 'someId' }]]), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue( + mockGetImportStateMapForRetries.mockReturnValue( new Map([ - ['foo', { id: 'newId' }], - ['bar', { id: 'anotherNewId' }], + ['foo', { destinationId: 'newId' }], + ['bar', { destinationId: 'anotherNewId' }], ]) ); - const importIdMap = new Map([ - ['foo', { id: 'someId' }], - ['bar', { id: 'anotherNewId' }], + const importStateMap = new Map([ + ['foo', { destinationId: 'someId' }], + ['bar', { destinationId: 'anotherNewId' }], ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; - getMockFn(splitOverwrites).mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ - errors: [createError()], // this error will NOT be passed to the second `createSavedObjects` call + mockSplitOverwrites.mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); + mockCreateSavedObjects.mockResolvedValueOnce({ + errors: [createError()], // this error will NOT be passed to the second `mockCreateSavedObjects` call createdObjects: [], }); @@ -362,15 +392,15 @@ describe('#importSavedObjectsFromStream', () => { const partialCreateSavedObjectsParams = { accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, namespace, }; - expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(1, { ...partialCreateSavedObjectsParams, objects: objectsToOverwrite, overwrite: true, }); - expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(2, { ...partialCreateSavedObjectsParams, objects: objectsToNotOverwrite, }); @@ -381,54 +411,65 @@ describe('#importSavedObjectsFromStream', () => { test('regenerates object IDs', async () => { const options = setupOptions({ createNewCopies: true }); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); await resolveSavedObjectsImportErrors(options); - expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + expect(mockRegenerateIds).toHaveBeenCalledWith(collectedObjects); }); test('creates saved objects', async () => { const options = setupOptions({ createNewCopies: true }); const errors = [createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], // doesn't matter - importIdMap: new Map(), // doesn't matter + importStateMap: new Map([ + ['foo', {}], + ['bar', {}], + ['baz', {}], + ['qux', { isOnlyReference: true }], + ]), }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(regenerateIds).mockReturnValue( + mockCheckReferenceOrigins.mockResolvedValue({ + importStateMap: new Map([['qux', { isOnlyReference: true, destinationId: 'newId1' }]]), + }); + mockValidateReferences.mockResolvedValue([errors[1]]); + mockRegenerateIds.mockReturnValue( new Map([ - ['foo', { id: 'randomId1' }], - ['bar', { id: 'randomId2' }], - ['baz', { id: 'randomId3' }], + ['foo', { destinationId: 'randomId1' }], + ['bar', { destinationId: 'randomId2' }], + ['baz', { destinationId: 'randomId3' }], ]) ); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [errors[2]], filteredObjects: [], - importIdMap: new Map([['bar', { id: 'someId' }]]), + importStateMap: new Map([['bar', { destinationId: 'someId' }]]), pendingOverwrites: new Set(), // not used by resolveImportErrors, but is a required return type }); - getMockFn(getImportIdMapForRetries).mockReturnValue( + mockGetImportStateMapForRetries.mockReturnValue( new Map([ - ['bar', { id: 'newId' }], - ['baz', { id: 'anotherNewId' }], + ['bar', { destinationId: 'newId2' }], // this is overridden by the checkConflicts result + ['baz', { destinationId: 'newId3' }], ]) ); - const importIdMap = new Map([ - ['foo', { id: 'randomId1' }], - ['bar', { id: 'someId' }], - ['baz', { id: 'anotherNewId' }], + + // assert that the importStateMap is correctly composed of the results from the five modules + const importStateMap = new Map([ + ['foo', { destinationId: 'randomId1' }], + ['bar', { destinationId: 'someId' }], + ['baz', { destinationId: 'newId3' }], + ['qux', { isOnlyReference: true, destinationId: 'newId1' }], ]); const objectsToOverwrite = [createObject()]; const objectsToNotOverwrite = [createObject()]; - getMockFn(splitOverwrites).mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ - errors: [createError()], // this error will NOT be passed to the second `createSavedObjects` call + mockSplitOverwrites.mockReturnValue({ objectsToOverwrite, objectsToNotOverwrite }); + mockCreateSavedObjects.mockResolvedValueOnce({ + errors: [createError()], // this error will NOT be passed to the second `mockCreateSavedObjects` call createdObjects: [], }); @@ -436,15 +477,15 @@ describe('#importSavedObjectsFromStream', () => { const partialCreateSavedObjectsParams = { accumulatedErrors: errors, savedObjectsClient, - importIdMap, + importStateMap, namespace, }; - expect(createSavedObjects).toHaveBeenNthCalledWith(1, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(1, { ...partialCreateSavedObjectsParams, objects: objectsToOverwrite, overwrite: true, }); - expect(createSavedObjects).toHaveBeenNthCalledWith(2, { + expect(mockCreateSavedObjects).toHaveBeenNthCalledWith(2, { ...partialCreateSavedObjectsParams, objects: objectsToNotOverwrite, }); @@ -462,10 +503,10 @@ describe('#importSavedObjectsFromStream', () => { test('returns success=false if an error occurred', async () => { const options = setupOptions(); - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [createError()], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); const result = await resolveSavedObjectsImportErrors(options); @@ -480,17 +521,17 @@ describe('#importSavedObjectsFromStream', () => { test('executes import hooks', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [], collectedObjects, - importIdMap: new Map(), + importStateMap: new Map(), }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [], createdObjects: collectedObjects, }); const warnings: SavedObjectsImportWarning[] = [{ type: 'simple', message: 'foo' }]; - getMockFn(executeImportHooks).mockResolvedValue(warnings); + mockExecuteImportHooks.mockResolvedValue(warnings); const result = await resolveSavedObjectsImportErrors(options); @@ -507,11 +548,11 @@ describe('#importSavedObjectsFromStream', () => { const tmp = createObject(); const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId; this is a new copy - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [error1], createdObjects: [obj1], }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [error2], createdObjects: [obj2, obj3], }); @@ -569,13 +610,13 @@ describe('#importSavedObjectsFromStream', () => { }, }); - getMockFn(checkConflicts).mockResolvedValue({ + mockCheckConflicts.mockResolvedValue({ errors: [], filteredObjects: [], - importIdMap: new Map(), + importStateMap: new Map(), pendingOverwrites: new Set(), }); - getMockFn(createSavedObjects) + mockCreateSavedObjects .mockResolvedValueOnce({ errors: [], createdObjects: [obj1, obj2] }) .mockResolvedValueOnce({ errors: [], createdObjects: [] }); @@ -607,17 +648,17 @@ describe('#importSavedObjectsFromStream', () => { test('accumulates multiple errors', async () => { const options = setupOptions(); const errors = [createError(), createError(), createError(), createError()]; - getMockFn(collectSavedObjects).mockResolvedValue({ + mockCollectSavedObjects.mockResolvedValue({ errors: [errors[0]], collectedObjects: [], - importIdMap: new Map(), // doesn't matter + importStateMap: new Map(), // doesn't matter }); - getMockFn(validateReferences).mockResolvedValue([errors[1]]); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockValidateReferences.mockResolvedValue([errors[1]]); + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [errors[2]], createdObjects: [], }); - getMockFn(createSavedObjects).mockResolvedValueOnce({ + mockCreateSavedObjects.mockResolvedValueOnce({ errors: [errors[3]], createdObjects: [], }); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 25382965e845bd..61fbde5bb9d874 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -20,10 +20,11 @@ import { createObjectsFilter, splitOverwrites, regenerateIds, + checkReferenceOrigins, validateReferences, validateRetries, createSavedObjects, - getImportIdMapForRetries, + getImportStateMapForRetries, checkConflicts, executeImportHooks, } from './lib'; @@ -71,20 +72,20 @@ export async function resolveSavedObjectsImportErrors({ let successCount = 0; let errorAccumulator: SavedObjectsImportFailure[] = []; - let importIdMap: Map = new Map(); const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); const filter = createObjectsFilter(retries); // Get the objects to resolve errors - const { errors: collectorErrors, collectedObjects: objectsToResolve } = await collectSavedObjects( - { - readStream, - objectLimit, - filter, - supportedTypes, - } - ); - errorAccumulator = [...errorAccumulator, ...collectorErrors]; + const collectSavedObjectsResult = await collectSavedObjects({ + readStream, + objectLimit, + filter, + supportedTypes, + }); + // Map of all IDs for objects that we are attempting to import, and any references that are not included in the read stream; + // each value is empty by default + let importStateMap = collectSavedObjectsResult.importStateMap; + errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; // Create a map of references to replace for each object to avoid iterating through // retries for every object to resolve @@ -98,7 +99,7 @@ export async function resolveSavedObjectsImportErrors({ } // Replace references - for (const savedObject of objectsToResolve) { + for (const savedObject of collectSavedObjectsResult.collectedObjects) { const refMap = retriesReferencesMap.get(`${savedObject.type}:${savedObject.id}`); if (!refMap) { continue; @@ -106,28 +107,42 @@ export async function resolveSavedObjectsImportErrors({ for (const reference of savedObject.references || []) { if (refMap[`${reference.type}:${reference.id}`]) { reference.id = refMap[`${reference.type}:${reference.id}`]; + // Any reference ID changed here will supersede the results of checkReferenceOrigins below; this is intentional. } } } + // Check any references that aren't included in the import file and retries, to see if they have a match with a different origin + const checkReferenceOriginsResult = await checkReferenceOrigins({ + savedObjectsClient, + typeRegistry, + namespace, + importStateMap, + }); + importStateMap = new Map([...importStateMap, ...checkReferenceOriginsResult.importStateMap]); + // Validate references - const validateReferencesResult = await validateReferences( - objectsToResolve, + const validateReferencesResult = await validateReferences({ + objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, - retries - ); + importStateMap, + retries, + }); errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { // In case any missing reference errors were resolved, ensure that we regenerate those object IDs as well // This is because a retry to resolve a missing reference error may not necessarily specify a destinationId - importIdMap = regenerateIds(objectsToResolve); + importStateMap = new Map([ + ...importStateMap, // preserve any entries for references that aren't included in collectedObjects + ...regenerateIds(collectSavedObjectsResult.collectedObjects), + ]); } // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { - objects: objectsToResolve, + objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, retries, @@ -137,16 +152,16 @@ export async function resolveSavedObjectsImportErrors({ errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; // Check multi-namespace object types for regular conflicts and ambiguous conflicts - const getImportIdMapForRetriesParams = { + const getImportStateMapForRetriesParams = { objects: checkConflictsResult.filteredObjects, retries, createNewCopies, }; - const importIdMapForRetries = getImportIdMapForRetries(getImportIdMapForRetriesParams); - importIdMap = new Map([ - ...importIdMap, - ...importIdMapForRetries, - ...checkConflictsResult.importIdMap, // this importIdMap takes precedence over the others + const importStateMapForRetries = getImportStateMapForRetries(getImportStateMapForRetriesParams); + importStateMap = new Map([ + ...importStateMap, + ...importStateMapForRetries, + ...checkConflictsResult.importStateMap, // this importStateMap takes precedence over the others ]); // Bulk create in two batches, overwrites and non-overwrites @@ -161,7 +176,7 @@ export async function resolveSavedObjectsImportErrors({ objects, accumulatedErrors, savedObjectsClient, - importIdMap, + importStateMap, namespace, overwrite, }; @@ -191,7 +206,10 @@ export async function resolveSavedObjectsImportErrors({ }), ]; }; - const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites(objectsToResolve, retries); + const { objectsToOverwrite, objectsToNotOverwrite } = splitOverwrites( + collectSavedObjectsResult.collectedObjects, + retries + ); await bulkCreateObjects(objectsToOverwrite, true); await bulkCreateObjects(objectsToNotOverwrite); diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 64c79b34243767..d5f994a3e01eaf 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from '../../import/lib/__mocks__'; +jest.mock('uuid'); + import supertest from 'supertest'; import { registerImportRoute } from '../import'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; @@ -19,7 +20,6 @@ import { SavedObjectsErrorHelpers, SavedObjectsImporter } from '../..'; type SetupServerReturn = Awaited>; -const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; let coreUsageStatsClient: jest.Mocked; @@ -46,8 +46,6 @@ describe(`POST ${URL}`, () => { }; beforeEach(async () => { - mockUuidv4.mockReset(); - mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) @@ -487,7 +485,9 @@ describe(`POST ${URL}`, () => { describe('createNewCopies enabled', () => { it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { - mockUuidv4 + const mockUuid = jest.requireMock('uuid'); + mockUuid.v4 = jest + .fn() .mockReturnValueOnce('foo') // a uuid.v4() is generated for the request.id .mockReturnValueOnce('foo') // another uuid.v4() is used for the request.uuid .mockReturnValueOnce('new-id-1') diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index 99139d82821c5b..a7f1b1e304aa7a 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { mockUuidv4 } from '../../import/lib/__mocks__'; +jest.mock('uuid'); + import supertest from 'supertest'; import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; @@ -19,7 +20,6 @@ import { SavedObjectsImporter } from '../..'; type SetupServerReturn = Awaited>; -const { v4: uuidv4 } = jest.requireActual('uuid'); const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; let coreUsageStatsClient: jest.Mocked; @@ -51,8 +51,6 @@ describe(`POST ${URL}`, () => { }; beforeEach(async () => { - mockUuidv4.mockReset(); - mockUuidv4.mockImplementation(() => uuidv4()); ({ server, httpSetup, handlerContext } = await setupServer()); handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) @@ -335,7 +333,8 @@ describe(`POST ${URL}`, () => { describe('createNewCopies enabled', () => { it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { - mockUuidv4.mockReturnValue('new-id-1'); + const mockUuid = jest.requireMock('uuid'); + mockUuid.v4 = jest.fn().mockReturnValue('new-id-1'); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); const obj1 = { type: 'visualization', diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index e7d2c630fc130d..671072999d90a6 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -710,3 +710,60 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "id": "index-pattern:inbound-reference-origin-match-1-newId", + "index": ".kibana", + "source": { + "originId": "inbound-reference-origin-match-1", + "index-pattern": { + "title": "This is used to test if an imported object with a reference to this originId will be remapped properly" + }, + "namespaces": ["*"], + "type": "index-pattern", + "migrationVersion": { "index-pattern": "8.0.0" }, + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:inbound-reference-origin-match-2a", + "index": ".kibana", + "source": { + "originId": "inbound-reference-origin-match-2", + "index-pattern": { + "title": "This is used to test if an imported object with a reference to this originId will *not* be remapped" + }, + "namespaces": ["*"], + "type": "index-pattern", + "migrationVersion": { "index-pattern": "8.0.0" }, + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:inbound-reference-origin-match-2b", + "index": ".kibana", + "source": { + "originId": "inbound-reference-origin-match-2", + "index-pattern": { + "title": "This is used to test if an imported object with a reference to this originId will *not* be remapped" + }, + "namespaces": ["*"], + "type": "index-pattern", + "migrationVersion": { "index-pattern": "8.0.0" }, + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index 04e0f3c41ed872..58b323e86d607b 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -8,23 +8,34 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import type { Client } from '@elastic/elasticsearch'; +import type { SavedObjectReference } from 'src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; export interface ImportTestDefinition extends TestDefinition { - request: Array<{ type: string; id: string; originId?: string }>; + request: Array<{ + type: string; + id: string; + originId?: string; + references?: SavedObjectReference[]; + }>; overwrite: boolean; createNewCopies: boolean; } export type ImportTestSuite = TestSuite; -export interface ImportTestCase extends TestCase { +export type FailureType = + | 'unsupported_type' + | 'conflict' + | 'ambiguous_conflict' + | 'missing_references'; +export interface ImportTestCase extends Omit { originId?: string; expectedNewId?: string; + references?: SavedObjectReference[]; successParam?: string; - failure?: 400 | 409; // only used for permitted response case - fail409Param?: string; + failureType?: FailureType; // only used for permitted response case } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake @@ -37,33 +48,60 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; // * id: conflict_3 // * id: conflict_4a, originId: conflict_4 // using the seven conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios -const CID = 'conflict_'; +const { HIDDEN, ...REMAINING_CASES } = CASES; export const TEST_CASES: Record = Object.freeze({ - ...CASES, - CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1` }), - CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1a`, originId: `${CID}1` }), - CONFLICT_1B_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1b`, originId: `${CID}1` }), - CONFLICT_2C_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2c`, originId: `${CID}2` }), - CONFLICT_2D_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2d`, originId: `${CID}2` }), + ...REMAINING_CASES, + CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1` }), + CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1a`, originId: `conflict_1` }), + CONFLICT_1B_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1b`, originId: `conflict_1` }), + CONFLICT_2A_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_2a`, originId: `conflict_2` }), + CONFLICT_2C_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_2c`, originId: `conflict_2` }), + CONFLICT_2D_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_2d`, originId: `conflict_2` }), CONFLICT_3A_OBJ: Object.freeze({ type: 'sharedtype', - id: `${CID}3a`, - originId: `${CID}3`, - expectedNewId: `${CID}3`, + id: `conflict_3a`, + originId: `conflict_3`, + expectedNewId: `conflict_3`, + }), + CONFLICT_4_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_4`, + expectedNewId: `conflict_4a`, }), - CONFLICT_4_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}4`, expectedNewId: `${CID}4a` }), NEW_SINGLE_NAMESPACE_OBJ: Object.freeze({ type: 'isolatedtype', id: 'new-isolatedtype-id' }), NEW_MULTI_NAMESPACE_OBJ: Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }), NEW_NAMESPACE_AGNOSTIC_OBJ: Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }), }); +export const SPECIAL_TEST_CASES: Record = Object.freeze({ + HIDDEN, + OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of an index pattern that does exist. + // We use index patterns because they are one of the few reference types that are validated, so the import will fail if the reference + // is broken. + // This import is designed to succeed because there is exactly one origin match for its reference, and that reference will be changed to + // match the index pattern's new ID. + type: 'sharedtype', + id: 'outbound-reference-origin-match-1', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-1' }], + }), + OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of two index patterns that do exist. + // This import is designed to fail because there are two origin matches for its reference, and we can't currently handle ambiguous + // destinations for reference origin matches. + type: 'sharedtype', + id: 'outbound-reference-origin-match-2', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-2' }], + }), +}); /** * Test cases have additional properties that we don't want to send in HTTP Requests */ -const createRequest = ({ type, id, originId }: ImportTestCase) => ({ +const createRequest = ({ type, id, originId, references }: ImportTestCase) => ({ type, id, ...(originId && { originId }), + ...(references && { references }), }); const getConflictDest = (id: string) => ({ @@ -72,8 +110,20 @@ const getConflictDest = (id: string) => ({ updatedAt: '2017-09-21T18:59:16.270Z', }); +export const importTestCaseFailures = { + failUnsupportedType: (condition?: boolean): { failureType?: 'unsupported_type' } => + condition !== false ? { failureType: 'unsupported_type' } : {}, + failConflict: (condition?: boolean): { failureType?: 'conflict' } => + condition !== false ? { failureType: 'conflict' } : {}, + failAmbiguousConflict: (condition?: boolean): { failureType?: 'ambiguous_conflict' } => + condition !== false ? { failureType: 'ambiguous_conflict' } : {}, + failMissingReferences: (condition?: boolean): { failureType?: 'missing_references' } => + condition !== false ? { failureType: 'missing_references' } : {}, +}; + export function importTestSuiteFactory(es: Client, esArchiver: any, supertest: SuperTest) { - const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_create'); + const expectSavedObjectForbidden = (action: string, typeOrTypes: string | string[]) => + expectResponses.forbiddenTypes(action)(typeOrTypes); const expectResponseBody = ( testCases: ImportTestCase | ImportTestCase[], @@ -87,12 +137,12 @@ export function importTestSuiteFactory(es: Client, esArchiver: any, supertest: S const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; if (statusCode === 403) { const types = testCaseArray.map((x) => x.type); - await expectSavedObjectForbidden(types)(response); + await expectSavedObjectForbidden('bulk_create', types)(response); } else { // permitted const { success, successCount, successResults, errors } = response.body; - const expectedSuccesses = testCaseArray.filter((x) => !x.failure); - const expectedFailures = testCaseArray.filter((x) => x.failure); + const expectedSuccesses = testCaseArray.filter((x) => !x.failureType); + const expectedFailures = testCaseArray.filter((x) => x.failureType); expect(success).to.eql(expectedFailures.length === 0); expect(successCount).to.eql(expectedSuccesses.length); if (expectedFailures.length) { @@ -147,30 +197,37 @@ export function importTestSuiteFactory(es: Client, esArchiver: any, supertest: S } } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure, fail409Param, expectedNewId } = expectedFailures[i]; + const { type, id, failureType, expectedNewId } = expectedFailures[i]; // we don't know the order of the returned errors; search for each one const object = (errors as Array>).find( (x) => x.type === type && x.id === id ); expect(object).not.to.be(undefined); - if (failure === 400) { - expect(object!.error).to.eql({ type: 'unsupported_type' }); - } else { - // 409 - let error: Record = { - type: 'conflict', - ...(expectedNewId && { destinationId: expectedNewId }), - }; - if (fail409Param === 'ambiguous_conflict_2c') { - // "ambiguous destination" conflict - error = { - type: 'ambiguous_conflict', - // response destinations should be sorted by updatedAt in descending order, then ID in ascending order - destinations: [getConflictDest(`${CID}2a`), getConflictDest(`${CID}2b`)], - }; - } - expect(object!.error).to.eql(error); + const expectedError: Record = { type: failureType }; + switch (failureType!) { + case 'unsupported_type': + break; + case 'conflict': + if (expectedNewId) { + expectedError.destinationId = expectedNewId; + } + break; + case 'ambiguous_conflict': + // We only have one test case for ambiguous conflicts, so these destination IDs are hardcoded below for simplicity. + // Response destinations should be sorted by updatedAt in descending order, then ID in ascending order. + expectedError.destinations = [ + getConflictDest(`conflict_2a`), + getConflictDest(`conflict_2b`), + ]; + break; + case 'missing_references': + // We only have one test case for missing references, so this reference is hardcoded below for simplicity. + expectedError.references = [ + { type: 'index-pattern', id: 'inbound-reference-origin-match-2' }, + ]; + break; } + expect(object!.error).to.eql(expectedError); } } }; diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index 6de4e6dfbdcfad..cd4123433cb8ba 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import type { Client } from '@elastic/elasticsearch'; +import type { SavedObjectReference, SavedObjectsImportRetry } from 'src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; @@ -15,18 +16,32 @@ import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/ export interface ResolveImportErrorsTestDefinition extends TestDefinition { request: { - objects: Array<{ type: string; id: string; originId?: string }>; - retries: Array<{ type: string; id: string; overwrite: boolean; destinationId?: string }>; + objects: Array<{ + type: string; + id: string; + originId?: string; + references?: SavedObjectReference[]; + }>; + retries: Array<{ + type: string; + id: string; + overwrite: boolean; + destinationId?: string; + replaceReferences?: SavedObjectsImportRetry['replaceReferences']; + }>; }; overwrite: boolean; createNewCopies: boolean; } export type ResolveImportErrorsTestSuite = TestSuite; -export interface ResolveImportErrorsTestCase extends TestCase { +export type FailureType = 'unsupported_type' | 'conflict'; +export interface ResolveImportErrorsTestCase extends Omit { originId?: string; expectedNewId?: string; + references?: SavedObjectReference[]; + replaceReferences?: SavedObjectsImportRetry['replaceReferences']; successParam?: string; - failure?: 400 | 409; // only used for permitted response case + failureType?: FailureType; // only used for permitted response case } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake @@ -39,8 +54,9 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; // * id: conflict_3 // * id: conflict_4a, originId: conflict_4 // using the five conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios +const { HIDDEN, ...REMAINING_CASES } = CASES; export const TEST_CASES: Record = Object.freeze({ - ...CASES, + ...REMAINING_CASES, CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `conflict_1a`, @@ -71,32 +87,78 @@ export const TEST_CASES: Record = Object.fr expectedNewId: `conflict_4a`, }), }); +export const SPECIAL_TEST_CASES: Record = Object.freeze({ + HIDDEN, + OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of an index pattern that does exist. + // We use index patterns because they are one of the few reference types that are validated, so the import will fail if the reference + // is broken. + // This import is designed to succeed because there is exactly one origin match for its reference, and that reference will be changed to + // match the index pattern's new ID. + type: 'sharedtype', + id: 'outbound-reference-origin-match-1', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-1' }], + }), + OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ: Object.freeze({ + // This object does not already exist, but it has a reference to the originId of two index patterns that do exist. + // This import would normally fail because there are two origin matches for its reference, and we can't currently handle ambiguous + // destinations for reference origin matches. + // However, when retrying we can specify which reference(s) should be replaced. + type: 'sharedtype', + id: 'outbound-reference-origin-match-2', + references: [{ name: '1', type: 'index-pattern', id: 'inbound-reference-origin-match-2' }], + replaceReferences: [ + { + type: 'index-pattern', + from: 'inbound-reference-origin-match-2', + to: 'inbound-reference-origin-match-2a', + }, + ], + }), +}); /** * Test cases have additional properties that we don't want to send in HTTP Requests */ const createRequest = ( - { type, id, originId, expectedNewId, successParam }: ResolveImportErrorsTestCase, + { + type, + id, + originId, + expectedNewId, + references, + replaceReferences, + successParam, + }: ResolveImportErrorsTestCase, overwrite: boolean ): ResolveImportErrorsTestDefinition['request'] => ({ - objects: [{ type, id, ...(originId && { originId }) }], + objects: [{ type, id, ...(originId && { originId }), ...(references && { references }) }], retries: [ { type, id, overwrite, ...(expectedNewId && { destinationId: expectedNewId }), + ...(replaceReferences && { replaceReferences }), ...(successParam === 'createNewCopy' && { createNewCopy: true }), }, ], }); +export const resolveImportErrorsTestCaseFailures = { + failUnsupportedType: (condition?: boolean): { failureType?: 'unsupported_type' } => + condition !== false ? { failureType: 'unsupported_type' } : {}, + failConflict: (condition?: boolean): { failureType?: 'conflict' } => + condition !== false ? { failureType: 'conflict' } : {}, +}; + export function resolveImportErrorsTestSuiteFactory( es: Client, esArchiver: any, supertest: SuperTest ) { - const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_create'); + const expectSavedObjectForbidden = (action: string, typeOrTypes: string | string[]) => + expectResponses.forbiddenTypes(action)(typeOrTypes); const expectResponseBody = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], @@ -110,12 +172,12 @@ export function resolveImportErrorsTestSuiteFactory( const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; if (statusCode === 403) { const types = testCaseArray.map((x) => x.type); - await expectSavedObjectForbidden(types)(response); + await expectSavedObjectForbidden('bulk_create', types)(response); } else { // permitted const { success, successCount, successResults, errors } = response.body; - const expectedSuccesses = testCaseArray.filter((x) => !x.failure); - const expectedFailures = testCaseArray.filter((x) => x.failure); + const expectedSuccesses = testCaseArray.filter((x) => !x.failureType); + const expectedFailures = testCaseArray.filter((x) => x.failureType); expect(success).to.eql(expectedFailures.length === 0); expect(successCount).to.eql(expectedSuccesses.length); if (expectedFailures.length) { @@ -168,21 +230,23 @@ export function resolveImportErrorsTestSuiteFactory( } } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure, expectedNewId } = expectedFailures[i]; + const { type, id, failureType, expectedNewId } = expectedFailures[i]; // we don't know the order of the returned errors; search for each one const object = (errors as Array>).find( (x) => x.type === type && x.id === id ); expect(object).not.to.be(undefined); - if (failure === 400) { - expect(object!.error).to.eql({ type: 'unsupported_type' }); - } else { - // 409 - expect(object!.error).to.eql({ - type: 'conflict', - ...(expectedNewId && { destinationId: expectedNewId }), - }); + const expectedError: Record = { type: failureType }; + switch (failureType!) { + case 'unsupported_type': + break; + case 'conflict': + if (expectedNewId) { + expectedError.destinationId = expectedNewId; + } + break; } + expect(object!.error).to.eql(expectedError); } } }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 1992dd6fea2248..b1f1776a7c2f17 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -6,12 +6,14 @@ */ import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { importTestSuiteFactory, + importTestCaseFailures, TEST_CASES as CASES, + SPECIAL_TEST_CASES, ImportTestDefinition, } from '../../common/suites/import'; @@ -20,21 +22,23 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict, failAmbiguousConflict, failMissingReferences } = + importTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); -const ambiguousConflict = (suffix: string) => ({ - failure: 409 as 409, - fail409Param: `ambiguous_conflict_${suffix}`, -}); const createNewCopiesTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); - const importable = cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })); - const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const importable = Object.entries(CASES).map(([, val]) => ({ + ...val, + successParam: 'createNewCopies', + })); + const nonImportable = [{ ...CASES.HIDDEN, ...failUnsupportedType() }]; // unsupported_type is an "unresolvable" error + // Other special test cases are excluded because they can result in "resolvable" errors that will prevent the rest of the objects from + // being created. The test suite assumes that when the createNewCopies option is enabled, all non-error results are actually created, + // and it makes assertions based on that. const all = [...importable, ...nonImportable]; return { importable, nonImportable, all }; }; @@ -46,64 +50,92 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...failConflict(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...failConflict(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1NonImportable = [{ ...CASES.HIDDEN, ...failUnsupportedType() }]; const group1All = group1Importable.concat(group1NonImportable); const group2 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes CASES.NEW_MULTI_NAMESPACE_OBJ, - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict ]; const group3 = [ // when overwrite=true, all of the objects in this group are errors, so we cannot check the created object attributes // grouping errors together simplifies the test suite code - { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict + { ...CASES.CONFLICT_2C_OBJ, ...failAmbiguousConflict() }, // "ambiguous destination" conflict ]; const group4 = [ + // This group needs to be executed *after* the previous test case, because those error assertions include metadata of the destinations, + // and *these* test cases would change that metadata. + { ...CASES.CONFLICT_2A_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict with 2a + { + // "inexact match" conflict with 2b (since 2a already has a conflict source, this is not an ambiguous destination conflict) + ...CASES.CONFLICT_2C_OBJ, + ...failConflict(!overwrite), + ...destinationId(), + expectedNewId: 'conflict_2b', + }, + ]; + const group5 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes - { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict + { ...CASES.CONFLICT_1_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - return { group1Importable, group1NonImportable, group1All, group2, group3, group4 }; + const refOrigins = [ + // One of these cases will always generate a missing_references error, which is an "unresolvable" error that stops any other objects + // from being created in the import. Other test cases can have assertions based on the created objects' attributes when the overwrite + // option is enabled, but these test cases are simply asserting pass/fail, so this group needs to be tested separately. + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ, ...failMissingReferences() }, + ]; + return { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + group5, + refOrigins, + }; }; export default function ({ getService }: FtrProviderContext) { @@ -121,48 +153,89 @@ export default function ({ getService }: FtrProviderContext) { if (createNewCopies) { const { importable, nonImportable, all } = createNewCopiesTestCases(); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + 'sharedtype', + 'sharecapabletype', + ]), + }), + ]; return { - unauthorized: [ - createTestDefinitions(importable, true, { createNewCopies, spaceId }), - createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), - createTestDefinitions(all, true, { - createNewCopies, - spaceId, - singleRequest, - responseBodyOverride: expectSavedObjectForbidden([ - 'globaltype', - 'isolatedtype', - 'sharedtype', - 'sharecapabletype', - ]), - }), - ].flat(), + unauthorizedRead: unauthorizedCommonTestDefinitions.flat(), + unauthorizedWrite: unauthorizedCommonTestDefinitions.flat(), authorized: createTestDefinitions(all, false, { createNewCopies, spaceId, singleRequest }), }; } - const { group1Importable, group1NonImportable, group1All, group2, group3, group4 } = - createTestCases(overwrite, spaceId); - return { - unauthorized: [ - createTestDefinitions(group1Importable, true, { overwrite, spaceId }), - createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group1All, true, { + const { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + group5, + refOrigins, + } = createTestCases(overwrite, spaceId); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + ]), + }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group5, true, { overwrite, spaceId, singleRequest }), + ]; + const unauthorizedReadTestDefinitions = [...unauthorizedCommonTestDefinitions]; + const unauthorizedWriteTestDefinitions = [...unauthorizedCommonTestDefinitions]; + const authorizedTestDefinitions = [ + createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group5, false, { overwrite, spaceId, singleRequest }), + ]; + if (!overwrite) { + // Only include this group of test cases if the overwrite option is not enabled + unauthorizedReadTestDefinitions.push( + createTestDefinitions(refOrigins, true, { overwrite, spaceId, singleRequest, - responseBodyOverride: expectSavedObjectForbidden(['globaltype', 'isolatedtype']), - }), - createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group3, true, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group4, true, { overwrite, spaceId, singleRequest }), - ].flat(), - authorized: [ - createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), - ].flat(), + responseBodyOverride: expectSavedObjectForbidden('bulk_get', ['index-pattern']), + }) + ); + unauthorizedWriteTestDefinitions.push( + createTestDefinitions(refOrigins, true, { + overwrite, + spaceId, + singleRequest, + }) + ); + authorizedTestDefinitions.push( + createTestDefinitions(refOrigins, false, { overwrite, spaceId, singleRequest }) + ); + } + return { + unauthorizedRead: unauthorizedReadTestDefinitions.flat(), + unauthorizedWrite: unauthorizedWriteTestDefinitions.flat(), + authorized: authorizedTestDefinitions.flat(), }; }; @@ -180,20 +253,20 @@ export default function ({ getService }: FtrProviderContext) { ? ' with createNewCopies enabled' : '' }`; - const { unauthorized, authorized } = createTests(overwrite, createNewCopies, spaceId); + const { unauthorizedRead, unauthorizedWrite, authorized } = createTests( + overwrite, + createNewCopies, + spaceId + ); const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - users.allAtOtherSpace, - ].forEach((user) => { - _addTests(user, unauthorized); + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { + _addTests(user, unauthorizedRead); + }); + [users.dualRead, users.readGlobally, users.readAtSpace].forEach((user) => { + _addTests(user, unauthorizedWrite); }); [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { _addTests(user, authorized); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index b59ae923250403..153c756ee64610 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -7,12 +7,14 @@ import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { resolveImportErrorsTestSuiteFactory, + resolveImportErrorsTestCaseFailures, TEST_CASES as CASES, + SPECIAL_TEST_CASES, ResolveImportErrorsTestDefinition, } from '../../common/suites/resolve_import_errors'; @@ -21,7 +23,7 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict } = resolveImportErrorsTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); @@ -29,13 +31,12 @@ const newCopy = () => ({ successParam: 'createNewCopy' }); const createNewCopiesTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); - const importable = cases.map(([, val]) => ({ + const importable = Object.entries(CASES).map(([, val]) => ({ ...val, successParam: 'createNewCopies', expectedNewId: uuidv4(), })); - const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const nonImportable = [{ ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }]; // unsupported_type is an "unresolvable" error const all = [...importable, ...nonImportable]; return { importable, nonImportable, all }; }; @@ -50,36 +51,36 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ? CASES.SINGLE_NAMESPACE_SPACE_1 : CASES.SINGLE_NAMESPACE_SPACE_2; const group1Importable = [ - { ...singleNamespaceObject, ...fail409(!overwrite) }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...singleNamespaceObject, ...failConflict(!overwrite) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, ]; - const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1NonImportable = [{ ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }]; const group1All = [...group1Importable, ...group1NonImportable]; const group2 = [ - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID @@ -87,11 +88,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that // `expectedDestinationId` already exists - { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + { ...CASES.CONFLICT_2C_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' ]; - return { group1Importable, group1NonImportable, group1All, group2 }; + const refOrigins = [ + // These are in a separate group because they will result in a different 403 error for users who are unauthorized to read + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ }, + ]; + return { group1Importable, group1NonImportable, group1All, group2, refOrigins }; }; export default function ({ getService }: FtrProviderContext) { @@ -107,45 +113,62 @@ export default function ({ getService }: FtrProviderContext) { if (createNewCopies) { const { importable, nonImportable, all } = createNewCopiesTestCases(); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + 'sharedtype', + 'sharecapabletype', + ]), + }), + ]; return { - unauthorized: [ - createTestDefinitions(importable, true, { createNewCopies, spaceId }), - createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), - createTestDefinitions(all, true, { - createNewCopies, - spaceId, - singleRequest, - responseBodyOverride: expectSavedObjectForbidden([ - 'globaltype', - 'isolatedtype', - 'sharedtype', - 'sharecapabletype', - ]), - }), - ].flat(), + unauthorizedRead: unauthorizedCommonTestDefinitions.flat(), + unauthorizedWrite: unauthorizedCommonTestDefinitions.flat(), authorized: createTestDefinitions(all, false, { createNewCopies, spaceId, singleRequest }), }; } - const { group1Importable, group1NonImportable, group1All, group2 } = createTestCases( - overwrite, - spaceId - ); + const { group1Importable, group1NonImportable, group1All, group2, refOrigins } = + createTestCases(overwrite, spaceId); + const unauthorizedCommonTestDefinitions = [ + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + spaceId, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden('bulk_create', [ + 'globaltype', + 'isolatedtype', + ]), + }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + ]; return { - unauthorized: [ - createTestDefinitions(group1Importable, true, { overwrite, spaceId }), - createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), - createTestDefinitions(group1All, true, { + unauthorizedRead: [ + ...unauthorizedCommonTestDefinitions, + createTestDefinitions(refOrigins, true, { overwrite, spaceId, singleRequest, - responseBodyOverride: expectSavedObjectForbidden(['globaltype', 'isolatedtype']), + responseBodyOverride: expectSavedObjectForbidden('bulk_get', ['index-pattern']), }), - createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + ].flat(), + unauthorizedWrite: [ + ...unauthorizedCommonTestDefinitions, + createTestDefinitions(refOrigins, true, { overwrite, spaceId, singleRequest }), ].flat(), authorized: [ createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(refOrigins, false, { overwrite, spaceId, singleRequest }), ].flat(), }; }; @@ -164,20 +187,20 @@ export default function ({ getService }: FtrProviderContext) { ? ' with createNewCopies enabled' : '' }`; - const { unauthorized, authorized } = createTests(overwrite, createNewCopies, spaceId); + const { unauthorizedRead, unauthorizedWrite, authorized } = createTests( + overwrite, + createNewCopies, + spaceId + ); const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - users.allAtOtherSpace, - ].forEach((user) => { - _addTests(user, unauthorized); + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { + _addTests(user, unauthorizedRead); + }); + [users.dualRead, users.readGlobally, users.readAtSpace].forEach((user) => { + _addTests(user, unauthorizedWrite); }); [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { _addTests(user, authorized); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index 910b51a92ed816..04631641904a02 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -6,77 +6,81 @@ */ import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/import'; +import { + importTestSuiteFactory, + importTestCaseFailures, + TEST_CASES as CASES, + SPECIAL_TEST_CASES, +} from '../../common/suites/import'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict, failAmbiguousConflict, failMissingReferences } = + importTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); -const ambiguousConflict = (suffix: string) => ({ - failure: 409 as 409, - fail409Param: `ambiguous_conflict_${suffix}`, -}); const createNewCopiesTestCases = () => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); return [ - ...cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })), - { ...CASES.HIDDEN, ...fail400() }, + ...Object.entries(CASES).map(([, val]) => ({ ...val, successParam: 'createNewCopies' })), + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, // unsupported_type is an "unresolvable" error + // Other special test cases are excluded because they can result in "resolvable" errors that will prevent the rest of the objects from + // being created. The test suite assumes that when the createNewCopies option is enabled, all non-error results are actually created, + // and it makes assertions based on that. ]; }; const createTestCases = (overwrite: boolean, spaceId: string) => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result const group1 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...failConflict(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...failConflict(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_MULTI_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, @@ -84,17 +88,36 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { const group2 = [ // when overwrite=true, all of the objects in this group are errors, so we cannot check the created object attributes // grouping errors together simplifies the test suite code - { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict + { ...CASES.CONFLICT_2C_OBJ, ...failAmbiguousConflict() }, // "ambiguous destination" conflict ]; const group3 = [ + // This group needs to be executed *after* the previous test case, because those error assertions include metadata of the destinations, + // and *these* test cases would change that metadata. + { ...CASES.CONFLICT_2A_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict with 2a + { + // "inexact match" conflict with 2b (since 2a already has a conflict source, this is not an ambiguous destination conflict) + ...CASES.CONFLICT_2C_OBJ, + ...failConflict(!overwrite), + ...destinationId(), + expectedNewId: 'conflict_2b', + }, + ]; + const group4 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes - { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict + { ...CASES.CONFLICT_1_OBJ, ...failConflict(!overwrite) }, // "exact match" conflict CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - return { group1, group2, group3 }; + const refOrigins = [ + // One of these cases will always generate a missing_references error, which is an "unresolvable" error that stops any other objects + // from being created in the import. Other test cases can have assertions based on the created objects' attributes when the overwrite + // option is enabled, but these test cases are simply asserting pass/fail, so this group needs to be tested separately. + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ, ...failMissingReferences() }, + ]; + return { group1, group2, group3, group4, refOrigins }; }; export default function ({ getService }: FtrProviderContext) { @@ -110,12 +133,18 @@ export default function ({ getService }: FtrProviderContext) { return createTestDefinitions(cases, false, { createNewCopies, spaceId, singleRequest }); } - const { group1, group2, group3 } = createTestCases(overwrite, spaceId); - return [ + const { group1, group2, group3, group4, refOrigins } = createTestCases(overwrite, spaceId); + const tests = [ createTestDefinitions(group1, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), - ].flat(); + createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), + ]; + if (!overwrite) { + // Only include this group of test cases if the overwrite option is not enabled + tests.push(createTestDefinitions(refOrigins, false, { overwrite, spaceId, singleRequest })); + } + return tests.flat(); }; describe('_import', () => { diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index 131335c421f00f..862e53d6e4663f 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -7,11 +7,13 @@ import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; -import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { resolveImportErrorsTestSuiteFactory, + resolveImportErrorsTestCaseFailures, TEST_CASES as CASES, + SPECIAL_TEST_CASES, } from '../../common/suites/resolve_import_errors'; const { @@ -19,27 +21,28 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail400, fail409 } = testCaseFailures; +const { failUnsupportedType, failConflict } = resolveImportErrorsTestCaseFailures; const destinationId = (condition?: boolean) => condition !== false ? { successParam: 'destinationId' } : {}; const newCopy = () => ({ successParam: 'createNewCopy' }); const createNewCopiesTestCases = () => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); return [ - ...cases.map(([, val]) => ({ + ...Object.entries(CASES).map(([, val]) => ({ ...val, successParam: 'createNewCopies', expectedNewId: uuidv4(), })), - { ...CASES.HIDDEN, ...fail400() }, + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, // unsupported_type is an "unresolvable" error + // Other special test cases are excluded here for simplicity and consistency with the resolveImportErrors "spaces_and_security" test + // suite and the import test suites. ]; }; const createTestCases = (overwrite: boolean, spaceId: string) => { - // for each outcome, if failure !== undefined then we expect to receive + // for each outcome, if failureType !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result const singleNamespaceObject = spaceId === DEFAULT_SPACE_ID @@ -48,43 +51,45 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ? CASES.SINGLE_NAMESPACE_SPACE_1 : CASES.SINGLE_NAMESPACE_SPACE_2; return [ - { ...singleNamespaceObject, ...fail409(!overwrite) }, - { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, + { ...singleNamespaceObject, ...failConflict(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...failConflict(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...failConflict(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...failConflict(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...failConflict(!overwrite && spaceId === DEFAULT_SPACE_ID), ...destinationId(spaceId !== DEFAULT_SPACE_ID), }, { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, - ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...failConflict(!overwrite && spaceId === SPACE_1_ID), ...destinationId(spaceId !== SPACE_1_ID), }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...failConflict(!overwrite) }, + { ...SPECIAL_TEST_CASES.HIDDEN, ...failUnsupportedType() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that // `expectedDestinationId` already exists - { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' - { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' - { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + { ...CASES.CONFLICT_2C_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...failConflict(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_1_OBJ }, + { ...SPECIAL_TEST_CASES.OUTBOUND_REFERENCE_ORIGIN_MATCH_2_OBJ }, ]; }; diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index c5dc147b45123c..ab7118c132f1b3 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -673,12 +673,12 @@ { "type": "doc", "value": { - "id": "sharedtype:conflict_1_default", + "id": "sharedtype:conflict_1a_default", "index": ".kibana", "source": { - "originId": "conflict_1", + "originId": "conflict_1a", "sharedtype": { - "title": "A shared saved-object in one space" + "title": "This is used to test an inexact match conflict for an originId -> originId match" }, "type": "sharedtype", "namespaces": ["default"], @@ -691,12 +691,12 @@ { "type": "doc", "value": { - "id": "sharedtype:conflict_1_space_1", + "id": "sharedtype:conflict_1a_space_1", "index": ".kibana", "source": { - "originId": "conflict_1", + "originId": "conflict_1a", "sharedtype": { - "title": "A shared saved-object in one space" + "title": "This is used to test an inexact match conflict for an originId -> originId match" }, "type": "sharedtype", "namespaces": ["space_1"], @@ -709,12 +709,100 @@ { "type": "doc", "value": { - "id": "sharedtype:conflict_1_space_2", + "id": "sharedtype:conflict_1a_space_2", "index": ".kibana", "source": { - "originId": "conflict_1", + "originId": "conflict_1a", "sharedtype": { - "title": "A shared saved-object in one space" + "title": "This is used to test an inexact match conflict for an originId -> originId match" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1b_default", + "index": ".kibana", + "source": { + "originId": "conflict_1b_space_2", + "sharedtype": { + "title": "This is used to test an inexact match conflict for an originId -> id match" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1b_space_1", + "index": ".kibana", + "source": { + "originId": "conflict_1b_space_2", + "sharedtype": { + "title": "This is used to test an inexact match conflict for an originId -> id match" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1b_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "This is used to test an inexact match conflict for an originId -> id match" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1c_default_and_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "This is used to test an inexact match conflict for an id -> originId match" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1c_space_2", + "index": ".kibana", + "source": { + "originId": "conflict_1c_default_and_space_1", + "sharedtype": { + "title": "This is used to test an inexact match conflict for an id -> originId match" }, "type": "sharedtype", "namespaces": ["space_2"], diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index d0f83bb6574ef4..8ade75e90b541a 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -482,7 +482,9 @@ export function copyToSpaceTestSuiteFactory( const type = 'sharedtype'; const noConflictId = `${spaceId}_only`; const exactMatchId = 'each_space'; - const inexactMatchId = `conflict_1_${spaceId}`; + const inexactMatchIdA = `conflict_1a_${spaceId}`; + const inexactMatchIdB = `conflict_1b_${spaceId}`; + const inexactMatchIdC = `conflict_1c_default_and_space_1`; const ambiguousConflictId = `conflict_2_${spaceId}`; const getResult = (response: TestResponse) => (response.body as CopyResponse).space_2; @@ -560,22 +562,108 @@ export function copyToSpaceTestSuiteFactory( }, }, { - testTitle: 'copying with an inexact match conflict', - objects: [{ type, id: inexactMatchId }], + testTitle: + 'copying with an inexact match conflict (a) - originId matches existing originId', + objects: [{ type, id: inexactMatchIdA }], statusCode, response: async (response: TestResponse) => { if (outcome === 'authorized') { const { success, successCount, successResults, errors } = getResult(response); - const title = 'A shared saved-object in one space'; + const title = + 'This is used to test an inexact match conflict for an originId -> originId match'; + const meta = { title, icon: 'beaker' }; + const destinationId = 'conflict_1a_space_2'; + if (createNewCopies) { + expectNewCopyResponse(response, inexactMatchIdA, title); + } else if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([ + { type, id: inexactMatchIdA, meta, overwrite: true, destinationId }, + ]); + expect(errors).to.be(undefined); + } else { + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'conflict', destinationId }, + type, + id: inexactMatchIdA, + title, + meta, + }, + ]); + } + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (b) - originId matches existing id', + objects: [{ type, id: inexactMatchIdB }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const title = + 'This is used to test an inexact match conflict for an originId -> id match'; + const meta = { title, icon: 'beaker' }; + const destinationId = 'conflict_1b_space_2'; + if (createNewCopies) { + expectNewCopyResponse(response, inexactMatchIdB, title); + } else if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([ + { type, id: inexactMatchIdB, meta, overwrite: true, destinationId }, + ]); + expect(errors).to.be(undefined); + } else { + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'conflict', destinationId }, + type, + id: inexactMatchIdB, + title, + meta, + }, + ]); + } + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (c) - id matches existing originId', + objects: [{ type, id: inexactMatchIdC }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const title = + 'This is used to test an inexact match conflict for an id -> originId match'; const meta = { title, icon: 'beaker' }; - const destinationId = 'conflict_1_space_2'; + const destinationId = 'conflict_1c_space_2'; if (createNewCopies) { - expectNewCopyResponse(response, inexactMatchId, title); + expectNewCopyResponse(response, inexactMatchIdC, title); } else if (overwrite) { expect(success).to.eql(true); expect(successCount).to.eql(1); expect(successResults).to.eql([ - { type, id: inexactMatchId, meta, overwrite: true, destinationId }, + { type, id: inexactMatchIdC, meta, overwrite: true, destinationId }, ]); expect(errors).to.be(undefined); } else { @@ -586,7 +674,7 @@ export function copyToSpaceTestSuiteFactory( { error: { type: 'conflict', destinationId }, type, - id: inexactMatchId, + id: inexactMatchIdC, title, meta, }, diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index ae8b73535c2c65..84f899bb911e55 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -101,7 +101,7 @@ export function deleteTestSuiteFactory(es: Client, esArchiver: any, supertest: S expect(buckets).to.eql(expectedBuckets); - // There were 15 multi-namespace objects. + // There were 22 multi-namespace objects. // Since Space 2 was deleted, any multi-namespace objects that existed in that space // are updated to remove it, and of those, any that don't exist in any space are deleted. const multiNamespaceResponse = await es.search>({ @@ -110,8 +110,8 @@ export function deleteTestSuiteFactory(es: Client, esArchiver: any, supertest: S body: { query: { terms: { type: ['sharedtype'] } } }, }); const docs = multiNamespaceResponse.hits.hits; - // Just 14 results, since spaces_2_only, conflict_1_space_2 and conflict_2_space_2 got deleted. - expect(docs).length(14); + // Just 17 results, since spaces_2_only, conflict_1a_space_2, conflict_1b_space_2, conflict_1c_space_2, and conflict_2_space_2 got deleted. + expect(docs).length(17); docs.forEach((doc) => () => { const containsSpace2 = doc?._source?.namespaces.includes('space_2'); expect(containsSpace2).to.eql(false); diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 1d9d5325cbabf5..336b04832e2dc5 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -323,7 +323,9 @@ export function resolveCopyToSpaceConflictsSuite( const statusCode = outcome === 'noAccess' ? 403 : 200; const type = 'sharedtype'; const exactMatchId = 'each_space'; - const inexactMatchId = `conflict_1_${spaceId}`; + const inexactMatchIdA = `conflict_1a_${spaceId}`; + const inexactMatchIdB = `conflict_1b_${spaceId}`; + const inexactMatchIdC = `conflict_1c_default_and_space_1`; const ambiguousConflictId = `conflict_2_${spaceId}`; const createRetries = (overwriteRetry: Record) => ({ @@ -350,10 +352,20 @@ export function resolveCopyToSpaceConflictsSuite( expect(success).to.eql(true); expect(successCount).to.eql(1); expect(errors).to.be(undefined); - const title = - id === exactMatchId - ? 'A shared saved-object in the default, space_1, and space_2 spaces' - : 'A shared saved-object in one space'; + const title = (() => { + switch (id) { + case exactMatchId: + return 'A shared saved-object in the default, space_1, and space_2 spaces'; + case inexactMatchIdA: + return 'This is used to test an inexact match conflict for an originId -> originId match'; + case inexactMatchIdB: + return 'This is used to test an inexact match conflict for an originId -> id match'; + case inexactMatchIdC: + return 'This is used to test an inexact match conflict for an id -> originId match'; + default: + return 'A shared saved-object in one space'; + } + })(); const meta = { title, icon: 'beaker' }; expect(successResults).to.eql([ { type, id, meta, overwrite: true, ...(destinationId && { destinationId }) }, @@ -378,18 +390,61 @@ export function resolveCopyToSpaceConflictsSuite( }, }, { - testTitle: 'copying with an inexact match conflict', - objects: [{ type, id: inexactMatchId }], + testTitle: + 'copying with an inexact match conflict (a) - originId matches existing originId', + objects: [{ type, id: inexactMatchIdA }], retries: createRetries({ type, - id: inexactMatchId, + id: inexactMatchIdA, overwrite: true, - destinationId: 'conflict_1_space_2', + destinationId: 'conflict_1a_space_2', }), statusCode, response: async (response: TestResponse) => { if (outcome === 'authorized') { - expectSavedObjectSuccessResponse(response, inexactMatchId, 'conflict_1_space_2'); + expectSavedObjectSuccessResponse(response, inexactMatchIdA, 'conflict_1a_space_2'); + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (b) - originId matches existing id', + objects: [{ type, id: inexactMatchIdB }], + retries: createRetries({ + type, + id: inexactMatchIdB, + overwrite: true, + destinationId: 'conflict_1b_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSavedObjectSuccessResponse(response, inexactMatchIdB, 'conflict_1b_space_2'); + } else if (outcome === 'noAccess') { + expectRouteForbiddenResponse(response); + } else { + // unauthorized read/write + expectSavedObjectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict (c) - id matches existing originId', + objects: [{ type, id: inexactMatchIdC }], + retries: createRetries({ + type, + id: inexactMatchIdC, + overwrite: true, + destinationId: 'conflict_1c_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSavedObjectSuccessResponse(response, inexactMatchIdC, 'conflict_1c_space_2'); } else if (outcome === 'noAccess') { expectRouteForbiddenResponse(response); } else { From d152ca5b6bf7f56fcba1d1d8c2cfee5404a821de Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Wed, 12 Jan 2022 13:10:48 -0700 Subject: [PATCH 06/29] [RAC][Rule Registry] Paginate results for fetching existing alerts (#122474) * [RAC][Rule Registry] Paginate results for fetching existing alerts * Change to difference to increase performance by 2 seconds for 50K alerts * Changing the pagination to break up the request into 10K chunks * Updating NOTICE.txt per CI instructions * Changing warning message to debug * Prefix log message with [Rule Registry] --- .../server/utils/create_lifecycle_executor.ts | 53 ++++--------- .../server/utils/fetch_existing_alerts.ts | 74 +++++++++++++++++++ 2 files changed, 88 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/rule_registry/server/utils/fetch_existing_alerts.ts diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts index 0256212608820c..9ae3dff28b2ae2 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts @@ -10,6 +10,7 @@ import type { PublicContract } from '@kbn/utility-types'; import { getOrElse } from 'fp-ts/lib/Either'; import * as rt from 'io-ts'; import { v4 } from 'uuid'; +import { difference } from 'lodash'; import { AlertExecutorOptions, AlertInstance, @@ -24,7 +25,6 @@ import { ALERT_DURATION, ALERT_END, ALERT_INSTANCE_ID, - ALERT_RULE_UUID, ALERT_START, ALERT_STATUS, ALERT_STATUS_ACTIVE, @@ -39,6 +39,7 @@ import { } from '../../common/technical_rule_data_field_names'; import { IRuleDataClient } from '../rule_data_client'; import { AlertExecutorOptionsWithExtraServices } from '../types'; +import { fetchExistingAlerts } from './fetch_existing_alerts'; import { CommonAlertFieldName, CommonAlertIdFieldName, @@ -179,13 +180,13 @@ export const createLifecycleExecutor = const currentAlertIds = Object.keys(currentAlerts); const trackedAlertIds = Object.keys(state.trackedAlerts); - const newAlertIds = currentAlertIds.filter((alertId) => !trackedAlertIds.includes(alertId)); + const newAlertIds = difference(currentAlertIds, trackedAlertIds); const allAlertIds = [...new Set(currentAlertIds.concat(trackedAlertIds))]; const trackedAlertStates = Object.values(state.trackedAlerts); logger.debug( - `Tracking ${allAlertIds.length} alerts (${newAlertIds.length} new, ${trackedAlertStates.length} previous)` + `[Rule Registry] Tracking ${allAlertIds.length} alerts (${newAlertIds.length} new, ${trackedAlertStates.length} previous)` ); const trackedAlertsDataMap: Record< @@ -194,40 +195,14 @@ export const createLifecycleExecutor = > = {}; if (trackedAlertStates.length) { - const { hits } = await ruleDataClient.getReader().search({ - body: { - query: { - bool: { - filter: [ - { - term: { - [ALERT_RULE_UUID]: commonRuleFields[ALERT_RULE_UUID], - }, - }, - { - terms: { - [ALERT_UUID]: trackedAlertStates.map( - (trackedAlertState) => trackedAlertState.alertUuid - ), - }, - }, - ], - }, - }, - size: trackedAlertStates.length, - collapse: { - field: ALERT_UUID, - }, - sort: { - [TIMESTAMP]: 'desc' as const, - }, - }, - allow_no_indices: true, - }); - - hits.hits.forEach((hit) => { - const alertId = hit._source[ALERT_INSTANCE_ID]; - if (alertId) { + const result = await fetchExistingAlerts( + ruleDataClient, + trackedAlertStates, + commonRuleFields + ); + result.forEach((hit) => { + const alertId = hit._source ? hit._source[ALERT_INSTANCE_ID] : void 0; + if (alertId && hit._source) { trackedAlertsDataMap[alertId] = { indexName: hit._index, fields: hit._source, @@ -242,7 +217,7 @@ export const createLifecycleExecutor = const currentAlertData = currentAlerts[alertId]; if (!alertData) { - logger.warn(`Could not find alert data for ${alertId}`); + logger.debug(`[Rule Registry] Could not find alert data for ${alertId}`); } const isNew = !state.trackedAlerts[alertId]; @@ -291,7 +266,7 @@ export const createLifecycleExecutor = const writeAlerts = ruleDataClient.isWriteEnabled() && shouldWriteAlerts(); if (allEventsToIndex.length > 0 && writeAlerts) { - logger.debug(`Preparing to index ${allEventsToIndex.length} alerts.`); + logger.debug(`[Rule Registry] Preparing to index ${allEventsToIndex.length} alerts.`); await ruleDataClient.getWriter().bulk({ body: allEventsToIndex.flatMap(({ event, indexName }) => [ diff --git a/x-pack/plugins/rule_registry/server/utils/fetch_existing_alerts.ts b/x-pack/plugins/rule_registry/server/utils/fetch_existing_alerts.ts new file mode 100644 index 00000000000000..892e237f8e301f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/utils/fetch_existing_alerts.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { chunk } from 'lodash'; +import { PublicContract } from '@kbn/utility-types'; +import { IRuleDataClient } from '../rule_data_client'; +import { + ALERT_RULE_UUID, + ALERT_UUID, + TIMESTAMP, +} from '../../common/technical_rule_data_field_names'; + +const CHUNK_SIZE = 10000; + +interface TrackedAlertState { + alertId: string; + alertUuid: string; + started: string; +} +type RuleDataClient = PublicContract; + +const fetchAlertsForStates = async ( + ruleDataClient: RuleDataClient, + states: TrackedAlertState[], + commonRuleFields: any +) => { + const request = { + body: { + query: { + bool: { + filter: [ + { + term: { + [ALERT_RULE_UUID]: commonRuleFields[ALERT_RULE_UUID], + }, + }, + { + terms: { + [ALERT_UUID]: states.map((state) => state.alertUuid), + }, + }, + ], + }, + }, + size: states.length, + collapse: { + field: ALERT_UUID, + }, + sort: { + [TIMESTAMP]: 'desc' as const, + }, + }, + allow_no_indices: true, + } as any; + const { hits } = await ruleDataClient.getReader().search(request); + return hits.hits; +}; + +export const fetchExistingAlerts = async ( + ruleDataClient: RuleDataClient, + trackedAlertStates: TrackedAlertState[], + commonRuleFields: any +) => { + const results = await Promise.all( + chunk(trackedAlertStates, CHUNK_SIZE).map((states) => + fetchAlertsForStates(ruleDataClient, states, commonRuleFields) + ) + ); + return results.flat(); +}; From e87cd90647c1535a36cbaf81390a7319e4366fc1 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Wed, 12 Jan 2022 15:23:29 -0500 Subject: [PATCH 07/29] [Security Solution][Endpoint][Admin][Policy List] Policy list side menu nav, route, and empty page (#122549) --- .../security_solution/common/constants.ts | 2 ++ .../common/experimental_features.ts | 1 + .../public/app/deep_links/index.ts | 8 +++++++ .../public/app/home/home_navigations.ts | 8 +++++++ .../public/app/translations.ts | 6 ++++++ .../common/components/navigation/types.ts | 1 + .../use_navigation_items.tsx | 11 +++++++++- .../public/management/pages/policy/index.tsx | 8 ++++++- .../management/pages/policy/view/index.ts | 1 + .../pages/policy/view/policy_list.tsx | 21 +++++++++++++++++++ 10 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index a99a3f8ee2fe99..9a9236d573fc4e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -128,6 +128,7 @@ export const UEBA_PATH = '/ueba' as const; export const NETWORK_PATH = '/network' as const; export const MANAGEMENT_PATH = '/administration' as const; export const ENDPOINTS_PATH = `${MANAGEMENT_PATH}/endpoints` as const; +export const POLICIES_PATH = `${MANAGEMENT_PATH}/policy` as const; export const TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/trusted_apps` as const; export const EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/event_filters` as const; export const HOST_ISOLATION_EXCEPTIONS_PATH = @@ -146,6 +147,7 @@ export const APP_NETWORK_PATH = `${APP_PATH}${NETWORK_PATH}` as const; export const APP_TIMELINES_PATH = `${APP_PATH}${TIMELINES_PATH}` as const; export const APP_CASES_PATH = `${APP_PATH}${CASES_PATH}` as const; export const APP_ENDPOINTS_PATH = `${APP_PATH}${ENDPOINTS_PATH}` as const; +export const APP_POLICIES_PATH = `${APP_PATH}${POLICIES_PATH}` as const; export const APP_TRUSTED_APPS_PATH = `${APP_PATH}${TRUSTED_APPS_PATH}` as const; export const APP_EVENT_FILTERS_PATH = `${APP_PATH}${EVENT_FILTERS_PATH}` as const; export const APP_HOST_ISOLATION_EXCEPTIONS_PATH = diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 4f43e9b61faf96..3f19b888d39011 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -22,6 +22,7 @@ export const allowedExperimentalValues = Object.freeze({ riskyHostsEnabled: false, securityRulesCancelEnabled: false, pendingActionResponsesWithAck: true, + policyListEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 545210c788e8ce..d2308bf1b4cbc6 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -28,6 +28,7 @@ import { HOST_ISOLATION_EXCEPTIONS, EVENT_FILTERS, TRUSTED_APPLICATIONS, + POLICIES, ENDPOINTS, } from '../translations'; import { @@ -40,6 +41,7 @@ import { TIMELINES_PATH, CASES_PATH, ENDPOINTS_PATH, + POLICIES_PATH, TRUSTED_APPS_PATH, EVENT_FILTERS_PATH, UEBA_PATH, @@ -327,6 +329,12 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ order: 9006, path: ENDPOINTS_PATH, }, + { + id: SecurityPageName.policies, + title: POLICIES, + path: POLICIES_PATH, + experimentalKey: 'policyListEnabled', + }, { id: SecurityPageName.trustedApps, title: TRUSTED_APPLICATIONS, diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index de76a570312a5c..15ec9a98b37fd6 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -22,6 +22,7 @@ import { APP_CASES_PATH, APP_MANAGEMENT_PATH, APP_ENDPOINTS_PATH, + APP_POLICIES_PATH, APP_TRUSTED_APPS_PATH, APP_EVENT_FILTERS_PATH, APP_UEBA_PATH, @@ -107,6 +108,13 @@ export const navTabs: SecurityNav = { disabled: false, urlKey: 'administration', }, + [SecurityPageName.policies]: { + id: SecurityPageName.policies, + name: i18n.POLICIES, + href: APP_POLICIES_PATH, + disabled: false, + urlKey: 'administration', + }, [SecurityPageName.trustedApps]: { id: SecurityPageName.trustedApps, name: i18n.TRUSTED_APPLICATIONS, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 7287739566e68c..be341f98df8d84 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -49,6 +49,12 @@ export const ADMINISTRATION = i18n.translate('xpack.securitySolution.navigation. export const ENDPOINTS = i18n.translate('xpack.securitySolution.search.administration.endpoints', { defaultMessage: 'Endpoints', }); +export const POLICIES = i18n.translate( + 'xpack.securitySolution.navigation.administration.policies', + { + defaultMessage: 'Policies', + } +); export const TRUSTED_APPLICATIONS = i18n.translate( 'xpack.securitySolution.search.administration.trustedApps', { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 76b5034ce31650..ea2b692b2b3b76 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -45,6 +45,7 @@ export type SecurityNavKey = | SecurityPageName.alerts | SecurityPageName.case | SecurityPageName.endpoints + | SecurityPageName.policies | SecurityPageName.eventFilters | SecurityPageName.exceptions | SecurityPageName.hostIsolationExceptions diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index d012945a23e27c..3c52efcc06567f 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -16,6 +16,7 @@ import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { useNavigation } from '../../../lib/kibana/hooks'; import { NavTab } from '../types'; import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; export const usePrimaryNavigationItems = ({ navTabs, @@ -65,6 +66,7 @@ export const usePrimaryNavigationItems = ({ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; const canSeeHostIsolationExceptions = useCanSeeHostIsolationExceptionsMenu(); + const isPolicyListEnabled = useIsExperimentalFeatureEnabled('policyListEnabled'); const uiCapabilities = useKibana().services.application.capabilities; return useMemo( () => @@ -97,6 +99,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { ...securityNavGroup.manage, items: [ navTabs.endpoints, + ...(isPolicyListEnabled ? [navTabs.policies] : []), navTabs.trusted_apps, navTabs.event_filters, ...(canSeeHostIsolationExceptions ? [navTabs.host_isolation_exceptions] : []), @@ -111,6 +114,12 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { }, ] : [], - [uiCapabilities.siem.show, navTabs, hasCasesReadPermissions, canSeeHostIsolationExceptions] + [ + uiCapabilities.siem.show, + navTabs, + hasCasesReadPermissions, + canSeeHostIsolationExceptions, + isPolicyListEnabled, + ] ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx index 249345a0a0ad85..48356808a50434 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx @@ -7,18 +7,21 @@ import React, { memo } from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; -import { PolicyDetails } from './view'; +import { PolicyDetails, PolicyList } from './view'; import { MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_PATH_OLD, MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_ROUTING_POLICIES_PATH, } from '../../common/constants'; import { NotFoundPage } from '../../../app/404'; import { getPolicyDetailPath } from '../../common/routing'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; export const PolicyContainer = memo(() => { + const isPolicyListEnabled = useIsExperimentalFeatureEnabled('policyListEnabled'); return ( { exact render={(props) => } /> + {isPolicyListEnabled && ( + + )} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/index.ts index d2bea12741b991..540484a7109135 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/index.ts @@ -5,5 +5,6 @@ * 2.0. */ +export * from './policy_list'; export * from './policy_details'; export * from './policy_advanced'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx new file mode 100644 index 00000000000000..472f4a7ba6c6b6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { AdministrationListPage } from '../../../components/administration_list_page'; + +export const PolicyList = memo(() => { + return ( + + ); +}); + +PolicyList.displayName = 'PolicyList'; From 6392a050fe4f18eb50393b1c072328f326ea78c2 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Wed, 12 Jan 2022 15:01:45 -0700 Subject: [PATCH 08/29] [Security Solution][Detections] Fixes Rule Details when viewing deleted Rule from Alert with flattened structure (#122619) ## Summary Resolves https://github.com/elastic/kibana/issues/122013 by transforming flattened `alertHit` back to a `Rule` using the existing `expandDottedObject` utility (which was moved to `security_solution/common/utils` for global use). Also fixed margins on deleted badge (and ensured no overlap when title overflows).

## Test Instructions: * Create Rule with all available fields filled out * Generate alerts for Rule * Delete Rule and navigate to Rule Details from Alert Details * Verify `Deleted` badge is present and Rule Details are filled out again Test with 7.x and 8.x alerts (as backwards compatibility was kept) --- .../utils/expand_dotted.test.ts | 0 .../utils/expand_dotted.ts | 0 .../common/components/header_page/index.tsx | 13 +- .../common/components/header_page/title.tsx | 1 + .../detection_engine/alerts/mock.ts | 283 ++++++++++++++++++ .../rules/use_rule_with_fallback.test.tsx | 149 ++++++++- .../rules/use_rule_with_fallback.tsx | 46 ++- .../schedule_notification_actions.ts | 2 +- .../rule_types/utils/index.ts | 1 - 9 files changed, 476 insertions(+), 19 deletions(-) rename x-pack/plugins/security_solution/{server/lib/detection_engine/rule_types => common}/utils/expand_dotted.test.ts (100%) rename x-pack/plugins/security_solution/{server/lib/detection_engine/rule_types => common}/utils/expand_dotted.ts (100%) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/expand_dotted.test.ts b/x-pack/plugins/security_solution/common/utils/expand_dotted.test.ts similarity index 100% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/expand_dotted.test.ts rename to x-pack/plugins/security_solution/common/utils/expand_dotted.test.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/expand_dotted.ts b/x-pack/plugins/security_solution/common/utils/expand_dotted.ts similarity index 100% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/expand_dotted.ts rename to x-pack/plugins/security_solution/common/utils/expand_dotted.ts diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index deba6c739fe34b..2647827c0d1b00 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -5,13 +5,7 @@ * 2.0. */ -import { - EuiBadge, - EuiProgress, - EuiPageHeader, - EuiPageHeaderSection, - EuiSpacer, -} from '@elastic/eui'; +import { EuiProgress, EuiPageHeader, EuiPageHeaderSection, EuiSpacer } from '@elastic/eui'; import React from 'react'; import styled, { css } from 'styled-components'; @@ -47,11 +41,6 @@ const LinkBack = styled.div.attrs({ `; LinkBack.displayName = 'LinkBack'; -const Badge = styled(EuiBadge)` - letter-spacing: 0; -` as unknown as typeof EuiBadge; -Badge.displayName = 'Badge'; - const HeaderSection = styled(EuiPageHeaderSection)` // Without min-width: 0, as a flex child, it wouldn't shrink properly // and could overflow its parent. diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx index 5e622c0cf63550..c52fd7bc34e82f 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx @@ -21,6 +21,7 @@ StyledEuiBetaBadge.displayName = 'StyledEuiBetaBadge'; const Badge = styled(EuiBadge)` letter-spacing: 0; + margin-left: 10px; ` as unknown as typeof EuiBadge; Badge.displayName = 'Badge'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index 7aba8fa4ac10f1..58ec564c6911b3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -945,6 +945,289 @@ export const alertsMock: AlertSearchResponse = { }, }; +export const alertsMock8x: AlertSearchResponse = { + took: 3, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 1, + failed: 0, + }, + hits: { + total: { + value: 10000, + relation: 'gte', + }, + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f8946a2cb00640d079dcf3d1007f792a794974674cedfd7a42c047ba029f311d', + _score: null, + _source: { + 'kibana.alert.severity': 'low', + 'kibana.alert.rule.updated_by': 'elastic', + 'kibana.alert.rule.references': ['http://www.example.com/1'], + 'kibana.alert.rule.threat': [ + { + framework: 'MITRE ATT&CK', + technique: [ + { + reference: 'https://attack.mitre.org/techniques/T1217', + name: 'Browser Bookmark Discovery', + subtechnique: [], + id: 'T1217', + }, + { + reference: 'https://attack.mitre.org/techniques/T1580', + name: 'Cloud Infrastructure Discovery', + subtechnique: [], + id: 'T1580', + }, + { + reference: 'https://attack.mitre.org/techniques/T1033', + name: 'System Owner/User Discovery', + subtechnique: [], + id: 'T1033', + }, + ], + tactic: { + reference: 'https://attack.mitre.org/tactics/TA0007', + name: 'Discovery', + id: 'TA0007', + }, + }, + { + framework: 'MITRE ATT&CK', + technique: [], + tactic: { + reference: 'https://attack.mitre.org/tactics/TA0007', + name: 'Discovery', + id: 'TA0007', + }, + }, + ], + 'kibana.alert.rule.rule_name_override': 'host.id', + 'kibana.alert.rule.description': '8.1: To Be Deleted', + 'kibana.alert.rule.tags': ['8.0-tag'], + 'kibana.alert.rule.producer': 'siem', + 'kibana.alert.rule.to': 'now', + 'kibana.alert.rule.created_by': 'elastic', + 'kibana.alert.original_event.ingested': '2022-01-11T22:43:03Z', + 'kibana.alert.risk_score': 37, + 'kibana.alert.rule.name': '944edf04-ea2d-44f9-b89a-574e9a9301da', + 'kibana.alert.original_event.id': '751afb02-94ee-46b7-9aea-1a7529374df9', + 'kibana.alert.workflow_status': 'open', + 'kibana.alert.rule.uuid': '63136880-7335-11ec-9f1b-9db9315083e9', + 'kibana.alert.original_event.category': 'driver', + 'kibana.alert.rule.risk_score_mapping': [ + { + field: 'Responses.process.pid', + value: '', + operator: 'equals', + }, + ], + 'kibana.alert.rule.interval': '5m', + 'kibana.alert.reason': + 'driver event with process powershell.exe, by 6nmm77jt8p on Host-7luvv0bmdn created low alert 944edf04-ea2d-44f9-b89a-574e9a9301da.', + 'kibana.alert.rule.type': 'query', + 'kibana.alert.rule.immutable': false, + 'kibana.alert.original_event.type': 'start', + 'kibana.alert.depth': 1, + 'kibana.alert.rule.enabled': true, + 'kibana.alert.rule.version': 1, + 'kibana.alert.rule.from': 'now-360s', + 'kibana.alert.rule.parameters': { + note: 'Investigation guuuide', + severity_mapping: [ + { + severity: 'low', + field: 'host.name', + value: '', + operator: 'equals', + }, + ], + references: ['http://www.example.com/1'], + description: '8.1: To Be Deleted', + language: 'kuery', + type: 'query', + rule_name_override: 'host.id', + exceptions_list: [], + from: 'now-360s', + severity: 'low', + max_signals: 100, + risk_score: 37, + risk_score_mapping: [ + { + field: 'Responses.process.pid', + value: '', + operator: 'equals', + }, + ], + author: ['author'], + query: 'host.name:*', + index: [ + 'apm-*-transaction*', + 'traces-apm*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + filters: [], + version: 1, + rule_id: 'a2490dbb-33f6-4b03-88d8-b7d009ef58db', + license: 'license', + immutable: false, + meta: { + from: '1m', + kibana_siem_app_url: 'http://localhost:5601/kbn/app/security', + }, + false_positives: ['fp'], + threat: [ + { + framework: 'MITRE ATT&CK', + technique: [ + { + reference: 'https://attack.mitre.org/techniques/T1217', + name: 'Browser Bookmark Discovery', + subtechnique: [], + id: 'T1217', + }, + { + reference: 'https://attack.mitre.org/techniques/T1580', + name: 'Cloud Infrastructure Discovery', + subtechnique: [], + id: 'T1580', + }, + { + reference: 'https://attack.mitre.org/techniques/T1033', + name: 'System Owner/User Discovery', + subtechnique: [], + id: 'T1033', + }, + ], + tactic: { + reference: 'https://attack.mitre.org/tactics/TA0007', + name: 'Discovery', + id: 'TA0007', + }, + }, + { + framework: 'MITRE ATT&CK', + technique: [], + tactic: { + reference: 'https://attack.mitre.org/tactics/TA0007', + name: 'Discovery', + id: 'TA0007', + }, + }, + ], + to: 'now', + }, + 'kibana.alert.status': 'active', + 'kibana.alert.ancestors': [ + { + depth: 0, + index: '.ds-logs-endpoint.events.process-default-2022.01.11-000001', + id: 'VWxPS34B7OkM56GXH627', + type: 'event', + }, + ], + 'kibana.alert.rule.exceptions_list': [], + 'kibana.alert.rule.actions': [], + 'kibana.alert.rule.rule_type_id': 'siem.queryRule', + 'kibana.alert.rule.license': 'license', + 'kibana.alert.original_event.kind': 'event', + 'kibana.alert.rule.note': 'Investigation guuuide', + 'kibana.alert.rule.severity_mapping': [ + { + severity: 'low', + field: 'host.name', + value: '', + operator: 'equals', + }, + ], + 'kibana.alert.rule.max_signals': 100, + 'kibana.alert.rule.updated_at': '2022-01-11T23:22:47.678Z', + 'kibana.alert.rule.risk_score': 37, + 'kibana.alert.rule.author': ['author'], + 'kibana.alert.rule.false_positives': ['fp'], + 'kibana.alert.rule.consumer': 'siem', + 'kibana.alert.rule.category': 'Custom Query Rule', + 'kibana.alert.original_event.sequence': 20, + 'kibana.alert.rule.created_at': '2022-01-11T23:22:47.678Z', + 'kibana.alert.rule.severity': 'low', + 'kibana.alert.original_event.agent_id_status': 'auth_metadata_missing', + 'kibana.alert.rule.meta.kibana_siem_app_url': 'http://localhost:5601/kbn/app/security', + 'kibana.alert.uuid': 'f8946a2cb00640d079dcf3d1007f792a794974674cedfd7a42c047ba029f311d', + 'kibana.alert.rule.meta.from': '1m', + 'kibana.alert.rule.rule_id': 'a2490dbb-33f6-4b03-88d8-b7d009ef58db', + 'kibana.alert.original_time': '2022-01-11T23:18:39.714Z', + }, + fields: { + 'kibana.alert.severity': ['low'], + 'process.hash.md5': ['33d3568e-cf11-42fb-b36e-08aec99570e9'], + 'event.category': ['driver'], + 'user.name': ['6nmm77jt8p'], + 'process.parent.pid': [1975], + 'process.pid': [2121], + 'kibana.alert.rule.producer': ['siem'], + 'kibana.alert.rule.to': ['now'], + 'process.entity_id': ['3fadfesdk0'], + 'host.ip': ['10.248.183.44'], + 'agent.type': ['endpoint'], + 'kibana.alert.risk_score': [37], + 'kibana.alert.rule.name': ['944edf04-ea2d-44f9-b89a-574e9a9301da'], + 'host.name': ['Host-7luvv0bmdn'], + 'user.domain': ['epjr8uvmrj'], + 'event.kind': ['signal'], + 'kibana.alert.original_event.kind': ['event'], + 'host.id': ['944edf04-ea2d-44f9-b89a-574e9a9301da'], + 'process.executable': ['C:\\powershell.exe'], + 'kibana.alert.rule.note': ['Investigation guuuide'], + 'kibana.alert.workflow_status': ['open'], + 'kibana.alert.rule.uuid': ['63136880-7335-11ec-9f1b-9db9315083e9'], + 'kibana.alert.rule.risk_score': [37], + 'process.args': ['"C:\\powershell.exe" \\fzw'], + 'kibana.alert.reason': [ + 'driver event with process powershell.exe, by 6nmm77jt8p on Host-7luvv0bmdn created low alert 944edf04-ea2d-44f9-b89a-574e9a9301da.', + ], + 'kibana.alert.rule.type': ['query'], + 'kibana.alert.rule.consumer': ['siem'], + 'kibana.alert.rule.category': ['Custom Query Rule'], + 'process.name': ['powershell.exe'], + '@timestamp': ['2022-01-11T23:22:52.034Z'], + 'kibana.alert.rule.severity': ['low'], + 'event.type': ['start'], + 'kibana.alert.uuid': ['f8946a2cb00640d079dcf3d1007f792a794974674cedfd7a42c047ba029f311d'], + 'kibana.alert.rule.version': ['1'], + 'event.id': ['751afb02-94ee-46b7-9aea-1a7529374df9'], + 'host.os.family': ['windows'], + 'kibana.alert.rule.from': ['now-360s'], + 'kibana.alert.rule.rule_id': ['a2490dbb-33f6-4b03-88d8-b7d009ef58db'], + 'kibana.alert.original_time': ['2022-01-11T23:18:39.714Z'], + }, + sort: [1641943372034], + }, + ], + }, + aggregations: { + producers: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'siem', + doc_count: 3, + }, + ], + }, + }, +}; + export const mockAlertsQuery: object = { aggs: { alertsByGrouping: { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx index 1f08a356602152..40d2e8663b618c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx @@ -7,8 +7,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { SecurityAppError } from '@kbn/securitysolution-t-grid'; +import { alertsMock8x } from '../alerts/mock'; +import { AlertSearchResponse } from '../alerts/types'; import { useRuleWithFallback } from './use_rule_with_fallback'; import * as api from './api'; +import * as alertsAPI from '../alerts/api'; import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; @@ -95,7 +98,7 @@ describe('useRuleWithFallback', () => { }); }); - it("should fallback to fetching rule data from a signal if the rule doesn't exist", async () => { + it("should fallback to fetching rule data from a 7.x signal if the rule doesn't exist", async () => { (api.fetchRuleById as jest.Mock).mockImplementation(async () => { const err = new Error('Not found') as SecurityAppError; err.body = { status_code: 404, message: 'Rule Not found' }; @@ -206,4 +209,148 @@ describe('useRuleWithFallback', () => { `); }); }); + + it("should fallback to fetching rule data from an 8.0 alert if the rule doesn't exist", async () => { + // Override default mock coming from ../alerts/__mocks__/api.ts + const spy = jest.spyOn(alertsAPI, 'fetchQueryAlerts').mockImplementation(async () => { + return alertsMock8x as AlertSearchResponse; + }); + + (api.fetchRuleById as jest.Mock).mockImplementation(async () => { + const err = new Error('Not found') as SecurityAppError; + err.body = { status_code: 404, message: 'Rule Not found' }; + throw err; + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook((id) => useRuleWithFallback(id), { + initialProps: 'testRuleId', + }); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "error": [Error: Not found], + "isExistingRule": false, + "loading": false, + "refresh": [Function], + "rule": Object { + "actions": Array [], + "author": Array [ + "author", + ], + "category": "Custom Query Rule", + "consumer": "siem", + "created_at": "2022-01-11T23:22:47.678Z", + "created_by": "elastic", + "description": "8.1: To Be Deleted", + "enabled": true, + "exceptions_list": Array [], + "false_positives": Array [ + "fp", + ], + "filters": Array [], + "from": "now-360s", + "immutable": false, + "index": Array [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*", + ], + "interval": "5m", + "language": "kuery", + "license": "license", + "max_signals": 100, + "meta": Object { + "from": "1m", + "kibana_siem_app_url": "http://localhost:5601/kbn/app/security", + }, + "name": "944edf04-ea2d-44f9-b89a-574e9a9301da", + "note": "Investigation guuuide", + "producer": "siem", + "query": "host.name:*", + "references": Array [ + "http://www.example.com/1", + ], + "risk_score": 37, + "risk_score_mapping": Array [ + Object { + "field": "Responses.process.pid", + "operator": "equals", + "value": "", + }, + ], + "rule_id": "a2490dbb-33f6-4b03-88d8-b7d009ef58db", + "rule_name_override": "host.id", + "rule_type_id": "siem.queryRule", + "severity": "low", + "severity_mapping": Array [ + Object { + "field": "host.name", + "operator": "equals", + "severity": "low", + "value": "", + }, + ], + "tags": Array [ + "8.0-tag", + ], + "threat": Array [ + Object { + "framework": "MITRE ATT&CK", + "tactic": Object { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007", + }, + "technique": Array [ + Object { + "id": "T1217", + "name": "Browser Bookmark Discovery", + "reference": "https://attack.mitre.org/techniques/T1217", + "subtechnique": Array [], + }, + Object { + "id": "T1580", + "name": "Cloud Infrastructure Discovery", + "reference": "https://attack.mitre.org/techniques/T1580", + "subtechnique": Array [], + }, + Object { + "id": "T1033", + "name": "System Owner/User Discovery", + "reference": "https://attack.mitre.org/techniques/T1033", + "subtechnique": Array [], + }, + ], + }, + Object { + "framework": "MITRE ATT&CK", + "tactic": Object { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007", + }, + "technique": Array [], + }, + ], + "to": "now", + "type": "query", + "updated_at": "2022-01-11T23:22:47.678Z", + "updated_by": "elastic", + "uuid": "63136880-7335-11ec-9f1b-9db9315083e9", + "version": 1, + }, + } + `); + }); + // Reset back to default mock coming from ../alerts/__mocks__/api.ts + spy.mockRestore(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx index 8c8736b03b2294..cda7b7aab0af2f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.tsx @@ -9,8 +9,10 @@ import { useCallback, useEffect, useMemo } from 'react'; import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; import { isNotFoundError } from '@kbn/securitysolution-t-grid'; +import { expandDottedObject } from '../../../../../common/utils/expand_dotted'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { AlertSearchResponse } from '../alerts/types'; import { useQueryAlerts } from '../alerts/use_query'; import { fetchRuleById } from './api'; import { transformInput } from './transforms'; @@ -41,9 +43,20 @@ interface AlertHit { }; } -const fetchWithOptionslSignal = withOptionalSignal(fetchRuleById); +// TODO: Create proper types for nested/flattened RACRule once contract w/ Fields API is finalized. +interface RACRule { + kibana: { + alert: { + rule: { + parameters?: {}; + }; + }; + }; +} -const useFetchRule = () => useAsync(fetchWithOptionslSignal); +const fetchWithOptionsSignal = withOptionalSignal(fetchRuleById); + +const useFetchRule = () => useAsync(fetchWithOptionsSignal); const buildLastAlertQuery = (ruleId: string) => ({ query: { @@ -94,10 +107,11 @@ export const useRuleWithFallback = (ruleId: string): UseRuleWithFallback => { }, [addError, error]); const rule = useMemo(() => { - const hit = alertsData?.hits.hits[0]; const result = isExistingRule ? ruleData - : hit?._source.signal?.rule ?? hit?._source.kibana?.alert?.rule; + : alertsData == null + ? undefined + : transformRuleFromAlertHit(alertsData); if (result) { return transformInput(result); } @@ -111,3 +125,27 @@ export const useRuleWithFallback = (ruleId: string): UseRuleWithFallback => { isExistingRule, }; }; + +/** + * Transforms an alertHit into a Rule + * @param data raw response containing single alert + */ +export const transformRuleFromAlertHit = (data: AlertSearchResponse): Rule | undefined => { + const hit = data?.hits.hits[0] as AlertHit | undefined; + + // If pre 8.x alert, pull directly from alertHit + const rule = hit?._source.signal?.rule ?? hit?._source.kibana?.alert?.rule; + + // If rule undefined, response likely flattened + if (rule == null) { + const expandedRuleWithParams = expandDottedObject(hit?._source ?? {}) as RACRule; + const expandedRule = { + ...expandedRuleWithParams?.kibana?.alert?.rule, + ...expandedRuleWithParams?.kibana?.alert?.rule?.parameters, + }; + delete expandedRule.parameters; + return expandedRule as Rule; + } + + return rule; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts index 2362a6a392a56e..9b20b031eea0f1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -7,7 +7,7 @@ import { mapKeys, snakeCase } from 'lodash/fp'; import { AlertInstance } from '../../../../../alerting/server'; -import { expandDottedObject } from '../rule_types/utils'; +import { expandDottedObject } from '../../../../common/utils/expand_dotted'; import { RuleParams } from '../schemas/rule_schemas'; import aadFieldConversion from '../routes/index/signal_aad_mapping.json'; import { isRACAlert } from '../signals/utils'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/index.ts index d60a190f94d19b..ac2f6495b8b46e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/index.ts @@ -24,5 +24,4 @@ export const createResultObject = (state: TState) return result; }; -export * from './expand_dotted'; export * from './get_list_client'; From a39bca4ba7a85defa369c4c80bda2d3c80f1e9e0 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 12 Jan 2022 16:07:15 -0600 Subject: [PATCH 09/29] [build] Fix docker ubuntu context (#122852) The docker ubuntu context is missing the base registry due to a missing argument. This declares the context build as ubuntu for the default image. --- src/dev/build/tasks/os_packages/create_os_package_tasks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index b4724085c51848..88240429856d11 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -119,6 +119,7 @@ export const CreateDockerContexts: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { + ubuntu: true, context: true, image: false, dockerBuildDate, From 39cef8bca958cf828c36909a07b764c4f3ecc3e8 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Wed, 12 Jan 2022 23:00:57 +0000 Subject: [PATCH 10/29] Add session cleanup audit logging (#122419) * Add session cleanup audit logging * Update snapshots * Added suggestions from code review * Clean up sessions in batches * Added suggestions form code review --- docs/user/security/audit-logging.asciidoc | 5 +- .../server/audit/audit_events.test.ts | 32 + .../security/server/audit/audit_events.ts | 29 + .../server/audit/audit_service.test.ts | 79 ++ .../security/server/audit/audit_service.ts | 110 +-- .../security/server/audit/index.mock.ts | 3 + x-pack/plugins/security/server/audit/index.ts | 1 + x-pack/plugins/security/server/plugin.test.ts | 3 + x-pack/plugins/security/server/plugin.ts | 5 +- .../session_management/session_index.test.ts | 731 +++++++++++------- .../session_management/session_index.ts | 119 ++- .../session_management_service.test.ts | 44 +- .../session_management_service.ts | 4 + 13 files changed, 806 insertions(+), 359 deletions(-) diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 33e1c2b5235110..1e7eb1971af087 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -53,8 +53,11 @@ Refer to the corresponding {es} logs for potential write errors. | `user_logout` | `unknown` | User is logging out. +| `session_cleanup` +| `unknown` | Removing invalid or expired session. + | `access_agreement_acknowledged` -| N/A | User has acknowledged the access agreement. +| n/a | User has acknowledged the access agreement. 3+a| ===== Category: database diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts index df796b0603176b..0a7337e4532747 100644 --- a/x-pack/plugins/security/server/audit/audit_events.test.ts +++ b/x-pack/plugins/security/server/audit/audit_events.test.ts @@ -15,6 +15,7 @@ import { httpRequestEvent, SavedObjectAction, savedObjectEvent, + sessionCleanupEvent, SpaceAuditAction, spaceAuditEvent, userLoginEvent, @@ -352,6 +353,37 @@ describe('#userLogoutEvent', () => { }); }); +describe('#sessionCleanupEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + sessionCleanupEvent({ + usernameHash: 'abcdef', + sessionId: 'sid', + provider: { name: 'basic1', type: 'basic' }, + }) + ).toMatchInlineSnapshot(` + Object { + "event": Object { + "action": "session_cleanup", + "category": Array [ + "authentication", + ], + "outcome": "unknown", + }, + "kibana": Object { + "authentication_provider": "basic1", + "authentication_type": "basic", + "session_id": "sid", + }, + "message": "Removing invalid or expired session for user [hash=abcdef]", + "user": Object { + "hash": "abcdef", + }, + } + `); + }); +}); + describe('#httpRequestEvent', () => { test('creates event with `unknown` outcome', () => { expect( diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 96bc85c1be37ee..2dfaf8ece004fc 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -156,6 +156,35 @@ export function userLogoutEvent({ username, provider }: UserLogoutParams): Audit }; } +export interface SessionCleanupParams { + sessionId: string; + usernameHash?: string; + provider: AuthenticationProvider; +} + +export function sessionCleanupEvent({ + usernameHash, + sessionId, + provider, +}: SessionCleanupParams): AuditEvent { + return { + message: `Removing invalid or expired session for user [hash=${usernameHash}]`, + event: { + action: 'session_cleanup', + category: ['authentication'], + outcome: 'unknown', + }, + user: { + hash: usernameHash, + }, + kibana: { + session_id: sessionId, + authentication_provider: provider.name, + authentication_type: provider.type, + }, + }; +} + export interface AccessAgreementAcknowledgedParams { username: string; provider: AuthenticationProvider; diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index 493490a8e8b9f5..1815f617dceaea 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -67,6 +67,9 @@ describe('#setup', () => { ).toMatchInlineSnapshot(` Object { "asScoped": [Function], + "withoutRequest": Object { + "log": [Function], + }, } `); audit.stop(); @@ -254,6 +257,82 @@ describe('#asScoped', () => { }); }); +describe('#withoutRequest', () => { + it('logs event without additional meta data', async () => { + const audit = new AuditService(logger); + const auditSetup = audit.setup({ + license, + config, + logging, + http, + getCurrentUser, + getSpaceId, + getSID, + recordAuditLoggingUsage, + }); + + await auditSetup.withoutRequest.log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + expect(logger.info).toHaveBeenCalledWith('MESSAGE', { + event: { action: 'ACTION' }, + }); + audit.stop(); + }); + + it('does not log to audit logger if event matches ignore filter', async () => { + const audit = new AuditService(logger); + const auditSetup = audit.setup({ + license, + config: { + enabled: true, + appender: { + type: 'console', + layout: { + type: 'json', + }, + }, + ignore_filters: [{ actions: ['ACTION'] }], + }, + logging, + http, + getCurrentUser, + getSpaceId, + getSID, + recordAuditLoggingUsage, + }); + + await auditSetup.withoutRequest.log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + expect(logger.info).not.toHaveBeenCalled(); + audit.stop(); + }); + + it('does not log to audit logger if no event was generated', async () => { + const audit = new AuditService(logger); + const auditSetup = audit.setup({ + license, + config: { + enabled: true, + appender: { + type: 'console', + layout: { + type: 'json', + }, + }, + ignore_filters: [{ actions: ['ACTION'] }], + }, + logging, + http, + getCurrentUser, + getSpaceId, + getSID, + recordAuditLoggingUsage, + }); + + await auditSetup.withoutRequest.log(undefined); + expect(logger.info).not.toHaveBeenCalled(); + audit.stop(); + }); +}); + describe('#createLoggingConfig', () => { test('sets log level to `info` when audit logging is enabled and appender is defined', async () => { const features$ = of({ diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index fb03669ca0fc5c..a29ec221b34744 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -26,11 +26,58 @@ export const ECS_VERSION = '1.6.0'; export const RECORD_USAGE_INTERVAL = 60 * 60 * 1000; // 1 hour export interface AuditLogger { + /** + * Logs an {@link AuditEvent} and automatically adds meta data about the + * current user, space and correlation id. + * + * Guidelines around what events should be logged and how they should be + * structured can be found in: `/x-pack/plugins/security/README.md` + * + * @example + * ```typescript + * const auditLogger = securitySetup.audit.asScoped(request); + * auditLogger.log({ + * message: 'User is updating dashboard [id=123]', + * event: { + * action: 'saved_object_update', + * outcome: 'unknown' + * }, + * kibana: { + * saved_object: { type: 'dashboard', id: '123' } + * }, + * }); + * ``` + */ log: (event: AuditEvent | undefined) => void; } export interface AuditServiceSetup { + /** + * Creates an {@link AuditLogger} scoped to the current request. + * + * This audit logger logs events with all required user and session info and should be used for + * all user-initiated actions. + * + * @example + * ```typescript + * const auditLogger = securitySetup.audit.asScoped(request); + * auditLogger.log(event); + * ``` + */ asScoped: (request: KibanaRequest) => AuditLogger; + + /** + * {@link AuditLogger} for background tasks only. + * + * This audit logger logs events without any user or session info and should never be used to log + * user-initiated actions. + * + * @example + * ```typescript + * securitySetup.audit.withoutRequest.log(event); + * ``` + */ + withoutRequest: AuditLogger; } interface AuditServiceSetupParams { @@ -88,46 +135,25 @@ export class AuditService { }); } - /** - * Creates an {@link AuditLogger} scoped to the current request. - * - * @example - * ```typescript - * const auditLogger = securitySetup.audit.asScoped(request); - * auditLogger.log(event); - * ``` - */ - const asScoped = (request: KibanaRequest): AuditLogger => { - /** - * Logs an {@link AuditEvent} and automatically adds meta data about the - * current user, space and correlation id. - * - * Guidelines around what events should be logged and how they should be - * structured can be found in: `/x-pack/plugins/security/README.md` - * - * @example - * ```typescript - * const auditLogger = securitySetup.audit.asScoped(request); - * auditLogger.log({ - * message: 'User is updating dashboard [id=123]', - * event: { - * action: 'saved_object_update', - * outcome: 'unknown' - * }, - * kibana: { - * saved_object: { type: 'dashboard', id: '123' } - * }, - * }); - * ``` - */ - const log: AuditLogger['log'] = async (event) => { + const log = (event: AuditEvent | undefined) => { + if (!event) { + return; + } + if (filterEvent(event, config.ignore_filters)) { + const { message, ...eventMeta } = event; + this.logger.info(message, eventMeta); + } + }; + + const asScoped = (request: KibanaRequest): AuditLogger => ({ + log: async (event) => { if (!event) { return; } const spaceId = getSpaceId(request); const user = getCurrentUser(request); const sessionId = await getSID(request); - const meta: AuditEvent = { + log({ ...event, user: (user && { @@ -141,14 +167,9 @@ export class AuditService { ...event.kibana, }, trace: { id: request.id }, - }; - if (filterEvent(meta, config.ignore_filters)) { - const { message, ...eventMeta } = meta; - this.logger.info(message, eventMeta); - } - }; - return { log }; - }; + }); + }, + }); http.registerOnPostAuth((request, response, t) => { if (request.auth.isAuthenticated) { @@ -157,7 +178,10 @@ export class AuditService { return t.next(); }); - return { asScoped }; + return { + asScoped, + withoutRequest: { log }, + }; } stop() { diff --git a/x-pack/plugins/security/server/audit/index.mock.ts b/x-pack/plugins/security/server/audit/index.mock.ts index ce6885aee50def..c84faacff01478 100644 --- a/x-pack/plugins/security/server/audit/index.mock.ts +++ b/x-pack/plugins/security/server/audit/index.mock.ts @@ -14,6 +14,9 @@ export const auditServiceMock = { asScoped: jest.fn().mockReturnValue({ log: jest.fn(), }), + withoutRequest: { + log: jest.fn(), + }, } as jest.Mocked>; }, }; diff --git a/x-pack/plugins/security/server/audit/index.ts b/x-pack/plugins/security/server/audit/index.ts index f83c7a7f3bd8a4..0bd8492b796706 100644 --- a/x-pack/plugins/security/server/audit/index.ts +++ b/x-pack/plugins/security/server/audit/index.ts @@ -11,6 +11,7 @@ export type { AuditEvent } from './audit_events'; export { userLoginEvent, userLogoutEvent, + sessionCleanupEvent, accessAgreementAcknowledgedEvent, httpRequestEvent, savedObjectEvent, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 3d43129b638098..85c2fff5a438ee 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -67,6 +67,9 @@ describe('Security Plugin', () => { Object { "audit": Object { "asScoped": [Function], + "withoutRequest": Object { + "log": [Function], + }, }, "authc": Object { "getCurrentUser": [Function], diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 1fc3932bb551bf..36be02138289a5 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -310,9 +310,7 @@ export class SecurityPlugin }); return Object.freeze({ - audit: { - asScoped: this.auditSetup.asScoped, - }, + audit: this.auditSetup, authc: { getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request) }, authz: { actions: this.authorizationSetup.actions, @@ -347,6 +345,7 @@ export class SecurityPlugin const clusterClient = core.elasticsearch.client; const { watchOnlineStatus$ } = this.elasticsearchService.start(); const { session } = this.sessionManagementService.start({ + auditLogger: this.auditSetup!.withoutRequest, elasticsearchClient: clusterClient.asInternalUser, kibanaIndexName: this.getKibanaIndexName(), online$: watchOnlineStatus$(), diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts index 251a0a3edb0613..45ce865de56357 100644 --- a/x-pack/plugins/security/server/session_management/session_index.test.ts +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -6,11 +6,19 @@ */ import { errors } from '@elastic/elasticsearch'; +import type { + BulkResponse, + ClosePointInTimeResponse, + OpenPointInTimeResponse, + SearchResponse, +} from '@elastic/elasticsearch/lib/api/types'; import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; import type { ElasticsearchClient } from 'src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import type { AuditLogger } from '../audit'; +import { auditServiceMock } from '../audit/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { securityMock } from '../mocks'; import { getSessionIndexTemplate, SessionIndex } from './session_index'; @@ -19,11 +27,13 @@ import { sessionIndexMock } from './session_index.mock'; describe('Session index', () => { let mockElasticsearchClient: DeeplyMockedKeys; let sessionIndex: SessionIndex; + let auditLogger: AuditLogger; const indexName = '.kibana_some_tenant_security_session_1'; const indexTemplateName = '.kibana_some_tenant_security_session_index_template_1'; beforeEach(() => { mockElasticsearchClient = elasticsearchServiceMock.createElasticsearchClient(); - const sessionIndexOptions = { + auditLogger = auditServiceMock.create().withoutRequest; + sessionIndex = new SessionIndex({ logger: loggingSystemMock.createLogger(), kibanaIndexName: '.kibana_some_tenant', config: createConfig( @@ -32,9 +42,8 @@ describe('Session index', () => { { isTLSEnabled: false } ), elasticsearchClient: mockElasticsearchClient, - }; - - sessionIndex = new SessionIndex(sessionIndexOptions); + auditLogger, + }); }); describe('#initialize', () => { @@ -219,74 +228,130 @@ describe('Session index', () => { describe('#cleanUp', () => { const now = 123456; + const sessionValue = { + _id: 'SESSION_ID', + _source: { usernameHash: 'USERNAME_HASH', provider: { name: 'basic1', type: 'basic' } }, + sort: [0], + }; beforeEach(() => { - mockElasticsearchClient.deleteByQuery.mockResolvedValue( - securityMock.createApiResponse({ body: {} as any }) + mockElasticsearchClient.openPointInTime.mockResolvedValue( + securityMock.createApiResponse({ + body: { id: 'PIT_ID' } as OpenPointInTimeResponse, + }) + ); + mockElasticsearchClient.closePointInTime.mockResolvedValue( + securityMock.createApiResponse({ + body: { succeeded: true, num_freed: 1 } as ClosePointInTimeResponse, + }) + ); + mockElasticsearchClient.search.mockResolvedValue( + securityMock.createApiResponse({ + body: { + hits: { hits: [sessionValue] }, + } as SearchResponse, + }) + ); + mockElasticsearchClient.bulk.mockResolvedValue( + securityMock.createApiResponse({ + body: { items: [{}] } as BulkResponse, + }) ); jest.spyOn(Date, 'now').mockImplementation(() => now); }); - it('throws if call to Elasticsearch fails', async () => { + it('throws if search call to Elasticsearch fails', async () => { const failureReason = new errors.ResponseError( securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) ); - mockElasticsearchClient.deleteByQuery.mockRejectedValue(failureReason); + mockElasticsearchClient.search.mockRejectedValue(failureReason); await expect(sessionIndex.cleanUp()).rejects.toBe(failureReason); + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).not.toHaveBeenCalled(); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + it('throws if bulk delete call to Elasticsearch fails', async () => { + const failureReason = new errors.ResponseError( + securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } })) + ); + mockElasticsearchClient.bulk.mockRejectedValue(failureReason); + + await expect(sessionIndex.cleanUp()).rejects.toBe(failureReason); + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); }); it('when neither `lifespan` nor `idleTimeout` is configured', async () => { await sessionIndex.cleanUp(); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( - { - index: indexName, - refresh: true, - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledWith({ + _source_includes: 'usernameHash,provider', + sort: '_shard_doc', + track_total_hits: false, + search_after: undefined, + size: 10_000, + pit: { + id: 'PIT_ID', + keep_alive: '5m', + }, + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - }, - }, - ], - minimum_should_match: 1, + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - }, - }, - // The sessions that belong to a particular provider that are expired based on the idle timeout. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, ], - should: [{ range: { idleTimeoutExpiration: { lte: now } } }], minimum_should_match: 1, }, }, - ], + }, }, - }, + // The sessions that belong to a particular provider that are expired based on the idle timeout. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [{ range: { idleTimeoutExpiration: { lte: now } } }], + minimum_should_match: 1, + }, + }, + ], }, }, - { ignore: [409, 404] } + }); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledWith( + { + index: indexName, + operations: [{ delete: { _id: sessionValue._id } }], + refresh: false, + }, + { + ignore: [409, 404], + } ); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); }); it('when only `lifespan` is configured', async () => { @@ -299,69 +364,85 @@ describe('Session index', () => { { isTLSEnabled: false } ), elasticsearchClient: mockElasticsearchClient, + auditLogger: auditServiceMock.create().withoutRequest, }); await sessionIndex.cleanUp(); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( - { - index: indexName, - refresh: true, - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledWith({ + _source_includes: 'usernameHash,provider', + sort: '_shard_doc', + track_total_hits: false, + search_after: undefined, + size: 10_000, + pit: { + id: 'PIT_ID', + keep_alive: '5m', + }, + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - }, - }, - ], - minimum_should_match: 1, + should: [ + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, }, - }, - }, - }, - // The sessions that belong to a particular provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, - }, - }, - // The sessions that belong to a particular provider that are expired based on the idle timeout. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, ], - should: [{ range: { idleTimeoutExpiration: { lte: now } } }], minimum_should_match: 1, }, }, - ], + }, }, - }, + // The sessions that belong to a particular provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, + }, + // The sessions that belong to a particular provider that are expired based on the idle timeout. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [{ range: { idleTimeoutExpiration: { lte: now } } }], + minimum_should_match: 1, + }, + }, + ], }, }, - { ignore: [409, 404] } + }); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledWith( + { + index: indexName, + operations: [{ delete: { _id: sessionValue._id } }], + refresh: false, + }, + { + ignore: [409, 404], + } ); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); }); it('when only `idleTimeout` is configured', async () => { @@ -375,63 +456,79 @@ describe('Session index', () => { { isTLSEnabled: false } ), elasticsearchClient: mockElasticsearchClient, + auditLogger: auditServiceMock.create().withoutRequest, }); await sessionIndex.cleanUp(); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( - { - index: indexName, - refresh: true, - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }, - // The sessions that belong to a particular provider that are either expired based on the idle timeout - // or don't have it configured at all. - { + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledWith({ + _source_includes: 'usernameHash,provider', + sort: '_shard_doc', + track_total_hits: false, + search_after: undefined, + size: 10_000, + pit: { + id: 'PIT_ID', + keep_alive: '5m', + }, + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, + }, ], minimum_should_match: 1, }, }, - ], + }, }, - }, + // The sessions that belong to a particular provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, + }, + ], }, }, - { ignore: [409, 404] } + }); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledWith( + { + index: indexName, + operations: [{ delete: { _id: sessionValue._id } }], + refresh: false, + }, + { + ignore: [409, 404], + } ); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); }); it('when both `lifespan` and `idleTimeout` are configured', async () => { @@ -445,73 +542,89 @@ describe('Session index', () => { { isTLSEnabled: false } ), elasticsearchClient: mockElasticsearchClient, + auditLogger: auditServiceMock.create().withoutRequest, }); await sessionIndex.cleanUp(); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( - { - index: indexName, - refresh: true, - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }, - // The sessions that belong to a particular provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, - }, - }, - // The sessions that belong to a particular provider that are either expired based on the idle timeout - // or don't have it configured at all. - { + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledWith({ + _source_includes: 'usernameHash,provider', + sort: '_shard_doc', + track_total_hits: false, + search_after: undefined, + size: 10_000, + pit: { + id: 'PIT_ID', + keep_alive: '5m', + }, + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic' } }, - ], should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + }, + }, ], minimum_should_match: 1, }, }, - ], + }, }, - }, + // The sessions that belong to a particular provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, + }, + // The sessions that belong to a particular provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, + }, + ], }, }, - { ignore: [409, 404] } + }); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledWith( + { + index: indexName, + operations: [{ delete: { _id: sessionValue._id } }], + refresh: false, + }, + { + ignore: [409, 404], + } ); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); }); it('when both `lifespan` and `idleTimeout` are configured and multiple providers are enabled', async () => { @@ -540,105 +653,167 @@ describe('Session index', () => { { isTLSEnabled: false } ), elasticsearchClient: mockElasticsearchClient, + auditLogger: auditServiceMock.create().withoutRequest, }); await sessionIndex.cleanUp(); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1); - expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith( - { - index: indexName, - refresh: true, - body: { - query: { - bool: { - should: [ - // All expired sessions based on the lifespan, no matter which provider they belong to. - { range: { lifespanExpiration: { lte: now } } }, - // All sessions that belong to the providers that aren't configured. - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], - }, - }, - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }, - // The sessions that belong to a Basic provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, - }, - }, - // The sessions that belong to a Basic provider that are either expired based on the idle timeout - // or don't have it configured at all. - { - bool: { - must: [ - { term: { 'provider.type': 'basic' } }, - { term: { 'provider.name': 'basic1' } }, - ], - should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * globalIdleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, - ], - minimum_should_match: 1, - }, - }, - // The sessions that belong to a SAML provider but don't have a configured lifespan. - { - bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], - must_not: { exists: { field: 'lifespanExpiration' } }, - }, - }, - // The sessions that belong to a SAML provider that are either expired based on the idle timeout - // or don't have it configured at all. - { + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledWith({ + _source_includes: 'usernameHash,provider', + sort: '_shard_doc', + track_total_hits: false, + search_after: undefined, + size: 10_000, + pit: { + id: 'PIT_ID', + keep_alive: '5m', + }, + query: { + bool: { + should: [ + // All expired sessions based on the lifespan, no matter which provider they belong to. + { range: { lifespanExpiration: { lte: now } } }, + // All sessions that belong to the providers that aren't configured. + { + bool: { + must_not: { bool: { - must: [ - { term: { 'provider.type': 'saml' } }, - { term: { 'provider.name': 'saml1' } }, - ], should: [ - { range: { idleTimeoutExpiration: { lte: now - 3 * samlIdleTimeout } } }, - { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + }, + }, + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + }, + }, ], minimum_should_match: 1, }, }, - ], + }, }, - }, + // The sessions that belong to a Basic provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, + }, + // The sessions that belong to a Basic provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'basic' } }, + { term: { 'provider.name': 'basic1' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * globalIdleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, + }, + // The sessions that belong to a SAML provider but don't have a configured lifespan. + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + must_not: { exists: { field: 'lifespanExpiration' } }, + }, + }, + // The sessions that belong to a SAML provider that are either expired based on the idle timeout + // or don't have it configured at all. + { + bool: { + must: [ + { term: { 'provider.type': 'saml' } }, + { term: { 'provider.name': 'saml1' } }, + ], + should: [ + { range: { idleTimeoutExpiration: { lte: now - 3 * samlIdleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, + }, + ], }, }, - { ignore: [409, 404] } + }); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledWith( + { + index: indexName, + operations: [{ delete: { _id: sessionValue._id } }], + refresh: false, + }, + { + ignore: [409, 404], + } + ); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + it('should clean up sessions in batches of 10,000', async () => { + for (const count of [10_000, 1]) { + mockElasticsearchClient.search.mockResolvedValueOnce( + securityMock.createApiResponse({ + body: { + hits: { hits: new Array(count).fill(sessionValue, 0) }, + } as SearchResponse, + }) + ); + } + + await sessionIndex.cleanUp(); + + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(2); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + it('should limit number of batches to 10', async () => { + mockElasticsearchClient.search.mockResolvedValue( + securityMock.createApiResponse({ + body: { + hits: { hits: new Array(10_000).fill(sessionValue, 0) }, + } as SearchResponse, + }) + ); + + await sessionIndex.cleanUp(); + + expect(mockElasticsearchClient.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockElasticsearchClient.search).toHaveBeenCalledTimes(10); + expect(mockElasticsearchClient.bulk).toHaveBeenCalledTimes(10); + expect(mockElasticsearchClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + it('should log audit event', async () => { + await sessionIndex.cleanUp(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: { action: 'session_cleanup', category: ['authentication'], outcome: 'unknown' }, + }) ); }); }); diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index 801597dad6bafd..e064a735bc0316 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -5,9 +5,16 @@ * 2.0. */ +import type { + BulkOperationContainer, + SortResults, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + import type { ElasticsearchClient, Logger } from 'src/core/server'; import type { AuthenticationProvider } from '../../common/model'; +import type { AuditLogger } from '../audit'; +import { sessionCleanupEvent } from '../audit'; import type { ConfigType } from '../config'; export interface SessionIndexOptions { @@ -15,6 +22,7 @@ export interface SessionIndexOptions { readonly kibanaIndexName: string; readonly config: Pick; readonly logger: Logger; + readonly auditLogger: AuditLogger; } /** @@ -34,6 +42,22 @@ export type InvalidateSessionsFilter = */ const SESSION_INDEX_TEMPLATE_VERSION = 1; +/** + * Number of sessions to remove per batch during cleanup. + */ +const SESSION_INDEX_CLEANUP_BATCH_SIZE = 10_000; + +/** + * Maximum number of batches per cleanup. + * If the batch size is 10,000 and this limit is 10, then Kibana will remove up to 100k sessions per cleanup. + */ +const SESSION_INDEX_CLEANUP_BATCH_LIMIT = 10; + +/** + * How long the session cleanup search point-in-time should be kept alive. + */ +const SESSION_INDEX_CLEANUP_KEEP_ALIVE = '5m'; + /** * Returns index template that is used for the current version of the session index. */ @@ -425,6 +449,56 @@ export class SessionIndex { async cleanUp() { this.options.logger.debug(`Running cleanup routine.`); + try { + for await (const sessionValues of this.getSessionValuesInBatches()) { + const operations: Array>> = []; + sessionValues.forEach(({ _id, _source }) => { + const { usernameHash, provider } = _source!; + this.options.auditLogger.log( + sessionCleanupEvent({ sessionId: _id, usernameHash, provider }) + ); + operations.push({ delete: { _id } }); + }); + if (operations.length > 0) { + const { body: bulkResponse } = await this.options.elasticsearchClient.bulk( + { + index: this.indexName, + operations, + refresh: false, + }, + { ignore: [409, 404] } + ); + if (bulkResponse.errors) { + const errorCount = bulkResponse.items.reduce( + (count, item) => (item.delete!.error ? count + 1 : count), + 0 + ); + if (errorCount < bulkResponse.items.length) { + this.options.logger.warn( + `Failed to clean up ${errorCount} of ${bulkResponse.items.length} invalid or expired sessions. The remaining sessions were cleaned up successfully.` + ); + } else { + this.options.logger.error( + `Failed to clean up ${bulkResponse.items.length} invalid or expired sessions.` + ); + } + } else { + this.options.logger.debug( + `Cleaned up ${bulkResponse.items.length} invalid or expired sessions.` + ); + } + } + } + } catch (err) { + this.options.logger.error(`Failed to clean up sessions: ${err.message}`); + throw err; + } + } + + /** + * Fetches session values from session index in batches of 10,000. + */ + private async *getSessionValuesInBatches() { const now = Date.now(); const providersSessionConfig = this.options.config.authc.sortedProviders.map((provider) => { return { @@ -484,24 +558,37 @@ export class SessionIndex { }); } - try { - const { body: response } = await this.options.elasticsearchClient.deleteByQuery( - { - index: this.indexName, - refresh: true, - body: { query: { bool: { should: deleteQueries } } }, - }, - { ignore: [409, 404] } - ); + const { body: openPitResponse } = await this.options.elasticsearchClient.openPointInTime({ + index: this.indexName, + keep_alive: SESSION_INDEX_CLEANUP_KEEP_ALIVE, + }); - if (response.deleted! > 0) { - this.options.logger.debug( - `Cleaned up ${response.deleted} invalid or expired session values.` - ); + try { + let searchAfter: SortResults | undefined; + for (let i = 0; i < SESSION_INDEX_CLEANUP_BATCH_LIMIT; i++) { + const { body: searchResponse } = + await this.options.elasticsearchClient.search({ + pit: { id: openPitResponse.id, keep_alive: SESSION_INDEX_CLEANUP_KEEP_ALIVE }, + _source_includes: 'usernameHash,provider', + query: { bool: { should: deleteQueries } }, + search_after: searchAfter, + size: SESSION_INDEX_CLEANUP_BATCH_SIZE, + sort: '_shard_doc', + track_total_hits: false, // for performance + }); + const { hits } = searchResponse.hits; + if (hits.length > 0) { + yield hits; + searchAfter = hits[hits.length - 1].sort; + } + if (hits.length < SESSION_INDEX_CLEANUP_BATCH_SIZE) { + break; + } } - } catch (err) { - this.options.logger.error(`Failed to clean up sessions: ${err.message}`); - throw err; + } finally { + await this.options.elasticsearchClient.closePointInTime({ + id: openPitResponse.id, + }); } } } diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts index 7e99181981e851..100d0b30082c6e 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.test.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -15,6 +15,8 @@ import type { TaskRunCreatorFunction, } from '../../../task_manager/server'; import { taskManagerMock } from '../../../task_manager/server/mocks'; +import type { AuditLogger } from '../audit'; +import { auditServiceMock } from '../audit/index.mock'; import { ConfigSchema, createConfig } from '../config'; import type { OnlineStatusRetryScheduler } from '../elasticsearch'; import { Session } from './session'; @@ -24,10 +26,23 @@ import { SessionManagementService, } from './session_management_service'; +const mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); +mockSessionIndexInitialize.mockResolvedValue(); + +const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); +mockSessionIndexCleanUp.mockResolvedValue(); + describe('SessionManagementService', () => { let service: SessionManagementService; + let auditLogger: AuditLogger; beforeEach(() => { service = new SessionManagementService(loggingSystemMock.createLogger()); + auditLogger = auditServiceMock.create().withoutRequest; + }); + + afterEach(() => { + mockSessionIndexInitialize.mockReset(); + mockSessionIndexCleanUp.mockReset(); }); describe('setup()', () => { @@ -56,12 +71,9 @@ describe('SessionManagementService', () => { }); describe('start()', () => { - let mockSessionIndexInitialize: jest.SpyInstance; let mockTaskManager: jest.Mocked; let sessionCleanupTaskRunCreator: TaskRunCreatorFunction; beforeEach(() => { - mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); - mockTaskManager = taskManagerMock.createStart(); mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); @@ -84,14 +96,11 @@ describe('SessionManagementService', () => { sessionCleanupTaskRunCreator = createTaskRunner; }); - afterEach(() => { - mockSessionIndexInitialize.mockReset(); - }); - it('exposes proper contract', () => { const mockStatusSubject = new Subject(); expect( service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), @@ -100,10 +109,10 @@ describe('SessionManagementService', () => { ).toEqual({ session: expect.any(Session) }); }); - it('registers proper session index cleanup task runner', () => { - const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); + it('registers proper session index cleanup task runner', async () => { const mockStatusSubject = new Subject(); service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), @@ -113,16 +122,17 @@ describe('SessionManagementService', () => { expect(mockSessionIndexCleanUp).not.toHaveBeenCalled(); const runner = sessionCleanupTaskRunCreator({} as any); - runner.run(); + await runner.run(); expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(1); - runner.run(); + await runner.run(); expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(2); }); it('initializes session index and schedules session index cleanup task when Elasticsearch goes online', async () => { const mockStatusSubject = new Subject(); service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), @@ -160,6 +170,7 @@ describe('SessionManagementService', () => { it('removes old cleanup task if cleanup interval changes', async () => { const mockStatusSubject = new Subject(); service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), @@ -195,6 +206,7 @@ describe('SessionManagementService', () => { it('does not remove old cleanup task if cleanup interval does not change', async () => { const mockStatusSubject = new Subject(); service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), @@ -221,6 +233,7 @@ describe('SessionManagementService', () => { it('schedules retry if index initialization fails', async () => { const mockStatusSubject = new Subject(); service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), @@ -257,6 +270,7 @@ describe('SessionManagementService', () => { it('schedules retry if cleanup task registration fails', async () => { const mockStatusSubject = new Subject(); service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), @@ -291,11 +305,8 @@ describe('SessionManagementService', () => { }); describe('stop()', () => { - let mockSessionIndexInitialize: jest.SpyInstance; let mockTaskManager: jest.Mocked; beforeEach(() => { - mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); - mockTaskManager = taskManagerMock.createStart(); mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); @@ -309,13 +320,10 @@ describe('SessionManagementService', () => { }); }); - afterEach(() => { - mockSessionIndexInitialize.mockReset(); - }); - it('properly unsubscribes from status updates', () => { const mockStatusSubject = new Subject(); service.start({ + auditLogger, elasticsearchClient: elasticsearchServiceMock.createElasticsearchClient(), kibanaIndexName: '.kibana', online$: mockStatusSubject.asObservable(), diff --git a/x-pack/plugins/security/server/session_management/session_management_service.ts b/x-pack/plugins/security/server/session_management/session_management_service.ts index fcd8e8c53cbe50..03a5d6130c3c17 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -14,6 +14,7 @@ import type { TaskManagerSetupContract, TaskManagerStartContract, } from '../../../task_manager/server'; +import type { AuditLogger } from '../audit'; import type { ConfigType } from '../config'; import type { OnlineStatusRetryScheduler } from '../elasticsearch'; import { Session } from './session'; @@ -31,6 +32,7 @@ export interface SessionManagementServiceStartParams { readonly kibanaIndexName: string; readonly online$: Observable; readonly taskManager: TaskManagerStartContract; + readonly auditLogger: AuditLogger; } export interface SessionManagementServiceStart { @@ -78,12 +80,14 @@ export class SessionManagementService { kibanaIndexName, online$, taskManager, + auditLogger, }: SessionManagementServiceStartParams): SessionManagementServiceStart { this.sessionIndex = new SessionIndex({ config: this.config, elasticsearchClient, kibanaIndexName, logger: this.logger.get('index'), + auditLogger, }); this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { From 25d1c669dcb85c1a73eda7fd573ef79c2e8c84a5 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 12 Jan 2022 16:09:01 -0700 Subject: [PATCH 11/29] [Security Solutions] Fixes deleting of alerts to also delete legacy notifications by migrating them before the deletion (#122610) ## Summary Bug: https://github.com/elastic/kibana/issues/122456, https://github.com/elastic/kibana/issues/113055 Previously when we brought back the legacy notifications we did not migrate them on delete so the legacy notifications end up becoming dangling alerts that run forever with warns in your log files like so: ```sh [2022-01-06T10:09:16.593-07:00][ERROR][plugins.alerting] Executing Rule frank-test:siem.notifications:549ac8c0-6f11-11ec-b6be-193e9dcb2837 has resulted in Error: Saved object [alert/36fe8520-6e7d-11ec-83a2-150f18947658] not found ``` This fixes the bug by migrating the actions first before deleting them in the routes of: * `delete_rules_route.ts` * `delete_rules_bulk_route.ts` That allows the action to be returned in the response body on successful delete and also deletes the legacy notification. I could not find a good way to turn off the legacy notification from within its self so for now I updated the logging message to include instructions on how to remove legacy notifications that are dangling or to upgrade/update from legacy to non-legacy in the log messages. Also, this changes the line of code for deleting legacy actions from: ```ts rulesClient.find({ options: { hasReference: { <-- This currently will delete the first found rule that happens to have a reference of the `rule.id` That should be only legacy notifications today but tomorrow or later could be a different rule by accident. type: 'alert', id: rule.id, }, }, }), ``` to ```ts rulesClient.find({ options: { filter: 'alert.attributes.alertTypeId:(siem.notifications)', <--- This filter should ensure we only delete `siem.notifications` as extra safety measure. hasReference: { type: 'alert', id: rule.id, }, }, }), ``` **Manual testing** Either you can use the `internal` rule route endpoints or do a true upgrade test scenario. For these manual testing steps I am going to outline the developer fast way of adding a legacy rule and then deleting it from there. Create a rule and activate it normally within security_solution: create_rule Do not add actions to the rule at this point as we will first exercise the older legacy system. However, you want at least one action configured such as a slack notification: define_connector Within dev tools do a query for all your actions and grab one of the _id of them without their prefix: ```sh GET .kibana/_search { "query": { "term": { "type": "action" } } } ``` Go to the file detection_engine/scripts/legacy_notifications/one_action.json and add this id to the file. Something like this: Mine was `_id : action:879e8ff0-1be1-11ec-a722-83da1c22a481`, so I will be copying the ID of `879e8ff0-1be1-11ec-a722-83da1c22a481`: ```json { "name": "Legacy notification with one action", "interval": "1m", <--- You can use whatever you want. Real values are "1h", "1d", "1w". I use "1m" for testing purposes. "actions": [ { "id": "879e8ff0-1be1-11ec-a722-83da1c22a481", <--- My action id "group": "default", "params": { "message": "Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts" }, "actionTypeId": ".slack" <--- I am a slack action id type. } ] } ``` Query for an alert you want to add manually add back a legacy notification to it. Such as: ```json GET .kibana/_search { "query": { "term": { "alert.alertTypeId": "siem.queryRule" } } } ``` Grab the `_id` without the alert prefix. For mine this was `933ca720-1be1-11ec-a722-83da1c22a481`: Within the directory of detection_engine/scripts execute the script: ```sh ./post_legacy_notification.sh 933ca720-1be1-11ec-a722-83da1c22a481 { "ok": "acknowledged" } ``` which is going to do a few things. See the file detection_engine/routes/rules/legacy_create_legacy_notification.ts for the definition of the route and what it does in full, but we should notice that we have now have actions on the rule: edit_rule If you query for legacy notifications you should see the one you have: ```sh GET .kibana/_search { "query": { "term": { "alert.alertTypeId": "siem.notifications" } } } ``` If you query for saved objects associated such as `siem-detection-engine-rule-actions`: ```sh GET .kibana/_search { "query": { "term": { "type": { "value": "siem-detection-engine-rule-actions" } } } } ``` You should also see 1. After you delete the rule, both should be gone if you repeat the query above now. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- ...gacy_rules_notification_alert_type.test.ts | 2 +- .../legacy_rules_notification_alert_type.ts | 19 ++- .../routes/rules/delete_rules_bulk_route.ts | 15 +- .../routes/rules/delete_rules_route.ts | 18 ++- .../lib/detection_engine/rules/utils.ts | 3 +- .../security_and_spaces/tests/delete_rules.ts | 116 ++++++++++++++ .../tests/delete_rules_bulk.ts | 145 ++++++++++++++++++ .../detection_engine_api_integration/utils.ts | 1 + 8 files changed, 301 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.test.ts index 6c0ffa65a9afa2..f064380cc4a13a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.test.ts @@ -88,7 +88,7 @@ describe('legacyRules_notification_alert_type', () => { }); await alert.executor(payload); expect(logger.error).toHaveBeenCalledWith( - `Security Solution notification (Legacy) saved object for alert ${payload.params.ruleAlertId} was not found` + `Security Solution notification (Legacy) saved object for alert ${payload.params.ruleAlertId} was not found with id: \"1111\". space id: \"\" This indicates a dangling (Legacy) notification alert. You should delete this rule through \"Kibana UI -> Stack Management -> Rules and Connectors\" to remove this error message.` ); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.ts index bbcee3897d23e2..6a5a9478681f3c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.ts @@ -51,11 +51,7 @@ export const legacyRulesNotificationAlertType = ({ }, minimumLicenseRequired: 'basic', isExportable: false, - async executor({ startedAt, previousStartedAt, alertId, services, params }) { - // TODO: Change this to be a link to documentation on how to migrate: https://github.com/elastic/kibana/issues/113055 - logger.warn( - 'Security Solution notification (Legacy) system detected still running. Please see documentation on how to migrate to the new notification system.' - ); + async executor({ startedAt, previousStartedAt, alertId, services, params, spaceId }) { const ruleAlertSavedObject = await services.savedObjectsClient.get( 'alert', params.ruleAlertId @@ -63,17 +59,26 @@ export const legacyRulesNotificationAlertType = ({ if (!ruleAlertSavedObject.attributes.params) { logger.error( - `Security Solution notification (Legacy) saved object for alert ${params.ruleAlertId} was not found` + [ + `Security Solution notification (Legacy) saved object for alert ${params.ruleAlertId} was not found with`, + `id: "${alertId}".`, + `space id: "${spaceId}"`, + 'This indicates a dangling (Legacy) notification alert.', + 'You should delete this rule through "Kibana UI -> Stack Management -> Rules and Connectors" to remove this error message.', + ].join(' ') ); return; } + logger.warn( [ 'Security Solution notification (Legacy) system still active for alert with', `name: "${ruleAlertSavedObject.attributes.name}"`, `description: "${ruleAlertSavedObject.attributes.params.description}"`, `id: "${ruleAlertSavedObject.id}".`, - `Please see documentation on how to migrate to the new notification system.`, + `space id: "${spaceId}"`, + 'Editing or updating this rule through "Kibana UI -> Security -> Alerts -> Manage Rules"', + 'will auto-migrate the rule to the new notification system and remove this warning message.', ].join(' ') ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index 054238cf6fa45d..149227084ace06 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -25,6 +25,7 @@ import { transformValidateBulkError } from './validate'; import { transformBulkError, buildSiemResponse, createBulkErrorObject } from '../utils'; import { deleteRules } from '../../rules/delete_rules'; import { readRules } from '../../rules/read_rules'; +import { legacyMigrate } from '../../rules/utils'; type Config = RouteConfig; type Handler = RequestHandler< @@ -60,6 +61,7 @@ export const deleteRulesBulkRoute = ( } const ruleStatusClient = context.securitySolution.getExecutionLogClient(); + const savedObjectsClient = context.core.savedObjects.client; const rules = await Promise.all( request.body.map(async (payloadRule) => { @@ -76,22 +78,27 @@ export const deleteRulesBulkRoute = ( try { const rule = await readRules({ rulesClient, id, ruleId, isRuleRegistryEnabled }); - if (!rule) { + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule, + }); + if (!migratedRule) { return getIdBulkError({ id, ruleId }); } const ruleStatus = await ruleStatusClient.getCurrentStatus({ - ruleId: rule.id, + ruleId: migratedRule.id, spaceId: context.securitySolution.getSpaceId(), }); await deleteRules({ - ruleId: rule.id, + ruleId: migratedRule.id, rulesClient, ruleStatusClient, }); return transformValidateBulkError( idOrRuleIdOrUnknown, - rule, + migratedRule, ruleStatus, isRuleRegistryEnabled ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts index abcf0d07a33b68..3bb7778e5bc5ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -19,6 +19,7 @@ import { getIdError, transform } from './utils'; import { buildSiemResponse } from '../utils'; import { readRules } from '../../rules/read_rules'; +import { legacyMigrate } from '../../rules/utils'; export const deleteRulesRoute = ( router: SecuritySolutionPluginRouter, @@ -47,14 +48,20 @@ export const deleteRulesRoute = ( const { id, rule_id: ruleId } = request.query; const rulesClient = context.alerting?.getRulesClient(); - + const savedObjectsClient = context.core.savedObjects.client; if (!rulesClient) { return siemResponse.error({ statusCode: 404 }); } const ruleStatusClient = context.securitySolution.getExecutionLogClient(); const rule = await readRules({ isRuleRegistryEnabled, rulesClient, id, ruleId }); - if (!rule) { + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule, + }); + + if (!migratedRule) { const error = getIdError({ id, ruleId }); return siemResponse.error({ body: error.message, @@ -63,15 +70,16 @@ export const deleteRulesRoute = ( } const currentStatus = await ruleStatusClient.getCurrentStatus({ - ruleId: rule.id, + ruleId: migratedRule.id, spaceId: context.securitySolution.getSpaceId(), }); + await deleteRules({ - ruleId: rule.id, + ruleId: migratedRule.id, rulesClient, ruleStatusClient, }); - const transformed = transform(rule, currentStatus, isRuleRegistryEnabled); + const transformed = transform(migratedRule, currentStatus, isRuleRegistryEnabled); if (transformed == null) { return siemResponse.error({ statusCode: 500, body: 'failed to transform alert' }); } else { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index dee2006669f85d..73039697268e62 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -316,7 +316,7 @@ export const legacyMigrate = async ({ } /** * On update / patch I'm going to take the actions as they are, better off taking rules client.find (siem.notification) result - * and putting that into the actions array of the rule, then set the rules onThrottle property, notifyWhen and throttle from null -> actualy value (1hr etc..) + * and putting that into the actions array of the rule, then set the rules onThrottle property, notifyWhen and throttle from null -> actual value (1hr etc..) * Then use the rules client to delete the siem.notification * Then with the legacy Rule Actions saved object type, just delete it. */ @@ -325,6 +325,7 @@ export const legacyMigrate = async ({ const [siemNotification, legacyRuleActionsSO] = await Promise.all([ rulesClient.find({ options: { + filter: 'alert.attributes.alertTypeId:(siem.notifications)', hasReference: { type: 'alert', id: rule.id, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts index e5f828d0f862d4..19076506998a97 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts @@ -7,9 +7,11 @@ import expect from '@kbn/expect'; +import { BASE_ALERTING_API_PATH } from '../../../../plugins/alerting/common'; import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { + createLegacyRuleAction, createRule, createSignalsIndex, deleteAllAlerts, @@ -18,6 +20,7 @@ import { getSimpleRuleOutput, getSimpleRuleOutputWithoutRuleId, getSimpleRuleWithoutRuleId, + getWebHookAction, removeServerGeneratedProperties, removeServerGeneratedPropertiesIncludingRuleId, } from '../../utils'; @@ -100,6 +103,119 @@ export default ({ getService }: FtrProviderContext): void => { status_code: 404, }); }); + + /** + * @deprecated Once the legacy notification system is removed, remove this test too. + */ + it('should have exactly 1 legacy action before a delete within alerting', async () => { + // create an action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule without actions + const createRuleBody = await createRule(supertest, log, getSimpleRule('rule-1')); + + // Add a legacy rule action to the body of the rule + await createLegacyRuleAction(supertest, createRuleBody.id, hookAction.id); + + // Test to ensure that we have exactly 1 legacy action by querying the Alerting client REST API directly + // See: https://www.elastic.co/guide/en/kibana/current/find-rules-api.html + // Note: We specifically query for both the filter of type "siem.notifications" and the "has_reference" to keep it very specific + const { body: alertFind } = await supertest + .get(`${BASE_ALERTING_API_PATH}/rules/_find`) + .query({ + page: 1, + per_page: 10, + filter: 'alert.attributes.alertTypeId:(siem.notifications)', + has_reference: JSON.stringify({ id: createRuleBody.id, type: 'alert' }), + }) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + // Expect that we have exactly 1 legacy rule before the deletion + expect(alertFind.total).to.eql(1); + }); + + /** + * @deprecated Once the legacy notification system is removed, remove this test too. + */ + it('should return the legacy action in the response body when it deletes a rule that has one', async () => { + // create an action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule without actions + const createRuleBody = await createRule(supertest, log, getSimpleRule('rule-1')); + + // Add a legacy rule action to the body of the rule + await createLegacyRuleAction(supertest, createRuleBody.id, hookAction.id); + + // delete the rule with the legacy action + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}?id=${createRuleBody.id}`) + .set('kbn-xsrf', 'true') + .expect(200); + + // ensure the actions contains the response + expect(body.actions).to.eql([ + { + id: hookAction.id, + action_type_id: hookAction.actionTypeId, + group: 'default', + params: { + message: + 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + }, + ]); + }); + + /** + * @deprecated Once the legacy notification system is removed, remove this test too. + */ + it('should delete a legacy action when it deletes a rule that has one', async () => { + // create an action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule without actions + const createRuleBody = await createRule(supertest, log, getSimpleRule('rule-1')); + + // Add a legacy rule action to the body of the rule + await createLegacyRuleAction(supertest, createRuleBody.id, hookAction.id); + + await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}?id=${createRuleBody.id}`) + .set('kbn-xsrf', 'true') + .expect(200); + + // Test to ensure that we have exactly 0 legacy actions by querying the Alerting client REST API directly + // See: https://www.elastic.co/guide/en/kibana/current/find-rules-api.html + // Note: We specifically query for both the filter of type "siem.notifications" and the "has_reference" to keep it very specific + const { body: bodyAfterDelete } = await supertest + .get(`${BASE_ALERTING_API_PATH}/rules/_find`) + .query({ + page: 1, + per_page: 10, + filter: 'alert.attributes.alertTypeId:(siem.notifications)', + has_reference: JSON.stringify({ id: createRuleBody.id, type: 'alert' }), + }) + .set('kbn-xsrf', 'true') + .send(); + + // Expect that we have exactly 0 legacy rules after the deletion + expect(bodyAfterDelete.total).to.eql(0); + }); }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts index b7517697ad2a94..69be1f2eb0affa 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts @@ -7,9 +7,11 @@ import expect from '@kbn/expect'; +import { BASE_ALERTING_API_PATH } from '../../../../plugins/alerting/common'; import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { + createLegacyRuleAction, createRule, createSignalsIndex, deleteAllAlerts, @@ -18,6 +20,7 @@ import { getSimpleRuleOutput, getSimpleRuleOutputWithoutRuleId, getSimpleRuleWithoutRuleId, + getWebHookAction, removeServerGeneratedProperties, removeServerGeneratedPropertiesIncludingRuleId, } from '../../utils'; @@ -249,6 +252,148 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); }); + + /** + * @deprecated Once the legacy notification system is removed, remove this test too. + */ + it('should return the legacy action in the response body when it deletes a rule that has one', async () => { + // create an action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule without actions + const createRuleBody = await createRule(supertest, log, getSimpleRule('rule-1')); + + // Add a legacy rule action to the body of the rule + await createLegacyRuleAction(supertest, createRuleBody.id, hookAction.id); + + // delete the rule with the legacy action + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: createRuleBody.id }]) + .set('kbn-xsrf', 'true') + .expect(200); + + // ensure we only get one body back + expect(body.length).to.eql(1); + + // ensure that its actions equal what we expect + expect(body[0].actions).to.eql([ + { + id: hookAction.id, + action_type_id: hookAction.actionTypeId, + group: 'default', + params: { + message: + 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + }, + ]); + }); + + /** + * @deprecated Once the legacy notification system is removed, remove this test too. + */ + it('should return 2 legacy actions in the response body when it deletes 2 rules', async () => { + // create two different actions + const { body: hookAction1 } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + const { body: hookAction2 } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create 2 rules without actions + const createRuleBody1 = await createRule(supertest, log, getSimpleRule('rule-1')); + const createRuleBody2 = await createRule(supertest, log, getSimpleRule('rule-2')); + + // Add a legacy rule action to the body of the 2 rules + await createLegacyRuleAction(supertest, createRuleBody1.id, hookAction1.id); + await createLegacyRuleAction(supertest, createRuleBody2.id, hookAction2.id); + + // delete 2 rules where both have legacy actions + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: createRuleBody1.id }, { id: createRuleBody2.id }]) + .set('kbn-xsrf', 'true') + .expect(200); + + // ensure we only get two bodies back + expect(body.length).to.eql(2); + + // ensure that its actions equal what we expect for both responses + expect(body[0].actions).to.eql([ + { + id: hookAction1.id, + action_type_id: hookAction1.actionTypeId, + group: 'default', + params: { + message: + 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + }, + ]); + expect(body[1].actions).to.eql([ + { + id: hookAction2.id, + action_type_id: hookAction2.actionTypeId, + group: 'default', + params: { + message: + 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + }, + ]); + }); + + /** + * @deprecated Once the legacy notification system is removed, remove this test too. + */ + it('should delete a legacy action when it deletes a rule that has one', async () => { + // create an action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule without actions + const createRuleBody = await createRule(supertest, log, getSimpleRule('rule-1')); + + // Add a legacy rule action to the body of the rule + await createLegacyRuleAction(supertest, createRuleBody.id, hookAction.id); + + // bulk delete the rule + await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: createRuleBody.id }]) + .set('kbn-xsrf', 'true') + .expect(200); + + // Test to ensure that we have exactly 0 legacy actions by querying the Alerting client REST API directly + // See: https://www.elastic.co/guide/en/kibana/current/find-rules-api.html + // Note: We specifically query for both the filter of type "siem.notifications" and the "has_reference" to keep it very specific + const { body: bodyAfterDelete } = await supertest + .get(`${BASE_ALERTING_API_PATH}/rules/_find`) + .query({ + page: 1, + per_page: 10, + filter: 'alert.attributes.alertTypeId:(siem.notifications)', + has_reference: JSON.stringify({ id: createRuleBody.id, type: 'alert' }), + }) + .set('kbn-xsrf', 'true') + .send(); + + // Expect that we have exactly 0 legacy rules after the deletion + expect(bodyAfterDelete.total).to.eql(0); + }); }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 2ebaed7defe674..2f9fba7430d593 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -604,6 +604,7 @@ export const createLegacyRuleAction = async ( }, ], }); + /** * Deletes the signals index for use inside of afterEach blocks of tests * @param supertest The supertest client library From bbf2906aa3b37f72da099bb776d5ffe15a3a05b7 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 12 Jan 2022 18:01:37 -0700 Subject: [PATCH 12/29] [Reporting] Event Log integration (#119923) * [Reporting] Use Event log for monitoring in csv_searchsource_immediate * event logger class * factory function to put service in closure * complete the class * remove some mappings * instrument generateCsv to return num_rows * fix ts * cleanup * remove error action type add event.outcome field * unit test for ReportingEventLogger class * fix username capture * api doc comment * add integration for all export types * descope metrics collection * fix snapshots * simplify * log execution start and complete in queued reports * log user field * use task namespace for task.id * add byteSize metric * use reporting.id instead of event.id * pass report and task objects to getEventLogger * fix getEventLogger in csv_searchsource_immediate * use internal string for message parameter in the log methods * add more logError in ReportingStore Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/event_log/generated/mappings.json | 18 +- x-pack/plugins/event_log/generated/schemas.ts | 8 + x-pack/plugins/event_log/scripts/mappings.js | 17 ++ x-pack/plugins/reporting/kibana.json | 1 + x-pack/plugins/reporting/server/core.ts | 12 +- .../server/lib/event_logger/index.ts | 22 ++ .../server/lib/event_logger/logger.test.ts | 250 ++++++++++++++++++ .../server/lib/event_logger/logger.ts | 200 ++++++++++++++ .../server/lib/event_logger/types.ts | 68 +++++ .../reporting/server/lib/store/index.ts | 7 + .../reporting/server/lib/store/store.ts | 124 +++++---- .../server/lib/tasks/execute_report.ts | 12 + .../server/lib/tasks/monitor_reports.ts | 3 + .../plugins/reporting/server/plugin.test.ts | 52 ++-- x-pack/plugins/reporting/server/plugin.ts | 9 +- .../generate/csv_searchsource_immediate.ts | 15 +- .../server/routes/lib/request_handler.ts | 3 + .../create_mock_reportingplugin.ts | 4 + x-pack/plugins/reporting/server/types.ts | 2 + x-pack/plugins/reporting/tsconfig.json | 1 + 20 files changed, 734 insertions(+), 94 deletions(-) create mode 100644 x-pack/plugins/reporting/server/lib/event_logger/index.ts create mode 100644 x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts create mode 100644 x-pack/plugins/reporting/server/lib/event_logger/logger.ts create mode 100644 x-pack/plugins/reporting/server/lib/event_logger/types.ts diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index aba23eef79e3f8..0627673ceff931 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -239,6 +239,9 @@ }, "task": { "properties": { + "id": { + "type": "keyword" + }, "scheduled": { "type": "date" }, @@ -303,6 +306,19 @@ } } }, + "reporting": { + "properties": { + "id": { + "type": "keyword" + }, + "jobType": { + "type": "keyword" + }, + "byteSize": { + "type": "long" + } + } + }, "saved_objects": { "type": "nested", "properties": { @@ -341,4 +357,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index e73bafd9cb81ee..b0ec7e78d28757 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -104,6 +104,7 @@ export const EventSchema = schema.maybe( server_uuid: ecsString(), task: schema.maybe( schema.object({ + id: ecsString(), scheduled: ecsDate(), schedule_delay: ecsNumber(), }) @@ -138,6 +139,13 @@ export const EventSchema = schema.maybe( ), }) ), + reporting: schema.maybe( + schema.object({ + id: ecsString(), + jobType: ecsString(), + byteSize: ecsNumber(), + }) + ), saved_objects: schema.maybe( schema.arrayOf( schema.object({ diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index 231cc225f7c471..d96cb8f1d6dfc1 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -20,6 +20,9 @@ exports.EcsCustomPropertyMappings = { // task specific fields task: { properties: { + id: { + type: 'keyword', + }, scheduled: { type: 'date', }, @@ -85,6 +88,20 @@ exports.EcsCustomPropertyMappings = { }, }, }, + // reporting specific fields + reporting: { + properties: { + id: { + type: 'keyword', + }, + jobType: { + type: 'keyword', + }, + byteSize: { + type: 'long', + }, + }, + }, // array of saved object references, for "linking" via search saved_objects: { type: 'nested', diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index 123c23e5e1c29c..57cfc25c4b5288 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -17,6 +17,7 @@ "licensing", "uiActions", "taskManager", + "eventLog", "embeddable", "screenshotting", "screenshotMode", diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 232d32aeff10b6..e48983634efd88 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -8,7 +8,6 @@ import Hapi from '@hapi/hapi'; import * as Rx from 'rxjs'; import { filter, first, map, switchMap, take } from 'rxjs/operators'; -import type { ScreenshottingStart, ScreenshotResult } from '../../screenshotting/server'; import { BasePath, IClusterClient, @@ -22,8 +21,10 @@ import { UiSettingsServiceStart, } from '../../../../src/core/server'; import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; +import { IEventLogService } from '../../event_log/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; +import type { ScreenshotResult, ScreenshottingStart } from '../../screenshotting/server'; import { SecurityPluginSetup } from '../../security/server'; import { DEFAULT_SPACE_ID } from '../../spaces/common/constants'; import { SpacesPluginSetup } from '../../spaces/server'; @@ -33,11 +34,13 @@ import { durationToNumber } from '../common/schema_utils'; import { ReportingConfig, ReportingSetup } from './'; import { ReportingConfigType } from './config'; import { checkLicense, getExportTypesRegistry, LevelLogger } from './lib'; -import { ReportingStore } from './lib/store'; +import { reportingEventLoggerFactory } from './lib/event_logger/logger'; +import { IReport, ReportingStore } from './lib/store'; import { ExecuteReportTask, MonitorReportsTask, ReportTaskParams } from './lib/tasks'; import { ReportingPluginRouter, ScreenshotOptions } from './types'; export interface ReportingInternalSetup { + eventLog: IEventLogService; basePath: Pick; router: ReportingPluginRouter; features: FeaturesPluginSetup; @@ -381,4 +384,9 @@ export class ReportingCore { public countConcurrentReports(): number { return this.executing.size; } + + public getEventLogger(report: IReport, task?: { id: string }) { + const ReportingEventLogger = reportingEventLoggerFactory(this.pluginSetupDeps!.eventLog); + return new ReportingEventLogger(report, task); + } } diff --git a/x-pack/plugins/reporting/server/lib/event_logger/index.ts b/x-pack/plugins/reporting/server/lib/event_logger/index.ts new file mode 100644 index 00000000000000..566f0a21e2b05d --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/event_logger/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEventLogService } from '../../../../event_log/server'; +import { PLUGIN_ID } from '../../../common/constants'; + +export enum ActionType { + SCHEDULE_TASK = 'schedule-task', + CLAIM_TASK = 'claim-task', + EXECUTE_START = 'execute-start', + EXECUTE_COMPLETE = 'execute-complete', + SAVE_REPORT = 'save-report', + RETRY = 'retry', + FAIL_REPORT = 'fail-report', +} +export function registerEventLogProviderActions(eventLog: IEventLogService) { + eventLog.registerProviderActions(PLUGIN_ID, Object.values(ActionType)); +} diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts new file mode 100644 index 00000000000000..21c4ee2d5e4cf9 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConcreteTaskInstance } from '../../../../task_manager/server'; +import { eventLogServiceMock } from '../../../../event_log/server/mocks'; +import { BasePayload } from '../../types'; +import { Report } from '../store'; +import { ReportingEventLogger, reportingEventLoggerFactory } from './logger'; + +describe('Event Logger', () => { + const mockReport = new Report({ + _id: '12348', + payload: { browserTimezone: 'UTC' } as BasePayload, + jobtype: 'csv', + }); + + let factory: ReportingEventLogger; + + beforeEach(() => { + factory = reportingEventLoggerFactory(eventLogServiceMock.create()); + }); + + it(`should construct with an internal seed object`, () => { + const logger = new factory(mockReport); + expect(logger.eventObj).toMatchInlineSnapshot(` + Object { + "event": Object { + "provider": "reporting", + "timezone": "UTC", + }, + "kibana": Object { + "reporting": Object { + "id": "12348", + "jobType": "csv", + }, + }, + "log": Object { + "logger": "reporting", + }, + "user": undefined, + } + `); + }); + + it(`allows optional user name`, () => { + const logger = new factory(new Report({ ...mockReport, created_by: 'thundercat' })); + expect(logger.eventObj).toMatchInlineSnapshot(` + Object { + "event": Object { + "provider": "reporting", + "timezone": "UTC", + }, + "kibana": Object { + "reporting": Object { + "id": "12348", + "jobType": "csv", + }, + }, + "log": Object { + "logger": "reporting", + }, + "user": Object { + "name": "thundercat", + }, + } + `); + }); + + it(`allows optional task.id`, () => { + const logger = new factory(new Report({ ...mockReport, created_by: 'thundercat' }), { + id: 'some-task-id-123', + } as ConcreteTaskInstance); + expect(logger.eventObj).toMatchInlineSnapshot(` + Object { + "event": Object { + "provider": "reporting", + "timezone": "UTC", + }, + "kibana": Object { + "reporting": Object { + "id": "12348", + "jobType": "csv", + }, + "task": Object { + "id": "some-task-id-123", + }, + }, + "log": Object { + "logger": "reporting", + }, + "user": Object { + "name": "thundercat", + }, + } + `); + }); + + it(`logExecutionStart`, () => { + const logger = new factory(mockReport); + const result = logger.logExecutionStart(); + expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` + Array [ + Object { + "action": "execute-start", + "kind": "event", + "provider": "reporting", + "timezone": "UTC", + }, + Object { + "id": "12348", + "jobType": "csv", + }, + "starting csv execution", + ] + `); + expect(result.message).toMatchInlineSnapshot(`"starting csv execution"`); + expect(logger.completionLogger.startTiming).toBeCalled(); + }); + + it(`logExecutionComplete`, () => { + const logger = new factory(mockReport); + logger.logExecutionStart(); + + const result = logger.logExecutionComplete({ byteSize: 444 }); + expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` + Array [ + Object { + "action": "execute-complete", + "kind": "metrics", + "outcome": "success", + "provider": "reporting", + "timezone": "UTC", + }, + Object { + "byteSize": 444, + "id": "12348", + "jobType": "csv", + }, + "completed csv execution", + ] + `); + expect(result.message).toMatchInlineSnapshot(`"completed csv execution"`); + expect(logger.completionLogger.startTiming).toBeCalled(); + expect(logger.completionLogger.stopTiming).toBeCalled(); + }); + + it(`logError`, () => { + const logger = new factory(mockReport); + const result = logger.logError(new Error('an error occurred')); + expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` + Array [ + Object { + "action": "execute-complete", + "kind": "error", + "outcome": "failure", + "provider": "reporting", + "timezone": "UTC", + }, + Object { + "id": "12348", + "jobType": "csv", + }, + "an error occurred", + ] + `); + expect(result.message).toBe(`an error occurred`); + }); + + it(`logClaimTask`, () => { + const logger = new factory(mockReport); + const result = logger.logClaimTask(); + expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` + Array [ + Object { + "action": "claim-task", + "kind": "event", + "provider": "reporting", + "timezone": "UTC", + }, + Object { + "id": "12348", + "jobType": "csv", + }, + "claimed report 12348", + ] + `); + }); + + it(`logReportFailure`, () => { + const logger = new factory(mockReport); + const result = logger.logReportFailure(); + expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` + Array [ + Object { + "action": "fail-report", + "kind": "event", + "provider": "reporting", + "timezone": "UTC", + }, + Object { + "id": "12348", + "jobType": "csv", + }, + "report 12348 has failed", + ] + `); + }); + it(`logReportSaved`, () => { + const logger = new factory(mockReport); + const result = logger.logReportSaved(); + expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` + Array [ + Object { + "action": "save-report", + "kind": "event", + "provider": "reporting", + "timezone": "UTC", + }, + Object { + "id": "12348", + "jobType": "csv", + }, + "saved report 12348", + ] + `); + }); + it(`logRetry`, () => { + const logger = new factory(mockReport); + const result = logger.logRetry(); + expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` + Array [ + Object { + "action": "retry", + "kind": "event", + "provider": "reporting", + "timezone": "UTC", + }, + Object { + "id": "12348", + "jobType": "csv", + }, + "scheduled retry for report 12348", + ] + `); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts new file mode 100644 index 00000000000000..0ec864e36620b4 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import deepMerge from 'deepmerge'; +import { IEventLogger, IEventLogService } from '../../../../event_log/server'; +import { PLUGIN_ID } from '../../../common/constants'; +import { IReport } from '../store'; +import { ActionType } from './'; +import { + ClaimedTask, + CompletedExecution, + ErrorAction, + ExecuteError, + FailedReport, + SavedReport, + ScheduledRetry, + ScheduledTask, + StartedExecution, +} from './types'; + +/** @internal */ +export interface ExecutionCompleteMetrics { + byteSize: number; +} + +/** @internal */ +export function reportingEventLoggerFactory(eventLog: IEventLogService) { + const genericLogger = eventLog.getLogger({ event: { provider: PLUGIN_ID } }); + + return class ReportingEventLogger { + readonly eventObj: { + event: { + timezone: string; + provider: 'reporting'; + }; + kibana: { reporting: StartedExecution['kibana']['reporting']; task?: { id: string } }; + log: { logger: 'reporting' }; + user?: { name: string }; + }; + + readonly report: IReport; + readonly task?: { id: string }; + + completionLogger: IEventLogger; + + constructor(report: IReport, task?: { id: string }) { + this.report = report; + this.task = task; + this.eventObj = { + event: { timezone: report.payload.browserTimezone, provider: 'reporting' }, + kibana: { + reporting: { id: report._id, jobType: report.jobtype }, + ...(task?.id ? { task: { id: task.id } } : undefined), + }, + log: { logger: 'reporting' }, + user: report.created_by ? { name: report.created_by } : undefined, + }; + + // create a "complete" logger that will use EventLog helpers to calculate timings + this.completionLogger = eventLog.getLogger({ event: { provider: PLUGIN_ID } }); + } + + logScheduleTask(): ScheduledTask { + const event = deepMerge( + { + message: `queued report ${this.report._id}`, + event: { kind: 'event', action: ActionType.SCHEDULE_TASK }, + log: { level: 'info' }, + } as Partial, + this.eventObj + ); + + genericLogger.logEvent(event); + return event; + } + + logExecutionStart(): StartedExecution { + this.completionLogger.startTiming(this.eventObj); + const event = deepMerge( + { + message: `starting ${this.report.jobtype} execution`, + event: { kind: 'event', action: ActionType.EXECUTE_START }, + log: { level: 'info' }, + } as Partial, + this.eventObj + ); + + genericLogger.logEvent(event); + return event; + } + + logExecutionComplete({ byteSize }: ExecutionCompleteMetrics): CompletedExecution { + this.completionLogger.stopTiming(this.eventObj); + const event = deepMerge( + { + message: `completed ${this.report.jobtype} execution`, + event: { + kind: 'metrics', + outcome: 'success', + action: ActionType.EXECUTE_COMPLETE, + }, + kibana: { reporting: { byteSize } }, + log: { level: 'info' }, + } as Partial, + this.eventObj + ); + this.completionLogger.logEvent(event); + return event; + } + + logError(error: ErrorAction): ExecuteError { + interface LoggedErrorMessage { + message: string; + error: ExecuteError['error']; + event: Omit; + log: Omit; + } + const logErrorMessage: LoggedErrorMessage = { + message: error.message, + error: { + message: error.message, + code: error.code, + stack_trace: error.stack_trace, + type: error.type, + }, + event: { + kind: 'error', + outcome: 'failure', + action: ActionType.EXECUTE_COMPLETE, + }, + log: { level: 'error' }, + }; + const event = deepMerge(logErrorMessage, this.eventObj); + genericLogger.logEvent(event); + return event; + } + + logClaimTask(): ClaimedTask { + const event = deepMerge( + { + message: `claimed report ${this.report._id}`, + event: { kind: 'event', action: ActionType.CLAIM_TASK }, + log: { level: 'info' }, + } as Partial, + this.eventObj + ); + + genericLogger.logEvent(event); + return event; + } + + logReportFailure(): FailedReport { + const event = deepMerge( + { + message: `report ${this.report._id} has failed`, + event: { kind: 'event', action: ActionType.FAIL_REPORT }, + log: { level: 'info' }, + } as Partial, + this.eventObj + ); + + genericLogger.logEvent(event); + return event; + } + + logReportSaved(): SavedReport { + const event = deepMerge( + { + message: `saved report ${this.report._id}`, + event: { kind: 'event', action: ActionType.SAVE_REPORT }, + log: { level: 'info' }, + } as Partial, + this.eventObj + ); + + genericLogger.logEvent(event); + return event; + } + + logRetry(): ScheduledRetry { + const event = deepMerge( + { + message: `scheduled retry for report ${this.report._id}`, + event: { kind: 'event', action: ActionType.RETRY }, + log: { level: 'info' }, + } as Partial, + this.eventObj + ); + + genericLogger.logEvent(event); + return event; + } + }; +} + +export type ReportingEventLogger = ReturnType; diff --git a/x-pack/plugins/reporting/server/lib/event_logger/types.ts b/x-pack/plugins/reporting/server/lib/event_logger/types.ts new file mode 100644 index 00000000000000..1c31292d03e446 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/event_logger/types.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionType } from './'; + +type ActionKind = 'event' | 'error' | 'metrics'; +type ActionOutcome = 'success' | 'failure'; + +interface ActionBase< + A extends ActionType, + K extends ActionKind, + O extends ActionOutcome, + EventProvider +> { + event: { + action: A; + kind: K; + outcome?: O; + provider: 'reporting'; + timezone: string; + }; + kibana: EventProvider & { task?: { id?: string } }; + user?: { name: string }; + log: { + logger: 'reporting'; + level: K extends 'error' ? 'error' : 'info'; + }; + message: string; +} + +export interface ErrorAction { + message: string; + code?: string; + stack_trace?: string; + type?: string; +} + +type ReportingAction< + A extends ActionType, + K extends ActionKind, + O extends ActionOutcome = 'success' +> = ActionBase< + A, + K, + O, + { + reporting: { + id?: string; // "immediate download" exports have no ID + jobType: string; + byteSize?: number; + }; + } +>; + +export type ScheduledTask = ReportingAction; +export type StartedExecution = ReportingAction; +export type CompletedExecution = ReportingAction; +export type SavedReport = ReportingAction; +export type ClaimedTask = ReportingAction; +export type ScheduledRetry = ReportingAction; +export type FailedReport = ReportingAction; +export type ExecuteError = ReportingAction & { + error: ErrorAction; +}; diff --git a/x-pack/plugins/reporting/server/lib/store/index.ts b/x-pack/plugins/reporting/server/lib/store/index.ts index e5f1c65e47948e..125c592c8626b3 100644 --- a/x-pack/plugins/reporting/server/lib/store/index.ts +++ b/x-pack/plugins/reporting/server/lib/store/index.ts @@ -10,3 +10,10 @@ export { Report } from './report'; export { SavedReport } from './saved_report'; export { ReportingStore } from './store'; export { IlmPolicyManager } from './ilm_policy_manager'; + +export interface IReport { + _id?: string; + jobtype: string; + created_by: string | false; + payload: { browserTimezone: string }; +} diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 7ddef6d66e2756..94375d66c00ad3 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -12,7 +12,7 @@ import { ReportingCore } from '../../'; import { ILM_POLICY_NAME, REPORTING_SYSTEM_INDEX } from '../../../common/constants'; import { JobStatus, ReportOutput, ReportSource } from '../../../common/types'; import { ReportTaskParams } from '../tasks'; -import { Report, ReportDocument, SavedReport } from './'; +import { IReport, Report, ReportDocument, SavedReport } from './'; import { IlmPolicyManager } from './ilm_policy_manager'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; @@ -216,8 +216,8 @@ export class ReportingStore { return report as SavedReport; } catch (err) { - this.logger.error(`Error in adding a report!`); - this.logger.error(err); + this.reportingCore.getEventLogger(report).logError(err); + this.logError(`Error in adding a report!`, err, report); throw err; } } @@ -266,6 +266,7 @@ export class ReportingStore { `[id: ${taskJson.id}] [index: ${taskJson.index}]` ); this.logger.error(err); + this.reportingCore.getEventLogger({ _id: taskJson.id } as IReport).logError(err); throw err; } } @@ -279,25 +280,33 @@ export class ReportingStore { status: statuses.JOB_STATUS_PROCESSING, }); + let body: UpdateResponse; try { const client = await this.getClient(); - const { body } = await client.update({ - id: report._id, - index: report._index, - if_seq_no: report._seq_no, - if_primary_term: report._primary_term, - refresh: true, - body: { doc }, - }); - - return body; + body = ( + await client.update({ + id: report._id, + index: report._index, + if_seq_no: report._seq_no, + if_primary_term: report._primary_term, + refresh: true, + body: { doc }, + }) + ).body; } catch (err) { - this.logger.error( - `Error in updating status to processing! Report: ` + jobDebugMessage(report) - ); - this.logger.error(err); + this.logError(`Error in updating status to processing! Report: ${jobDebugMessage(report)}`, err, report); // prettier-ignore throw err; } + + this.reportingCore.getEventLogger(report).logClaimTask(); + + return body; + } + + private logError(message: string, err: Error, report: Report) { + this.logger.error(message); + this.logger.error(err); + this.reportingCore.getEventLogger(report).logError(err); } public async setReportFailed( @@ -309,22 +318,27 @@ export class ReportingStore { status: statuses.JOB_STATUS_FAILED, }); + let body: UpdateResponse; try { const client = await this.getClient(); - const { body } = await client.update({ - id: report._id, - index: report._index, - if_seq_no: report._seq_no, - if_primary_term: report._primary_term, - refresh: true, - body: { doc }, - }); - return body; + body = ( + await client.update({ + id: report._id, + index: report._index, + if_seq_no: report._seq_no, + if_primary_term: report._primary_term, + refresh: true, + body: { doc }, + }) + ).body; } catch (err) { - this.logger.error(`Error in updating status to failed! Report: ` + jobDebugMessage(report)); - this.logger.error(err); + this.logError(`Error in updating status to failed! Report: ${jobDebugMessage(report)}`, err, report); // prettier-ignore throw err; } + + this.reportingCore.getEventLogger(report).logReportFailure(); + + return body; } public async setReportCompleted( @@ -341,22 +355,27 @@ export class ReportingStore { status, } as ReportSource); + let body: UpdateResponse; try { const client = await this.getClient(); - const { body } = await client.update({ - id: report._id, - index: report._index, - if_seq_no: report._seq_no, - if_primary_term: report._primary_term, - refresh: true, - body: { doc }, - }); - return body; + body = ( + await client.update({ + id: report._id, + index: report._index, + if_seq_no: report._seq_no, + if_primary_term: report._primary_term, + refresh: true, + body: { doc }, + }) + ).body; } catch (err) { - this.logger.error(`Error in updating status to complete! Report: ` + jobDebugMessage(report)); - this.logger.error(err); + this.logError(`Error in updating status to complete! Report: ${jobDebugMessage(report)}`, err, report); // prettier-ignore throw err; } + + this.reportingCore.getEventLogger(report).logReportSaved(); + + return body; } public async prepareReportForRetry(report: SavedReport): Promise> { @@ -365,24 +384,25 @@ export class ReportingStore { process_expiration: null, }); + let body: UpdateResponse; try { const client = await this.getClient(); - const { body } = await client.update({ - id: report._id, - index: report._index, - if_seq_no: report._seq_no, - if_primary_term: report._primary_term, - refresh: true, - body: { doc }, - }); - return body; + body = ( + await client.update({ + id: report._id, + index: report._index, + if_seq_no: report._seq_no, + if_primary_term: report._primary_term, + refresh: true, + body: { doc }, + }) + ).body; } catch (err) { - this.logger.error( - `Error in clearing expiration and status for retry! Report: ` + jobDebugMessage(report) - ); - this.logger.error(err); + this.logError(`Error in clearing expiration and status for retry! Report: ${jobDebugMessage(report)}`, err, report); // prettier-ignore throw err; } + + return body; } /* diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index a17e3997828a46..dd3f93ad2c0c6c 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -333,6 +333,10 @@ export class ExecuteReportTask implements ReportingTask { ); this.logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); + const eventLog = this.reporting.getEventLogger( + new Report({ ...task, _id: task.id, _index: task.index }) + ); + try { const jobContentEncoding = this.getJobContentEncoding(jobType); const stream = await getContentStream( @@ -347,9 +351,15 @@ export class ExecuteReportTask implements ReportingTask { encoding: jobContentEncoding === 'base64' ? 'base64' : 'raw', } ); + + eventLog.logExecutionStart(); + const output = await this._performJob(task, cancellationToken, stream); stream.end(); + + eventLog.logExecutionComplete({ byteSize: stream.bytesWritten }); + await promisify(finished)(stream, { readable: false }); report._seq_no = stream.getSeqNo()!; @@ -365,6 +375,8 @@ export class ExecuteReportTask implements ReportingTask { // untrack the report for concurrency awareness this.logger.debug(`Stopping ${jobId}.`); } catch (failedToExecuteErr) { + eventLog.logError(failedToExecuteErr); + cancellationToken.cancel(); if (attempts < maxAttempts) { diff --git a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts index ce8bb74d666c54..4af28e3d1a6981 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/monitor_reports.ts @@ -152,6 +152,9 @@ export class MonitorReportsTask implements ReportingTask { logger.info(`Rescheduling task:${task.id} to retry.`); const newTask = await this.reporting.scheduleTask(task); + + this.reporting.getEventLogger({ _id: task.id, ...task }, newTask).logRetry(); + return newTask; } diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index 4c04eb0c004e53..7afeedd3d2832a 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -5,52 +5,35 @@ * 2.0. */ +import { CoreSetup, CoreStart } from 'kibana/server'; import { coreMock } from 'src/core/server/mocks'; -import { featuresPluginMock } from '../../features/server/mocks'; -import { TaskManagerSetupContract } from '../../task_manager/server'; +import { ReportingInternalStart } from './core'; import { ReportingPlugin } from './plugin'; -import { createMockConfigSchema } from './test_helpers'; +import { createMockConfigSchema, createMockPluginSetup } from './test_helpers'; +import { + createMockPluginStart, + createMockReportingCore, +} from './test_helpers/create_mock_reportingplugin'; +import { ReportingSetupDeps } from './types'; const sleep = (time: number) => new Promise((r) => setTimeout(r, time)); describe('Reporting Plugin', () => { let configSchema: any; let initContext: any; - let coreSetup: any; - let coreStart: any; - let pluginSetup: any; - let pluginStart: any; + let coreSetup: CoreSetup; + let coreStart: CoreStart; + let pluginSetup: ReportingSetupDeps; + let pluginStart: ReportingInternalStart; beforeEach(async () => { + const reportingCore = await createMockReportingCore(createMockConfigSchema()); configSchema = createMockConfigSchema(); initContext = coreMock.createPluginInitializerContext(configSchema); coreSetup = coreMock.createSetup(configSchema); coreStart = coreMock.createStart(); - pluginSetup = { - licensing: {}, - features: featuresPluginMock.createSetup(), - usageCollection: { - makeUsageCollector: jest.fn(), - registerCollector: jest.fn(), - }, - taskManager: { - registerTaskDefinitions: jest.fn(), - } as unknown as TaskManagerSetupContract, - security: { - authc: { - getCurrentUser: () => ({ - id: '123', - roles: ['superuser'], - username: 'Tom Riddle', - }), - }, - }, - } as unknown as any; - pluginStart = { - data: { - fieldFormats: {}, - }, - } as unknown as any; + pluginSetup = createMockPluginSetup({}) as unknown as ReportingSetupDeps; + pluginStart = createMockPluginStart(reportingCore, {}); }); it('has a sync setup process', () => { @@ -70,15 +53,14 @@ describe('Reporting Plugin', () => { const plugin = new ReportingPlugin(initContext); plugin.setup(coreSetup, pluginSetup); expect(coreSetup.uiSettings.register).toHaveBeenCalled(); - expect(coreSetup.uiSettings.register.mock.calls[0][0]).toHaveProperty( + expect((coreSetup.uiSettings.register as jest.Mock).mock.calls[0][0]).toHaveProperty( 'xpackReporting:customPdfLogo' ); }); it('logs start issues', async () => { const plugin = new ReportingPlugin(initContext); - // @ts-ignore overloading error logger - plugin.logger.error = jest.fn(); + (plugin as unknown as { logger: { error: jest.Mock } }).logger.error = jest.fn(); plugin.setup(coreSetup, pluginSetup); await sleep(5); plugin.start(null as any, pluginStart); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 414966170772c8..942ebbea47881d 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -11,6 +11,7 @@ import { ReportingCore } from './'; import { buildConfig, registerUiSettings, ReportingConfigType } from './config'; import { registerDeprecations } from './deprecations'; import { LevelLogger, ReportingStore } from './lib'; +import { registerEventLogProviderActions } from './lib/event_logger'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; import type { @@ -37,7 +38,7 @@ export class ReportingPlugin public setup(core: CoreSetup, plugins: ReportingSetupDeps) { const { http } = core; - const { features, licensing, security, spaces, taskManager } = plugins; + const { features, licensing, eventLog, security, spaces, taskManager } = plugins; const reportingCore = new ReportingCore(this.logger, this.initContext); @@ -57,15 +58,17 @@ export class ReportingPlugin reportingCore.pluginSetup({ features, licensing, - basePath, - router, + eventLog, security, spaces, taskManager, logger: this.logger, status: core.status, + basePath, + router, }); + registerEventLogProviderActions(eventLog); registerUiSettings(core); registerDeprecations({ core, diff --git a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts index 52e6eb87e05cde..23f27230b18424 100644 --- a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts @@ -9,10 +9,12 @@ import { schema } from '@kbn/config-schema'; import { KibanaRequest } from 'src/core/server'; import { Writable } from 'stream'; import { ReportingCore } from '../../'; +import { CSV_SEARCHSOURCE_IMMEDIATE_TYPE } from '../../../common/constants'; import { runTaskFnFactory } from '../../export_types/csv_searchsource_immediate/execute_job'; import { JobParamsDownloadCSV } from '../../export_types/csv_searchsource_immediate/types'; import { LevelLogger as Logger } from '../../lib'; import { TaskRunResult } from '../../lib/tasks'; +import { BaseParams } from '../../types'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; import { RequestHandler } from '../lib/request_handler'; @@ -64,10 +66,18 @@ export function registerGenerateCsvFromSavedObjectImmediate( authorizedUserPreRouting( reporting, async (user, context, req: CsvFromSavedObjectRequest, res) => { - const logger = parentLogger.clone(['csv_searchsource_immediate']); + const logger = parentLogger.clone([CSV_SEARCHSOURCE_IMMEDIATE_TYPE]); const runTaskFn = runTaskFnFactory(reporting, logger); const requestHandler = new RequestHandler(reporting, user, context, req, res, logger); + const eventLog = reporting.getEventLogger({ + jobtype: CSV_SEARCHSOURCE_IMMEDIATE_TYPE, + created_by: user && user.username, + payload: { browserTimezone: (req.params as BaseParams).browserTimezone }, + }); + + eventLog.logExecutionStart(); + try { let buffer = Buffer.from(''); const stream = new Writable({ @@ -98,6 +108,8 @@ export function registerGenerateCsvFromSavedObjectImmediate( logger.warn('CSV Job Execution created empty content result'); } + eventLog.logExecutionComplete({ byteSize: jobOutputSize }); + return res.ok({ body: jobOutputContent || '', headers: { @@ -107,6 +119,7 @@ export function registerGenerateCsvFromSavedObjectImmediate( }); } catch (err) { logger.error(err); + eventLog.logError(err); return requestHandler.handleError(err); } } diff --git a/x-pack/plugins/reporting/server/routes/lib/request_handler.ts b/x-pack/plugins/reporting/server/routes/lib/request_handler.ts index 998e8d12076b95..b0a2032c18f19e 100644 --- a/x-pack/plugins/reporting/server/routes/lib/request_handler.ts +++ b/x-pack/plugins/reporting/server/routes/lib/request_handler.ts @@ -99,6 +99,9 @@ export class RequestHandler { `Scheduled ${exportType.name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}` ); + // 6. Log the action with event log + reporting.getEventLogger(report, task).logScheduleTask(); + return report; } diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 9f1d7e614bd926..9570c82f23a8a2 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -39,6 +39,10 @@ export const createMockPluginSetup = ( taskManager: taskManagerMock.createSetup(), logger: createMockLevelLogger(), status: statusServiceMock.createSetupContract(), + eventLog: setupMock.eventLog || { + registerProviderActions: jest.fn(), + getLogger: jest.fn(() => ({ logEvent: jest.fn() })), + }, ...setupMock, }; }; diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 114cd77fbf4b53..e23a1d555fdbae 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -11,6 +11,7 @@ import type { DataPluginStart } from 'src/plugins/data/server/plugin'; import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import type { Writable } from 'stream'; +import { IEventLogService } from '../../event_log/server'; import type { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import type { LicensingPluginSetup } from '../../licensing/server'; import type { @@ -91,6 +92,7 @@ export interface ExportTypeDefinition< */ export interface ReportingSetupDeps { licensing: LicensingPluginSetup; + eventLog: IEventLogService; features: FeaturesPluginSetup; screenshotMode: ScreenshotModePluginSetup; security?: SecurityPluginSetup; diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json index 4e09708915f952..66d528cd83a22e 100644 --- a/x-pack/plugins/reporting/tsconfig.json +++ b/x-pack/plugins/reporting/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/field_formats/tsconfig.json" }, + { "path": "../event_log/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../screenshotting/tsconfig.json" }, From f47839ecafe68968ac7d6f5418f7971bda447da5 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 12 Jan 2022 17:37:45 -0800 Subject: [PATCH 13/29] [Security Solution][Lists] - Remove disabled exception list delete icon (#122844) ### Summary Addresses #121761 . Instead of showing disabled exception delete icon in exceptions table, if it's disabled, simply don't show it at all. --- .../rules/all/exceptions/columns.tsx | 23 +++++++++++-------- .../all/exceptions/exceptions_table.test.tsx | 8 +++---- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx index 17eafecbae34f9..2f5f347b0e8f61 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/columns.tsx @@ -154,16 +154,19 @@ export const getAllExceptionListsColumns = ( ), }, { - render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => ( - - ), + render: ({ id, list_id: listId, namespace_type: namespaceType }: ExceptionListInfo) => { + return listId === 'endpoint_list' ? ( + <> + ) : ( + + ); + }, }, ], }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx index 71ffc1076a2270..dae79e8f552f6c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx @@ -88,7 +88,7 @@ describe('ExceptionListsTable', () => { ]); }); - it('renders delete option disabled if list is "endpoint_list"', async () => { + it('does not render delete option disabled if list is "endpoint_list"', async () => { const wrapper = mount( @@ -97,15 +97,13 @@ describe('ExceptionListsTable', () => { expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(0).text()).toEqual( 'endpoint_list' ); - expect( - wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled') - ).toBeTruthy(); expect(wrapper.find('[data-test-subj="exceptionsTableListId"]').at(1).text()).toEqual( 'not_endpoint_list' ); + expect(wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button')).toHaveLength(1); expect( - wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(1).prop('disabled') + wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled') ).toBeFalsy(); }); }); From a6d21cc7151f38a4e0c54fd8e08053cbf50d5454 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 13 Jan 2022 02:23:18 +0000 Subject: [PATCH 14/29] chore(NA): splits types from code on @kbn/ui-shared-deps-npm (#122788) --- package.json | 1 + packages/BUILD.bazel | 1 + packages/kbn-optimizer/BUILD.bazel | 2 +- packages/kbn-storybook/BUILD.bazel | 2 +- packages/kbn-ui-shared-deps-npm/BUILD.bazel | 26 +++++++++++++++++--- packages/kbn-ui-shared-deps-npm/package.json | 3 +-- packages/kbn-ui-shared-deps-npm/src/index.js | 14 ++++++++--- packages/kbn-ui-shared-deps-src/BUILD.bazel | 2 +- yarn.lock | 6 ++++- 9 files changed, 43 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 79a2f47d25ba76..b6b5b233c4f759 100644 --- a/package.json +++ b/package.json @@ -602,6 +602,7 @@ "@types/kbn__server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository/npm_module_types", "@types/kbn__std": "link:bazel-bin/packages/kbn-std/npm_module_types", "@types/kbn__telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools/npm_module_types", + "@types/kbn__ui-shared-deps-npm": "link:bazel-bin/packages/kbn-ui-shared-deps-npm/npm_module_types", "@types/kbn__utility-types": "link:bazel-bin/packages/kbn-utility-types/npm_module_types", "@types/kbn__utils": "link:bazel-bin/packages/kbn-utils/npm_module_types", "@types/license-checker": "15.0.0", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index dc66d0a7561b76..7c8259b2f6857e 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -120,6 +120,7 @@ filegroup( "//packages/kbn-server-route-repository:build_types", "//packages/kbn-std:build_types", "//packages/kbn-telemetry-tools:build_types", + "//packages/kbn-ui-shared-deps-npm:build_types", "//packages/kbn-utility-types:build_types", "//packages/kbn-utils:build_types", ], diff --git a/packages/kbn-optimizer/BUILD.bazel b/packages/kbn-optimizer/BUILD.bazel index 548d410d2f3162..0e5e99c5d1096d 100644 --- a/packages/kbn-optimizer/BUILD.bazel +++ b/packages/kbn-optimizer/BUILD.bazel @@ -69,7 +69,7 @@ TYPES_DEPS = [ "//packages/kbn-config-schema:npm_module_types", "//packages/kbn-dev-utils:npm_module_types", "//packages/kbn-std:npm_module_types", - "//packages/kbn-ui-shared-deps-npm", + "//packages/kbn-ui-shared-deps-npm:npm_module_types", "//packages/kbn-ui-shared-deps-src", "//packages/kbn-utils:npm_module_types", "@npm//chalk", diff --git a/packages/kbn-storybook/BUILD.bazel b/packages/kbn-storybook/BUILD.bazel index 92650e4bbca1f5..729a60f8f7dc8b 100644 --- a/packages/kbn-storybook/BUILD.bazel +++ b/packages/kbn-storybook/BUILD.bazel @@ -49,7 +49,7 @@ RUNTIME_DEPS = [ TYPES_DEPS = [ "//packages/kbn-dev-utils:npm_module_types", - "//packages/kbn-ui-shared-deps-npm", + "//packages/kbn-ui-shared-deps-npm:npm_module_types", "//packages/kbn-ui-shared-deps-src", "//packages/kbn-utils:npm_module_types", "@npm//@storybook/addons", diff --git a/packages/kbn-ui-shared-deps-npm/BUILD.bazel b/packages/kbn-ui-shared-deps-npm/BUILD.bazel index 2beedafd699fdf..b2295ecfc8f282 100644 --- a/packages/kbn-ui-shared-deps-npm/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-npm/BUILD.bazel @@ -1,10 +1,11 @@ -load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") -load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") load("@npm//webpack-cli:index.bzl", webpack = "webpack_cli") -load("//src/dev/bazel:index.bzl", "jsts_transpiler") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") PKG_BASE_NAME = "kbn-ui-shared-deps-npm" PKG_REQUIRE_NAME = "@kbn/ui-shared-deps-npm" +TYPES_PKG_REQUIRE_NAME = "@types/kbn__ui-shared-deps-npm" SOURCE_FILES = glob( [ @@ -150,7 +151,7 @@ webpack( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = RUNTIME_DEPS + [":target_node", ":tsc_types", ":shared_built_assets"], + deps = RUNTIME_DEPS + [":target_node", ":shared_built_assets"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) @@ -169,3 +170,20 @@ filegroup( ], visibility = ["//visibility:public"], ) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = TYPES_PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [ + ":npm_module_types", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-ui-shared-deps-npm/package.json b/packages/kbn-ui-shared-deps-npm/package.json index 0ed7ea661c818e..353918eca145f5 100644 --- a/packages/kbn-ui-shared-deps-npm/package.json +++ b/packages/kbn-ui-shared-deps-npm/package.json @@ -4,6 +4,5 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "target_node/index.js", - "browser": "target_node/entry.js", - "types": "target_types/index.d.ts" + "browser": "target_node/entry.js" } \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps-npm/src/index.js b/packages/kbn-ui-shared-deps-npm/src/index.js index 678e7c845e6a5e..768c6131e0c368 100644 --- a/packages/kbn-ui-shared-deps-npm/src/index.js +++ b/packages/kbn-ui-shared-deps-npm/src/index.js @@ -12,20 +12,26 @@ const Path = require('path'); +// extracted const vars +const distDir = Path.resolve(__dirname, '../shared_built_assets'); +const dllManifestPath = Path.resolve(distDir, 'kbn-ui-shared-deps-npm-manifest.json'); +const dllFilename = 'kbn-ui-shared-deps-npm.dll.js'; +const publicPathLoader = require.resolve('./public_path_loader'); + /** * Absolute path to the distributable directory */ -exports.distDir = Path.resolve(__dirname, '../shared_built_assets'); +exports.distDir = distDir; /** * Path to dll manifest of modules included in this bundle */ -exports.dllManifestPath = Path.resolve(exports.distDir, 'kbn-ui-shared-deps-npm-manifest.json'); +exports.dllManifestPath = dllManifestPath; /** * Filename of the main bundle file in the distributable directory */ -exports.dllFilename = 'kbn-ui-shared-deps-npm.dll.js'; +exports.dllFilename = dllFilename; /** * Filename of the light-theme css file in the distributable directory @@ -54,4 +60,4 @@ exports.darkCssDistFilename = (themeVersion) => { /** * Webpack loader for configuring the public path lookup from `window.__kbnPublicPath__`. */ -exports.publicPathLoader = require.resolve('./public_path_loader'); +exports.publicPathLoader = publicPathLoader; diff --git a/packages/kbn-ui-shared-deps-src/BUILD.bazel b/packages/kbn-ui-shared-deps-src/BUILD.bazel index 5f605ca2b59b93..ce2cbe714a16c8 100644 --- a/packages/kbn-ui-shared-deps-src/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-src/BUILD.bazel @@ -49,7 +49,7 @@ TYPES_DEPS = [ "//packages/kbn-i18n-react:npm_module_types", "//packages/kbn-monaco:npm_module_types", "//packages/kbn-std:npm_module_types", - "//packages/kbn-ui-shared-deps-npm", + "//packages/kbn-ui-shared-deps-npm:npm_module_types", "@npm//@elastic/eui", "@npm//webpack", ] diff --git a/yarn.lock b/yarn.lock index 61d331ca5cb9f8..fd875d7f5ab302 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5956,6 +5956,10 @@ version "0.0.0" uid "" +"@types/kbn__ui-shared-deps-npm@link:bazel-bin/packages/kbn-ui-shared-deps-npm/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__utility-types@link:bazel-bin/packages/kbn-utility-types/npm_module_types": version "0.0.0" uid "" @@ -10526,7 +10530,7 @@ core-js@^2.4.0, core-js@^2.5.0, core-js@^2.6.9: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== -core-js@^3.0.4, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3, core-js@^3.20.2: +core-js@^3.0.4, core-js@^3.20.2, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: version "3.20.2" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.2.tgz#46468d8601eafc8b266bd2dd6bf9dee622779581" integrity sha512-nuqhq11DcOAbFBV4zCbKeGbKQsUDRqTX0oqx7AttUBuqe3h20ixsE039QHelbL6P4h+9kytVqyEtyZ6gsiwEYw== From 6c72063531de26c63f16b84a055007e34ae72404 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Wed, 12 Jan 2022 21:52:15 -0500 Subject: [PATCH 15/29] [Security Solution] Make rule detail link work for both signal.rule.name and kibana.alert.rule.name (#122437) * Make rule detail link work for both signal.rule.name and kibana.alert.rule.name * Remove failing test * Remove incorrect comment about possible bug * PR feedback * More cleanup/feedback * Memoize hook usage --- .../default_cell_actions.test.tsx | 1 - .../lib/cell_actions/default_cell_actions.tsx | 386 +++++++++++------- .../public/common/lib/cell_actions/helpers.ts | 30 +- .../observablity_alerts/render_cell_value.tsx | 4 +- .../render_cell_value.tsx | 4 +- .../security_solution/public/helpers.tsx | 4 +- .../body/data_driven_columns/index.tsx | 10 + .../stateful_cell.test.tsx | 22 +- .../timeline/body/events/stateful_event.tsx | 28 +- .../body/renderers/formatted_field.tsx | 1 - .../renderers/formatted_field_helpers.tsx | 2 - .../cell_rendering/default_cell_renderer.tsx | 18 +- .../public/components/t_grid/body/index.tsx | 1 - 13 files changed, 316 insertions(+), 195 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx index a4e6f0a66dfc92..8a7d6c3b27b8d3 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx @@ -53,7 +53,6 @@ describe('default cell actions', () => { }); expect(columnsWithCellActions[0]?.cellActions?.length).toEqual(5); - expect(columnsWithCellActions[0]?.cellActions![4]).toEqual(EmptyComponent); }); const columnHeadersToTest = COLUMNS_WITH_LINKS.map((c) => [ diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx index 3526724a54b414..64473cfddd2992 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx @@ -9,10 +9,7 @@ import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; import { head, getOr, get, isEmpty } from 'lodash/fp'; import React, { useMemo } from 'react'; -import type { - BrowserFields, - TimelineNonEcsData, -} from '../../../../../timelines/common/search_strategy'; +import type { TimelineNonEcsData } from '../../../../../timelines/common/search_strategy'; import { ColumnHeaderOptions, DataProvider, @@ -20,13 +17,14 @@ import { } from '../../../../../timelines/common/types'; import { getPageRowIndex } from '../../../../../timelines/public'; import { Ecs } from '../../../../common/ecs'; -import { getMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns'; +import { useGetMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns'; import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; import { parseValue } from '../../../timelines/components/timeline/body/renderers/parse_value'; import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; import { escapeDataProviderId } from '../../components/drag_and_drop/helpers'; import { useKibana } from '../kibana'; -import { getLink } from './helpers'; +import { getLinkColumnDefinition } from './helpers'; +import { getField, getFieldKey } from '../../../helpers'; /** a noop required by the filter in / out buttons */ const onFilterAdded = () => {}; @@ -45,139 +43,167 @@ const useKibanaServices = () => { export const EmptyComponent = () => <>; -const cellActionLink = [ - ({ - browserFields, - data, - ecsData, - header, - timelineId, - pageSize, - }: { - browserFields: BrowserFields; - data: TimelineNonEcsData[][]; - ecsData: Ecs[]; - header?: ColumnHeaderOptions; - timelineId: string; - pageSize: number; - }) => { - return getLink(header?.id, header?.type, header?.linkField) - ? ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { - const pageRowIndex = getPageRowIndex(rowIndex, pageSize); - const ecs = pageRowIndex < ecsData.length ? ecsData[pageRowIndex] : null; - const link = getLink(columnId, header?.type, header?.linkField); - const linkField = header?.linkField ? header?.linkField : link?.linkField; - const linkValues = header && getOr([], linkField ?? '', ecs); - const eventId = header && get('_id' ?? '', ecs); - if (pageRowIndex >= data.length) { - // data grid expects each cell action always return an element, it crashes if returns null - return <>; - } +const useFormattedFieldProps = ({ + rowIndex, + pageSize, + ecsData, + columnId, + header, + data, +}: { + rowIndex: number; + data: TimelineNonEcsData[][]; + ecsData: Ecs[]; + header?: ColumnHeaderOptions; + columnId: string; + pageSize: number; +}) => { + const pageRowIndex = getPageRowIndex(rowIndex, pageSize); + const ecs = ecsData[pageRowIndex]; + const link = getLinkColumnDefinition(columnId, header?.type, header?.linkField); + const linkField = header?.linkField ? header?.linkField : link?.linkField; + const linkValues = header && getOr([], linkField ?? '', ecs); + const eventId = (header && get('_id' ?? '', ecs)) || ''; + const rowData = useMemo(() => { + return { + data: data[pageRowIndex], + fieldName: columnId, + }; + }, [pageRowIndex, columnId, data]); - const values = getMappedNonEcsValue({ - data: data[pageRowIndex], - fieldName: columnId, - }); - - const value = parseValue(head(values)); - return link && eventId && values && !isEmpty(value) ? ( - 1 ? `${link?.label}: ${value}` : link?.label} - linkValue={head(linkValues)} - onClick={closePopover} - /> - ) : ( - // data grid expects each cell action always return an element, it crashes if returns null - <> - ); - } - : EmptyComponent; - }, -]; + const values = useGetMappedNonEcsValue(rowData); + const value = parseValue(head(values)); + const title = values && values.length > 1 ? `${link?.label}: ${value}` : link?.label; + // if linkField is defined but link values is empty, it's possible we are trying to look for a column definition for an old event set + if (linkField !== undefined && linkValues.length === 0 && values !== undefined) { + const normalizedLinkValue = getField(ecs, linkField); + const normalizedLinkField = getFieldKey(ecs, linkField); + const normalizedColumnId = getFieldKey(ecs, columnId); + const normalizedLink = getLinkColumnDefinition( + normalizedColumnId, + header?.type, + normalizedLinkField + ); + return { + pageRowIndex, + link: normalizedLink, + eventId, + fieldFormat: header?.format || '', + fieldName: normalizedColumnId, + fieldType: header?.type || '', + value: parseValue(head(normalizedColumnId)), + values, + title, + linkValue: head(normalizedLinkValue), + }; + } else { + return { + pageRowIndex, + link, + eventId, + fieldFormat: header?.format || '', + fieldName: columnId, + fieldType: header?.type || '', + value, + values, + title, + linkValue: head(linkValues), + }; + } +}; export const cellActions: TGridCellAction[] = [ ({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) => - ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => { + function FilterFor({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) { const { timelines, filterManager } = useKibanaServices(); const pageRowIndex = getPageRowIndex(rowIndex, pageSize); + const rowData = useMemo(() => { + return { + data: data[pageRowIndex], + fieldName: columnId, + }; + }, [pageRowIndex, columnId]); + + const value = useGetMappedNonEcsValue(rowData); + const filterForButton = useMemo( + () => timelines.getHoverActions().getFilterForValueButton, + [timelines] + ); + + const filterForProps = useMemo(() => { + return { + Component, + field: columnId, + filterManager, + onFilterAdded, + ownFocus: false, + showTooltip: false, + value, + }; + }, [Component, columnId, filterManager, value]); if (pageRowIndex >= data.length) { // data grid expects each cell action always return an element, it crashes if returns null return <>; } - const value = getMappedNonEcsValue({ - data: data[pageRowIndex], - fieldName: columnId, - }); - - return ( - <> - {timelines.getHoverActions().getFilterForValueButton({ - Component, - field: columnId, - filterManager, - onFilterAdded, - ownFocus: false, - showTooltip: false, - value, - })} - - ); + return <>{filterForButton(filterForProps)}; }, ({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) => - ({ rowIndex, columnId, Component }) => { + function FilterOut({ rowIndex, columnId, Component }) { const { timelines, filterManager } = useKibanaServices(); - const pageRowIndex = getPageRowIndex(rowIndex, pageSize); + + const rowData = useMemo(() => { + return { + data: data[pageRowIndex], + fieldName: columnId, + }; + }, [pageRowIndex, columnId]); + + const value = useGetMappedNonEcsValue(rowData); + + const filterOutButton = useMemo( + () => timelines.getHoverActions().getFilterOutValueButton, + [timelines] + ); + + const filterOutProps = useMemo(() => { + return { + Component, + field: columnId, + filterManager, + onFilterAdded, + ownFocus: false, + showTooltip: false, + value, + }; + }, [Component, columnId, filterManager, value]); if (pageRowIndex >= data.length) { // data grid expects each cell action always return an element, it crashes if returns null return <>; } - const value = getMappedNonEcsValue({ - data: data[pageRowIndex], - fieldName: columnId, - }); - - return ( - <> - {timelines.getHoverActions().getFilterOutValueButton({ - Component, - field: columnId, - filterManager, - onFilterAdded, - ownFocus: false, - showTooltip: false, - value, - })} - - ); + return <>{filterOutButton(filterOutProps)}; }, ({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) => - ({ rowIndex, columnId, Component }) => { + function AddToTimeline({ rowIndex, columnId, Component }) { const { timelines } = useKibanaServices(); const pageRowIndex = getPageRowIndex(rowIndex, pageSize); - if (pageRowIndex >= data.length) { - // data grid expects each cell action always return an element, it crashes if returns null - return <>; - } + const rowData = useMemo(() => { + return { + data: data[pageRowIndex], + fieldName: columnId, + }; + }, [pageRowIndex, columnId]); + + const value = useGetMappedNonEcsValue(rowData); - const value = getMappedNonEcsValue({ - data: data[pageRowIndex], - fieldName: columnId, - }); + const addToTimelineButton = useMemo( + () => timelines.getHoverActions().getAddToTimelineButton, + [timelines] + ); const dataProvider: DataProvider[] = useMemo( () => @@ -196,48 +222,124 @@ export const cellActions: TGridCellAction[] = [ })) ?? [], [columnId, rowIndex, value] ); + const addToTimelineProps = useMemo(() => { + return { + Component, + dataProvider, + field: columnId, + ownFocus: false, + showTooltip: false, + }; + }, [Component, columnId, dataProvider]); + if (pageRowIndex >= data.length) { + // data grid expects each cell action always return an element, it crashes if returns null + return <>; + } - return ( - <> - {timelines.getHoverActions().getAddToTimelineButton({ - Component, - dataProvider, - field: columnId, - ownFocus: false, - showTooltip: false, - })} - - ); + return <>{addToTimelineButton(addToTimelineProps)}; }, ({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) => - ({ rowIndex, columnId, Component }) => { + function CopyButton({ rowIndex, columnId, Component }) { const { timelines } = useKibanaServices(); const pageRowIndex = getPageRowIndex(rowIndex, pageSize); + + const copyButton = useMemo(() => timelines.getHoverActions().getCopyButton, [timelines]); + + const rowData = useMemo(() => { + return { + data: data[pageRowIndex], + fieldName: columnId, + }; + }, [pageRowIndex, columnId]); + + const value = useGetMappedNonEcsValue(rowData); + + const copyButtonProps = useMemo(() => { + return { + Component, + field: columnId, + isHoverAction: false, + ownFocus: false, + showTooltip: false, + value, + }; + }, [Component, columnId, value]); if (pageRowIndex >= data.length) { // data grid expects each cell action always return an element, it crashes if returns null return <>; } - const value = getMappedNonEcsValue({ - data: data[pageRowIndex], - fieldName: columnId, - }); - - return ( - <> - {timelines.getHoverActions().getCopyButton({ - Component, - field: columnId, - isHoverAction: false, - ownFocus: false, - showTooltip: false, - value, - })} - - ); + return <>{copyButton(copyButtonProps)}; }, + ({ + data, + ecsData, + header, + timelineId, + pageSize, + }: { + data: TimelineNonEcsData[][]; + ecsData: Ecs[]; + header?: ColumnHeaderOptions; + timelineId: string; + pageSize: number; + }) => { + if (header !== undefined) { + return function FieldValue({ + rowIndex, + columnId, + Component, + closePopover, + }: EuiDataGridColumnCellActionProps) { + const { + pageRowIndex, + link, + eventId, + value, + values, + title, + fieldName, + fieldFormat, + fieldType, + linkValue, + } = useFormattedFieldProps({ rowIndex, pageSize, ecsData, columnId, header, data }); + + const showEmpty = useMemo(() => { + const hasLink = link !== undefined && values && !isEmpty(value); + if (pageRowIndex >= data.length) { + return true; + } else { + return hasLink !== true; + } + }, [link, pageRowIndex, value, values]); + + return showEmpty === false ? ( + + ) : ( + // data grid expects each cell action always return an element, it crashes if returns null + <> + ); + }; + } else { + return EmptyComponent; + } + }, ]; /** the default actions shown in `EuiDataGrid` cells */ -export const defaultCellActions = [...cellActions, ...cellActionLink]; +export const defaultCellActions = [...cellActions]; diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.ts b/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.ts index 2db645106fc5cb..55347b156f4cb6 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/helpers.ts @@ -35,6 +35,11 @@ export const COLUMNS_WITH_LINKS = [ { columnId: SIGNAL_RULE_NAME_FIELD_NAME, label: i18n.VIEW_RULE_DETAILS, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnId: 'signal.rule.name', + label: i18n.VIEW_RULE_DETAILS, linkField: 'signal.rule.id', }, ...PORT_NAMES.map((p) => ({ @@ -59,9 +64,22 @@ export const COLUMNS_WITH_LINKS = [ }, ]; -export const getLink = (cId?: string, fieldType?: string, linkField?: string) => - COLUMNS_WITH_LINKS.find( - (c) => - (cId && c.columnId === cId) || - (c.fieldType && fieldType === c.fieldType && (linkField != null || c.linkField !== undefined)) - ); +export const getLinkColumnDefinition = ( + columnIdToFind: string, + fieldType?: string, + linkField?: string +) => { + return COLUMNS_WITH_LINKS.find((column) => { + if (column.columnId === columnIdToFind) { + return true; + } else if ( + column.fieldType && + fieldType === column.fieldType && + (linkField !== undefined || column.linkField !== undefined) + ) { + return true; + } else { + return false; + } + }); +}; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx index cffe7bce4b5d3c..443aeef5f4f81a 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx @@ -13,7 +13,7 @@ import { ALERT_DURATION, ALERT_REASON, ALERT_SEVERITY, ALERT_STATUS } from '@kbn import { TruncatableText } from '../../../../common/components/truncatable_text'; import { Severity } from '../../../components/severity'; -import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; +import { useGetMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import { Status } from '../../../components/status'; @@ -43,7 +43,7 @@ export const RenderCellValue: React.FC< timelineId, }) => { const value = - getMappedNonEcsValue({ + useGetMappedNonEcsValue({ data, fieldName: columnId, })?.reduce((x) => x[0]) ?? ''; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx index 8c50e24cc3305e..3ee8b2fbee54df 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { DefaultDraggable } from '../../../../common/components/draggables'; import { TruncatableText } from '../../../../common/components/truncatable_text'; import { Severity } from '../../../components/severity'; -import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; +import { useGetMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; @@ -40,7 +40,7 @@ export const RenderCellValue: React.FC< timelineId, }) => { const value = - getMappedNonEcsValue({ + useGetMappedNonEcsValue({ data, fieldName: columnId, })?.reduce((x) => x[0]) ?? ''; diff --git a/x-pack/plugins/security_solution/public/helpers.tsx b/x-pack/plugins/security_solution/public/helpers.tsx index 2236b1802df297..40feb9e0b3edac 100644 --- a/x-pack/plugins/security_solution/public/helpers.tsx +++ b/x-pack/plugins/security_solution/public/helpers.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_RULE_UUID, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; +import { ALERT_RULE_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; import { has, get, isEmpty } from 'lodash/fp'; import React from 'react'; import { matchPath, RouteProps, Redirect } from 'react-router-dom'; @@ -209,6 +209,7 @@ RedirectRoute.displayName = 'RedirectRoute'; const siemSignalsFieldMappings: Record = { [ALERT_RULE_UUID]: 'signal.rule.id', + [ALERT_RULE_NAME]: 'signal.rule.name', [`${ALERT_RULE_PARAMETERS}.filters`]: 'signal.rule.filters', [`${ALERT_RULE_PARAMETERS}.language`]: 'signal.rule.language', [`${ALERT_RULE_PARAMETERS}.query`]: 'signal.rule.query', @@ -216,6 +217,7 @@ const siemSignalsFieldMappings: Record = { const alertFieldMappings: Record = { 'signal.rule.id': ALERT_RULE_UUID, + 'signal.rule.name': ALERT_RULE_NAME, 'signal.rule.filters': `${ALERT_RULE_PARAMETERS}.filters`, 'signal.rule.language': `${ALERT_RULE_PARAMETERS}.language`, 'signal.rule.query': `${ALERT_RULE_PARAMETERS}.query`, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx index 82207906a62953..a2638c7b8eb0b5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx @@ -448,3 +448,13 @@ export const getMappedNonEcsValue = ({ } return undefined; }; + +export const useGetMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + return useMemo(() => getMappedNonEcsValue({ data, fieldName }), [data, fieldName]); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx index 3e22cba208ca2d..6f9bcc61a96931 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.test.tsx @@ -18,7 +18,7 @@ import { } from '../../../../../../common/types/timeline'; import { StatefulCell } from './stateful_cell'; -import { getMappedNonEcsValue } from '.'; +import { useGetMappedNonEcsValue } from '.'; /** * This (test) component implement's `EuiDataGrid`'s `renderCellValue` interface, @@ -30,14 +30,13 @@ import { getMappedNonEcsValue } from '.'; * https://codesandbox.io/s/zhxmo */ const RenderCellValue: React.FC = ({ columnId, data, setCellProps }) => { + const value = useGetMappedNonEcsValue({ + data, + fieldName: columnId, + }); useEffect(() => { // branching logic that conditionally renders a specific cell green: if (columnId === defaultHeaders[0].id) { - const value = getMappedNonEcsValue({ - data, - fieldName: columnId, - }); - if (value?.length) { setCellProps({ style: { @@ -46,16 +45,9 @@ const RenderCellValue: React.FC = ({ columnId, data, setC }); } } - }, [columnId, data, setCellProps]); + }, [columnId, data, setCellProps, value]); - return ( -
- {getMappedNonEcsValue({ - data, - fieldName: columnId, - })} -
- ); + return
{value}
; }; describe('StatefulCell', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 82f9cedc57a9cd..3f85551d005d0f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -39,7 +39,7 @@ import { getRowRenderer } from '../renderers/get_row_renderer'; import { StatefulRowRenderer } from './stateful_row_renderer'; import { NOTES_BUTTON_CLASS_NAME } from '../../properties/helpers'; import { timelineDefaults } from '../../../../store/timeline/defaults'; -import { getMappedNonEcsValue } from '../data_driven_columns'; +import { useGetMappedNonEcsValue } from '../data_driven_columns'; import { StatefulEventContext } from '../../../../../../../timelines/public'; interface Props { @@ -115,21 +115,23 @@ const StatefulEventComponent: React.FC = ({ const expandedDetail = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail ?? {} ); + const hostNameArr = useGetMappedNonEcsValue({ data: event?.data, fieldName: 'host.name' }); + const hostName = useMemo(() => { - const hostNameArr = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.name' }); return hostNameArr && hostNameArr.length > 0 ? hostNameArr[0] : null; - }, [event?.data]); - + }, [hostNameArr]); + const hostIpList = useGetMappedNonEcsValue({ data: event?.data, fieldName: 'host.ip' }); + const sourceIpList = useGetMappedNonEcsValue({ data: event?.data, fieldName: 'source.ip' }); + const destinationIpList = useGetMappedNonEcsValue({ + data: event?.data, + fieldName: 'destination.ip', + }); const hostIPAddresses = useMemo(() => { - const hostIpList = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.ip' }) ?? []; - const sourceIpList = getMappedNonEcsValue({ data: event?.data, fieldName: 'source.ip' }) ?? []; - const destinationIpList = - getMappedNonEcsValue({ - data: event?.data, - fieldName: 'destination.ip', - }) ?? []; - return new Set([...hostIpList, ...sourceIpList, ...destinationIpList]); - }, [event?.data]); + const hostIps = hostIpList ?? []; + const sourceIps = sourceIpList ?? []; + const destinationIps = destinationIpList ?? []; + return new Set([...hostIps, ...sourceIps, ...destinationIps]); + }, [destinationIpList, sourceIpList, hostIpList]); const activeTab = tabType ?? TimelineTabs.query; const activeExpandedDetail = expandedDetail[activeTab]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index ffd8da99bb6079..ce120f10d2fb39 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -221,7 +221,6 @@ const FormattedFieldValueComponent: React.FC<{ Component, eventId, fieldName, - linkValue, isDraggable, truncate, title, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx index f25f80aee1d691..267126843b5179 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field_helpers.tsx @@ -250,7 +250,6 @@ export const renderUrl = ({ eventId, fieldName, isDraggable, - linkValue, truncate, title, value, @@ -261,7 +260,6 @@ export const renderUrl = ({ eventId: string; fieldName: string; isDraggable: boolean; - linkValue: string | null | undefined; truncate?: boolean; title?: string; value: string | number | null | undefined; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx index c5b63cb5989696..7abd2c8ef04833 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx @@ -5,14 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; -import { getMappedNonEcsValue } from '../body/data_driven_columns'; +import { useGetMappedNonEcsValue } from '../body/data_driven_columns'; import { columnRenderers } from '../body/renderers'; import { getColumnRenderer } from '../body/renderers/get_column_renderer'; import { CellValueElementProps } from '.'; -import { getLink } from '../../../../common/lib/cell_actions/helpers'; +import { getLinkColumnDefinition } from '../../../../common/lib/cell_actions/helpers'; import { FIELDS_WITHOUT_CELL_ACTIONS } from '../../../../common/lib/cell_actions/constants'; import { ExpandedCellValueActions, @@ -39,7 +39,10 @@ export const DefaultCellRenderer: React.FC = ({ timelineId, truncate, }) => { - const values = getMappedNonEcsValue({ + const asPlainText = useMemo(() => { + return getLinkColumnDefinition(header.id, header.type) !== undefined && !isTimeline; + }, [header.id, header.type, isTimeline]); + const values = useGetMappedNonEcsValue({ data, fieldName: header.id, }); @@ -50,7 +53,7 @@ export const DefaultCellRenderer: React.FC = ({ <> {getColumnRenderer(header.id, columnRenderers, data).renderColumn({ - asPlainText: !!getLink(header.id, header.type) && !isTimeline, // we want to render value with links as plain text but keep other formatters like badge. + asPlainText, // we want to render value with links as plain text but keep other formatters like badge. browserFields, columnName: header.id, ecsData, @@ -62,10 +65,7 @@ export const DefaultCellRenderer: React.FC = ({ rowRenderers, timelineId, truncate, - values: getMappedNonEcsValue({ - data, - fieldName: header.id, - }), + values, })} {isDetails && browserFields && hasCellActions(header.id) && ( diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 33eb87de7a0ce2..0abe40405c4ef3 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -672,7 +672,6 @@ export const BodyComponent = React.memo( pageSize, timelineId: id, }); - return { ...header, actions: { From 0e1c1e680fe42330229ab8237bd61a5a9396f657 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 13 Jan 2022 11:15:21 +0100 Subject: [PATCH 16/29] Update APM (#122688) Co-authored-by: Renovate Bot Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 4 ++-- yarn.lock | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index b6b5b233c4f759..ed36f3277ee643 100644 --- a/package.json +++ b/package.json @@ -100,8 +100,8 @@ "@dnd-kit/core": "^3.1.1", "@dnd-kit/sortable": "^4.0.0", "@dnd-kit/utilities": "^2.0.0", - "@elastic/apm-rum": "^5.10.0", - "@elastic/apm-rum-react": "^1.3.2", + "@elastic/apm-rum": "^5.10.1", + "@elastic/apm-rum-react": "^1.3.3", "@elastic/apm-synthtrace": "link:bazel-bin/packages/elastic-apm-synthtrace", "@elastic/charts": "40.2.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", diff --git a/yarn.lock b/yarn.lock index fd875d7f5ab302..cc181351a3d96d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1396,29 +1396,29 @@ dependencies: tslib "^2.0.0" -"@elastic/apm-rum-core@^5.13.0": - version "5.13.0" - resolved "https://registry.yarnpkg.com/@elastic/apm-rum-core/-/apm-rum-core-5.13.0.tgz#dbada016fc73c0be4d7df1ba835a1556dc48d21e" - integrity sha512-5kSTbdmyLfpCdLgoy387y+WMCVl4YuYHdkFgDWAGfOBR+aaOCQAcQoLc8KK6+FZoN/vqvVSFCN+GxxyBc9Kmdw== +"@elastic/apm-rum-core@^5.14.0": + version "5.14.0" + resolved "https://registry.yarnpkg.com/@elastic/apm-rum-core/-/apm-rum-core-5.14.0.tgz#b3eb2569b3bb3dc706f92e6ec830f45efef5e76c" + integrity sha512-AhmbApgdvfJGcZZD1RUIGQsFtWe89LN5fzMpse7yVqrhytKoSxaG3r1ap6xhHQIvTwHVIE3Bg8t1vviak1f4DQ== dependencies: error-stack-parser "^1.3.5" opentracing "^0.14.3" promise-polyfill "^8.1.3" -"@elastic/apm-rum-react@^1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@elastic/apm-rum-react/-/apm-rum-react-1.3.2.tgz#682dbf040aad4a6d7c423d0da81cc9e65a910693" - integrity sha512-ohSgd8wXPziJQpaixvbNAHL6/sMLBW+iOrxCFvCKF9gRllowUTqYi+etery96Hq0X8yXC+/fJg18ZXx9wlJLEQ== +"@elastic/apm-rum-react@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@elastic/apm-rum-react/-/apm-rum-react-1.3.3.tgz#99ba436ecbbc9332c98fba402c0f602de1bc747b" + integrity sha512-jrI29O1xRtiQov0OMuUSgM157RlcBDglxOhkkPxxzAXDW8NGZ/s3qAVwN/xoVNuhdIPRiRU2+ooODs3QPTfj/Q== dependencies: - "@elastic/apm-rum" "^5.10.0" + "@elastic/apm-rum" "^5.10.1" hoist-non-react-statics "^3.3.0" -"@elastic/apm-rum@^5.10.0": - version "5.10.0" - resolved "https://registry.yarnpkg.com/@elastic/apm-rum/-/apm-rum-5.10.0.tgz#ffadc50e53d7b7bc9f1c7851f7d70631c3e14eba" - integrity sha512-Aw3UwiduxNfJ0/S3Uq0cO8O+60RmEMa9AJGq6v8fFQ8UdnTV1IgT73NpPyMLtn3qBcKyJNKpvx0jW28w5IVLcQ== +"@elastic/apm-rum@^5.10.1": + version "5.10.1" + resolved "https://registry.yarnpkg.com/@elastic/apm-rum/-/apm-rum-5.10.1.tgz#d907f50799b7c5d61ef292558784d55eaafb109f" + integrity sha512-UtYjzmg6A2dpeU4mue/z75yvJSFhbBlngdz95rXJhPKLKXJWiUpKWParfNOaH52B7H/UzFDoHTaButVkdGC7UA== dependencies: - "@elastic/apm-rum-core" "^5.13.0" + "@elastic/apm-rum-core" "^5.14.0" "@elastic/apm-synthtrace@link:bazel-bin/packages/elastic-apm-synthtrace": version "0.0.0" From cce8091d5c036359b6f1e2587194d9405060b8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 13 Jan 2022 12:30:41 +0100 Subject: [PATCH 17/29] [UA] Fix a11y test (#122539) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/accessibility/apps/upgrade_assistant.ts | 13 ++++++++++++- .../page_objects/upgrade_assistant_page.ts | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/upgrade_assistant.ts b/x-pack/test/accessibility/apps/upgrade_assistant.ts index 49f2bd33910837..850cb5de52bbad 100644 --- a/x-pack/test/accessibility/apps/upgrade_assistant.ts +++ b/x-pack/test/accessibility/apps/upgrade_assistant.ts @@ -97,11 +97,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with logs collection disabled', async () => { + const loggingEnabled = await PageObjects.upgradeAssistant.isDeprecationLoggingEnabled(); + if (loggingEnabled) { + await PageObjects.upgradeAssistant.clickDeprecationLoggingToggle(); + } + + await retry.waitFor('Deprecation logging to be disabled', async () => { + return !(await PageObjects.upgradeAssistant.isDeprecationLoggingEnabled()); + }); await a11y.testAppSnapshot(); }); it('with logs collection enabled', async () => { - await PageObjects.upgradeAssistant.clickDeprecationLoggingToggle(); + const loggingEnabled = await PageObjects.upgradeAssistant.isDeprecationLoggingEnabled(); + if (!loggingEnabled) { + await PageObjects.upgradeAssistant.clickDeprecationLoggingToggle(); + } await retry.waitFor('UA external links title to be present', async () => { return testSubjects.isDisplayed('externalLinksTitle'); diff --git a/x-pack/test/functional/page_objects/upgrade_assistant_page.ts b/x-pack/test/functional/page_objects/upgrade_assistant_page.ts index f795a5fd441cd2..f59cf660139b9e 100644 --- a/x-pack/test/functional/page_objects/upgrade_assistant_page.ts +++ b/x-pack/test/functional/page_objects/upgrade_assistant_page.ts @@ -56,6 +56,10 @@ export class UpgradeAssistantPageObject extends FtrService { }); } + async isDeprecationLoggingEnabled(): Promise { + return await this.testSubjects.exists('externalLinksTitle'); + } + async clickResetLastCheckpointButton() { return await this.retry.try(async () => { await this.testSubjects.click('resetLastStoredDate'); From fcc8e9b11f507e86c02a008145d0f16fb65c0cc8 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Thu, 13 Jan 2022 12:48:58 +0100 Subject: [PATCH 18/29] [8.0] [UA] - Fix is successful check for ml upgrade (#122541) (#122774) * [Upgrade Assistant] Fix isSuccessful check in verifySnapshotUpgrade method (#122541) * Fix matches check * commit using @elastic.co * Improve error messages * Fix linter issues * Add docs to regex * Add metadata about failed snapshot to log * commit using @elastic.co --- .../server/routes/ml_snapshots.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts b/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts index e618ef4b97ba82..fb65f6d41c43e8 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/ml_snapshots.ts @@ -78,10 +78,12 @@ const verifySnapshotUpgrade = async ( const isSuccessful = Boolean( mlSnapshotDeprecations.find((snapshotDeprecation) => { + // This regex will match all the bracket pairs from the deprecation message, at the moment + // that should match 3 pairs: snapshotId, jobId and version in which the snapshot was made. const regex = /(?<=\[).*?(?=\])/g; const matches = snapshotDeprecation.message.match(regex); - if (matches?.length === 2) { + if (matches?.length === 3) { // If there is no matching snapshot, we assume the deprecation was resolved successfully return matches[0] === snapshotId && matches[1] === jobId ? false : true; } @@ -130,7 +132,11 @@ const getModelSnapshotUpgradeStatus = async ( } }; -export function registerMlSnapshotRoutes({ router, lib: { handleEsError } }: RouteDependencies) { +export function registerMlSnapshotRoutes({ + router, + log, + lib: { handleEsError }, +}: RouteDependencies) { // Upgrade ML model snapshot router.post( { @@ -256,7 +262,7 @@ export function registerMlSnapshotRoutes({ router, lib: { handleEsError } }: Rou 'xpack.upgradeAssistant.ml_snapshots.modelSnapshotUpgradeFailed', { defaultMessage: - "The upgrade that was started for this model snapshot doesn't exist anymore.", + 'The upgrade process for this model snapshot failed. Check the Elasticsearch logs for more details.', } ), }, @@ -286,7 +292,7 @@ export function registerMlSnapshotRoutes({ router, lib: { handleEsError } }: Rou body: { message: upgradeSnapshotError?.body?.error?.reason || - 'There was an error upgrading your snapshot. Check the Elasticsearch logs for more details.', + 'The upgrade process for this model snapshot stopped yet the snapshot is not upgraded. Check the Elasticsearch logs for more details.', }, }); } @@ -310,12 +316,15 @@ export function registerMlSnapshotRoutes({ router, lib: { handleEsError } }: Rou }); } + log.error( + `Failed to determine status of the ML model upgrade, upgradeStatus is not defined and snapshot upgrade is not completed. snapshotId=${snapshotId} and jobId=${jobId}` + ); return response.customError({ statusCode: upgradeSnapshotError ? upgradeSnapshotError.statusCode! : 500, body: { message: upgradeSnapshotError?.body?.error?.reason || - 'There was an error upgrading your snapshot. Check the Elasticsearch logs for more details.', + 'The upgrade process for this model snapshot completed yet the snapshot is not upgraded. Check the Elasticsearch logs for more details.', }, }); } From 6473ff34c58c453cfe09ac9e43ebe62d196a0cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Thu, 13 Jan 2022 09:48:12 -0300 Subject: [PATCH 19/29] [APM] Update the style of the service/backend info icons in the selected service/backend header (#122587) * adding box-shadow * addressing pr comments * removing console * addressing comments * addressing pr comments * addressing comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../shared/service_icons/icon_popover.tsx | 39 ++++-- .../components/shared/service_icons/index.tsx | 3 +- .../service_icons/service_icons.stories.tsx | 130 ++++++++++++++++++ 3 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/service_icons/service_icons.stories.tsx diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx index 19abd2059c903c..ac7c38dc2f8882 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx @@ -6,13 +6,15 @@ */ import { - EuiButtonEmpty, - EuiIcon, + EuiButtonIcon, EuiLoadingContent, EuiPopover, EuiPopoverTitle, } from '@elastic/eui'; +import { rgba } from 'polished'; import React from 'react'; +import styled from 'styled-components'; +import { PopoverItem } from '.'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; interface IconPopoverProps { @@ -22,12 +24,18 @@ interface IconPopoverProps { onClose: () => void; detailsFetchStatus: FETCH_STATUS; isOpen: boolean; - icon: { - type?: string; - size?: 's' | 'm' | 'l'; - color?: string; - }; + icon: PopoverItem['icon']; } + +const StyledButtonIcon = styled(EuiButtonIcon)` + &.serviceIcon_button { + box-shadow: ${({ theme }) => { + const shadowColor = theme.eui.euiShadowColor; + return `0px 0.7px 1.4px ${rgba(shadowColor, 0.07)}, + 0px 1.9px 4px ${rgba(shadowColor, 0.05)}, + 0px 4.5px 10px ${rgba(shadowColor, 0.05)} !important;`; + }} +`; export function IconPopover({ icon, title, @@ -46,13 +54,16 @@ export function IconPopover({ anchorPosition="downCenter" ownFocus={false} button={ - - - + } isOpen={isOpen} closePopover={onClose} diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx index 77639ea1f6d729..b0c6a66d849d86 100644 --- a/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx @@ -49,11 +49,10 @@ export function getContainerIcon(container?: ContainerType) { type Icons = 'service' | 'container' | 'cloud' | 'alerts'; -interface PopoverItem { +export interface PopoverItem { key: Icons; icon: { type?: string; - color?: string; size?: 's' | 'm' | 'l'; }; isVisible: boolean; diff --git a/x-pack/plugins/apm/public/components/shared/service_icons/service_icons.stories.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/service_icons.stories.tsx new file mode 100644 index 00000000000000..41a63eae56d526 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/service_icons/service_icons.stories.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react'; +import React from 'react'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public'; +import { + APIReturnType, + createCallApmApi, +} from '../../../services/rest/createCallApmApi'; +import { ServiceIcons } from './'; + +type ServiceDetailsReturnType = + APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/details'>; +type ServiceIconsReturnType = + APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/icons'>; + +interface Args { + serviceName: string; + start: string; + end: string; + icons: ServiceIconsReturnType; + details: ServiceDetailsReturnType; +} + +const stories: Meta = { + title: 'shared/ServiceIcons', + component: ServiceIcons, + decorators: [ + (StoryComponent, { args }) => { + const { icons, details, serviceName } = args; + + const coreMock = { + http: { + get: (endpoint: string) => { + switch (endpoint) { + case `/internal/apm/services/${serviceName}/metadata/icons`: + return icons; + default: + return details; + } + }, + }, + notifications: { toasts: { add: () => {} } }, + uiSettings: { get: () => true }, + } as unknown as CoreStart; + + const KibanaReactContext = createKibanaReactContext(coreMock); + + createCallApmApi(coreMock); + + return ( + + + + ); + }, + ], +}; +export default stories; + +export const Example: Story = ({ serviceName, start, end }) => { + return ( + + + +

+ + + + + +

+ {serviceName} +

+
+
+ + + +
+
+
+

+
+
+
+ ); +}; +Example.args = { + serviceName: 'opbeans-java', + start: '2021-09-10T13:59:00.000Z', + end: '2021-09-10T14:14:04.789Z', + icons: { + agentName: 'java', + containerType: 'Kubernetes', + cloudProvider: 'gcp', + }, + details: { + service: { + versions: ['2021-12-22 17:03:27'], + runtime: { name: 'Java', version: '11.0.11' }, + agent: { + name: 'java', + version: '1.28.3-SNAPSHOT.UNKNOWN', + }, + }, + container: { + os: 'Linux', + type: 'Kubernetes', + isContainerized: true, + totalNumberInstances: 1, + }, + cloud: { + provider: 'gcp', + projectName: 'elastic-observability', + availabilityZones: ['us-central1-c'], + machineTypes: ['n1-standard-4'], + }, + }, +}; From 2cc7af09d43eb6bc2c52155607224a5023182fe8 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 13 Jan 2022 12:50:04 +0000 Subject: [PATCH 20/29] skip flaky suite (#122927) --- .../apps/ml/data_frame_analytics/regression_creation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index 7a84c41aa4a661..1d5c6d1bf84a36 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -13,7 +13,8 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); const editedDescription = 'Edited description'; - describe('regression creation', function () { + // FLAKY: https://github.com/elastic/kibana/issues/122927 + describe.skip('regression creation', function () { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/egs_regression'); await ml.testResources.createIndexPatternIfNeeded('ft_egs_regression', '@timestamp'); From 59be3ca81d3affc66a6393e5463a65f05197c394 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Thu, 13 Jan 2022 08:32:12 -0500 Subject: [PATCH 21/29] [Security Solution] Unskip remaining Cypress tests from RAC rules migration (#122661) * Unskip indicator match timeline test * Unskip fields_browser tests * Enable alert_summary tests * add cti feed enrichment * Fix accessibility text in indicator match cypress test * Adjust fields_browser test to account for removed field * Correct indicator_match row renderer text in cypress test * Revert "Enable alert_summary tests" This reverts commit 05d549e5627536c656a6faace0ad3eea11e782d2. --- .../integration/detection_rules/indicator_match_rule.spec.ts | 5 +++-- .../cypress/integration/timelines/fields_browser.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index c4709d857d5d0e..3d93ad96706e38 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -511,7 +511,7 @@ describe('indicator match', () => { cy.get(ALERT_RISK_SCORE).first().should('have.text', getNewThreatIndicatorRule().riskScore); }); - it.skip('Investigate alert in timeline', () => { + it('Investigate alert in timeline', () => { const accessibilityText = `Press enter for options, or press space to begin dragging.`; loadPrepackagedTimelineTemplates(); @@ -540,7 +540,8 @@ describe('indicator match', () => { getNewThreatIndicatorRule().indicatorMappingField }${accessibilityText}matched${getNewThreatIndicatorRule().indicatorMappingField}${ getNewThreatIndicatorRule().atomic - }${accessibilityText}threat.enrichments.matched.typeindicator_match_rule${accessibilityText}` + }${accessibilityText}threat.enrichments.matched.typeindicator_match_rule${accessibilityText}provided` + + ` byfeed.nameAbuseCH malware${accessibilityText}` ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index 0a5db030f1dca6..df194136c6bb2c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -104,12 +104,12 @@ describe('Fields Browser', () => { }); }); - it.skip('displays a count of only the fields in the selected category that match the filter input', () => { + it('displays a count of only the fields in the selected category that match the filter input', () => { const filterInput = 'host.geo.c'; filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should('have.text', '5'); + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should('have.text', '4'); }); }); From c9dcd9332aba976e548eaf2e9cfe1fe33cc20c79 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Thu, 13 Jan 2022 08:33:12 -0500 Subject: [PATCH 22/29] [Security Solution] Unskip remaining Jest tests from RAC rules migration (#122677) * Regenerate snapshot of memory event summary rows * Regenerate snapshot of behavior event summary rows * Unskip StepAboutRuleComponent tests * Unskip add_prepackaged_rules tests * Unskip update_rules tests --- .../alert_summary_view.test.tsx.snap | 1028 +++-------------- .../event_details/alert_summary_view.test.tsx | 4 +- .../rules/step_about_rule/index.test.tsx | 16 +- .../rules/add_prepackaged_rules_route.test.ts | 50 +- .../rules/update_rules.test.ts | 33 +- 5 files changed, 194 insertions(+), 937 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap index 8772def6861228..2c7c820cdd7a35 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap @@ -25,8 +25,6 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` } .c2 { - min-width: 138px; - padding: 0 8px; display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -116,28 +114,30 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` class="euiTableRow" > +
- Status + host.name
+
- open -
-
-
-
-
-

- You are in a dialog, containing options for field kibana.alert.workflow_status. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button + windows-native
- - - - - -
-
- Timestamp -
-
- - -
-
-
- - Nov 25, 2020 @ 15:42:39.417 - -
-
@@ -229,7 +158,7 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`

- You are in a dialog, containing options for field @timestamp. Press tab to navigate options. Press escape to exit. + You are in a dialog, containing options for field host.name. Press tab to navigate options. Press escape to exit.

Overflow button
@@ -258,28 +187,30 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` class="euiTableRow" > +
- Rule + user.name
+
- xxx + administrator
@@ -300,7 +231,7 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`

- You are in a dialog, containing options for field kibana.alert.rule.name. Press tab to navigate options. Press escape to exit. + You are in a dialog, containing options for field user.name. Press tab to navigate options. Press escape to exit.

Overflow button
@@ -329,37 +260,45 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` class="euiTableRow" > +
- Severity + source.ip
+
-
- low -
+ +
- You are in a dialog, containing options for field kibana.alert.severity. Press tab to navigate options. Press escape to exit. + You are in a dialog, containing options for field source.ip. Press tab to navigate options. Press escape to exit.

Overflow button
@@ -396,776 +335,130 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1`
- + + +
+
+`; + +exports[`AlertSummaryView Memory event code renders additional summary rows 1`] = ` +.c0 .euiTableHeaderCell, +.c0 .euiTableRowCell { + border: none; +} + +.c0 .euiTableHeaderCell .euiTableCellContent { + padding: 0; +} + +.c0 .flyoutOverviewDescription .hoverActions-active .timelines__hoverActionButton, +.c0 .flyoutOverviewDescription .hoverActions-active .securitySolution__hoverActionButton { + opacity: 1; +} + +.c0 .flyoutOverviewDescription:hover .timelines__hoverActionButton, +.c0 .flyoutOverviewDescription:hover .securitySolution__hoverActionButton { + opacity: 1; +} + +.c1 { + line-height: 1.7rem; +} + +.c2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.c2:focus-within .timelines__hoverActionButton, +.c2:focus-within .securitySolution__hoverActionButton { + opacity: 1; +} + +.c2:hover .timelines__hoverActionButton, +.c2:hover .securitySolution__hoverActionButton { + opacity: 1; +} + +.c2 .timelines__hoverActionButton, +.c2 .securitySolution__hoverActionButton { + opacity: 0; +} + +.c2 .timelines__hoverActionButton:focus, +.c2 .securitySolution__hoverActionButton:focus { + opacity: 1; +} + +
+
+
+
+
+
+
+
+ + + - - - - - - - - - - - - - - -
+
-
-
- Risk Score -
-
+ +
-
-
-
-
- 21 -
-
-
-
-
-

- You are in a dialog, containing options for field kibana.alert.risk_score. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
- host.name -
-
-
-
-
-
-
- windows-native -
-
-
-
-
-

- You are in a dialog, containing options for field host.name. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
- user.name -
-
-
-
-
-
-
- administrator -
-
-
-
-
-

- You are in a dialog, containing options for field user.name. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
- source.ip -
-
-
-
-
-
- - - -
-
-
-
-

- You are in a dialog, containing options for field source.ip. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
-`; - -exports[`AlertSummaryView Memory event code renders additional summary rows 1`] = ` -.c0 .euiTableHeaderCell, -.c0 .euiTableRowCell { - border: none; -} - -.c0 .euiTableHeaderCell .euiTableCellContent { - padding: 0; -} - -.c0 .flyoutOverviewDescription .hoverActions-active .timelines__hoverActionButton, -.c0 .flyoutOverviewDescription .hoverActions-active .securitySolution__hoverActionButton { - opacity: 1; -} - -.c0 .flyoutOverviewDescription:hover .timelines__hoverActionButton, -.c0 .flyoutOverviewDescription:hover .securitySolution__hoverActionButton { - opacity: 1; -} - -.c1 { - line-height: 1.7rem; -} - -.c2 { - min-width: 138px; - padding: 0 8px; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; -} - -.c2:focus-within .timelines__hoverActionButton, -.c2:focus-within .securitySolution__hoverActionButton { - opacity: 1; -} - -.c2:hover .timelines__hoverActionButton, -.c2:hover .securitySolution__hoverActionButton { - opacity: 1; -} - -.c2 .timelines__hoverActionButton, -.c2 .securitySolution__hoverActionButton { - opacity: 0; -} - -.c2 .timelines__hoverActionButton:focus, -.c2 .securitySolution__hoverActionButton:focus { - opacity: 1; -} - -
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - + +
-
- - - - - - - -
-
-
- Status -
-
-
-
-
-
-
- open -
-
-
-
-
-

- You are in a dialog, containing options for field kibana.alert.workflow_status. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
- Timestamp -
-
-
-
-
-
- - Nov 25, 2020 @ 15:42:39.417 - -
-
-
-
-

- You are in a dialog, containing options for field @timestamp. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
- Rule -
-
-
-
-
-
-
- xxx -
-
-
-
-
-

- You are in a dialog, containing options for field kibana.alert.rule.name. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
- Severity -
-
-
-
-
-
-
- low -
-
-
-
-
-

- You are in a dialog, containing options for field kibana.alert.severity. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
-
-
-
- Risk Score -
-
-
-
-
-
-
- 21 -
-
-
-
-
-

- You are in a dialog, containing options for field kibana.alert.risk_score. Press tab to navigate options. Press escape to exit. -

-
- Filter button -
-
- Filter out button -
-
- Overflow button -
-
-
-
+ +
+
@@ -1177,8 +470,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
+
@@ -1234,9 +528,10 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`] class="euiTableRow" >
+
@@ -1248,8 +543,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
+
@@ -1305,9 +601,10 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`] class="euiTableRow" >
+
@@ -1319,8 +616,9 @@ exports[`AlertSummaryView Memory event code renders additional summary rows 1`]
+
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index c397ac313c48ce..1afba4184c4123 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -85,7 +85,7 @@ describe('AlertSummaryView', () => { expect(queryByTestId('summary-view-guide')).not.toBeInTheDocument(); }); }); - test.skip('Memory event code renders additional summary rows', () => { + test('Memory event code renders additional summary rows', () => { const renderProps = { ...props, data: mockAlertDetailsData.map((item) => { @@ -107,7 +107,7 @@ describe('AlertSummaryView', () => { ); expect(container.querySelector('div[data-test-subj="summary-view"]')).toMatchSnapshot(); }); - test.skip('Behavior event code renders additional summary rows', () => { + test('Behavior event code renders additional summary rows', () => { const renderProps = { ...props, data: mockAlertDetailsData.map((item) => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index 01ba47f728e430..3c34897fe2e657 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -44,8 +44,7 @@ jest.mock('@elastic/eui', () => { }; }); -// Failing with rule registry enabled -describe.skip('StepAboutRuleComponent', () => { +describe('StepAboutRuleComponent', () => { let formHook: RuleStepsFormHooks[RuleStep.aboutRule] | null = null; const setFormHook = ( step: K, @@ -149,14 +148,19 @@ describe.skip('StepAboutRuleComponent', () => { ); + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') + .first() + .simulate('change', { target: { value: 'Test description text' } }); + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') + .first() + .simulate('change', { target: { value: 'Test name text' } }); + await act(async () => { if (!formHook) { throw new Error('Form hook not set, but tests depend on it'); } - wrapper - .find('[data-test-subj="detectionEngineStepAboutThreatIndicatorPath"] input') - .first() - .simulate('change', { target: { value: '' } }); const result = await formHook(); expect(result?.isValid).toEqual(true); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index a094ea84e9bf1a..3ec8cb733aa287 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -11,7 +11,6 @@ import { getFindResultWithSingleHit, getAlertMock, getBasicEmptySearchResponse, - getBasicNoShardsSearchResponse, } from '../__mocks__/request_responses'; import { configMock, requestContextMock, serverMock } from '../__mocks__'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; @@ -71,15 +70,10 @@ jest.mock('../../../timeline/routes/prepackaged_timelines/install_prepackaged_ti }; }); -// Failing with rule registry enabled -describe.skip.each([ - ['Legacy', false], - ['RAC', true], -])('add_prepackaged_rules_route - %s', (_, isRuleRegistryEnabled) => { +describe('add_prepackaged_rules_route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let mockExceptionsClient: ExceptionListClient; - const testif = isRuleRegistryEnabled ? test.skip : test; const defaultConfig = context.securitySolution.getConfig(); beforeEach(() => { @@ -88,13 +82,11 @@ describe.skip.each([ mockExceptionsClient = listMock.getExceptionListClient(); context.securitySolution.getConfig.mockImplementation(() => - configMock.withRuleRegistryEnabled(defaultConfig, isRuleRegistryEnabled) + configMock.withRuleRegistryEnabled(defaultConfig, true) ); - clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); - clients.rulesClient.update.mockResolvedValue( - getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) - ); + clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(true)); + clients.rulesClient.update.mockResolvedValue(getAlertMock(true, getQueryRuleParams())); (installPrepackagedTimelines as jest.Mock).mockReset(); (installPrepackagedTimelines as jest.Mock).mockResolvedValue({ @@ -131,26 +123,6 @@ describe.skip.each([ }); }); - test('it returns a 400 if the index does not exist when rule registry not enabled', async () => { - const request = addPrepackagedRulesRequest(); - context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - getBasicNoShardsSearchResponse() - ) - ); - const response = await server.inject(request, context); - - expect(response.status).toEqual(isRuleRegistryEnabled ? 200 : 400); - if (!isRuleRegistryEnabled) { - expect(response.body).toEqual({ - status_code: 400, - message: expect.stringContaining( - 'Pre-packaged rules cannot be installed until the signals index is created' - ), - }); - } - }); - test('returns 404 if siem client is unavailable', async () => { const { securitySolution, ...contextWithoutSecuritySolution } = context; const response = await server.inject( @@ -190,20 +162,6 @@ describe.skip.each([ timelines_updated: 0, }); }); - - testif( - 'catches errors if signals index does not exist when rule registry not enabled', - async () => { - context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( - elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Test error')) - ); - const request = addPrepackagedRulesRequest(); - const response = await server.inject(request, context); - - expect(response.status).toEqual(500); - expect(response.body).toEqual({ message: 'Test error', status_code: 500 }); - } - ); }); test('should install prepackaged timelines', async () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts index 79371aa6e68b6b..ecf625ceaee174 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.test.ts @@ -12,18 +12,12 @@ import { RulesClientMock } from '../../../../../alerting/server/rules_client.moc import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; // Failing with rule registry enabled -describe.skip.each([ - ['Legacy', false], - ['RAC', true], -])('updateRules - %s', (_, isRuleRegistryEnabled) => { +describe('updateRules', () => { it('should call rulesClient.disable if the rule was enabled and enabled is false', async () => { - const rulesOptionsMock = getUpdateRulesOptionsMock(isRuleRegistryEnabled); + const rulesOptionsMock = getUpdateRulesOptionsMock(true); rulesOptionsMock.ruleUpdate.enabled = false; - (rulesOptionsMock.rulesClient as unknown as RulesClientMock).resolve.mockResolvedValue( - resolveAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) - ); (rulesOptionsMock.rulesClient as unknown as RulesClientMock).update.mockResolvedValue( - getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) + getAlertMock(true, getQueryRuleParams()) ); await updateRules(rulesOptionsMock); @@ -36,15 +30,18 @@ describe.skip.each([ }); it('should call rulesClient.enable if the rule was disabled and enabled is true', async () => { - const rulesOptionsMock = getUpdateRulesOptionsMock(isRuleRegistryEnabled); + const baseRulesOptionsMock = getUpdateRulesOptionsMock(true); + const rulesOptionsMock = { + ...baseRulesOptionsMock, + existingRule: { + ...baseRulesOptionsMock.existingRule, + enabled: false, + }, + }; rulesOptionsMock.ruleUpdate.enabled = true; - (rulesOptionsMock.rulesClient as unknown as RulesClientMock).resolve.mockResolvedValue({ - ...resolveAlertMock(isRuleRegistryEnabled, getQueryRuleParams()), - enabled: false, - }); (rulesOptionsMock.rulesClient as unknown as RulesClientMock).update.mockResolvedValue( - getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) + getAlertMock(true, getQueryRuleParams()) ); await updateRules(rulesOptionsMock); @@ -57,15 +54,15 @@ describe.skip.each([ }); it('calls the rulesClient with params', async () => { - const rulesOptionsMock = getUpdateMlRulesOptionsMock(isRuleRegistryEnabled); + const rulesOptionsMock = getUpdateMlRulesOptionsMock(true); rulesOptionsMock.ruleUpdate.enabled = true; (rulesOptionsMock.rulesClient as unknown as RulesClientMock).update.mockResolvedValue( - getAlertMock(isRuleRegistryEnabled, getMlRuleParams()) + getAlertMock(true, getMlRuleParams()) ); (rulesOptionsMock.rulesClient as unknown as RulesClientMock).resolve.mockResolvedValue( - resolveAlertMock(isRuleRegistryEnabled, getMlRuleParams()) + resolveAlertMock(true, getMlRuleParams()) ); await updateRules(rulesOptionsMock); From d0850b0dd157d8790a70991c6feb347f6bda026d Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 13 Jan 2022 09:24:19 -0500 Subject: [PATCH 23/29] [Fleet] Add reset preconfigured policies API (#122467) --- .../plugins/fleet/common/constants/routes.ts | 4 + .../reset_preconfiguration.test.ts | 236 ++++++++++++++++++ .../server/routes/preconfiguration/handler.ts | 77 ++++++ .../server/routes/preconfiguration/index.ts | 61 ++--- .../fleet/server/services/agent_policy.ts | 34 ++- .../services/api_keys/enrollment_api_key.ts | 32 ++- .../server/services/preconfiguration/index.ts | 8 + .../preconfiguration/reset_agent_policies.ts | 149 +++++++++++ .../types/rest_spec/preconfiguration.ts | 6 + .../server/lib/reindexing/worker.ts | 32 ++- .../reindex_indices/create_reindex_worker.ts | 2 +- 11 files changed, 584 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/fleet/server/integration_tests/reset_preconfiguration.test.ts create mode 100644 x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts create mode 100644 x-pack/plugins/fleet/server/services/preconfiguration/index.ts create mode 100644 x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 69363f37d33e04..81108b15f4aa14 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -7,6 +7,8 @@ // Base API paths +export const INTERNAL_ROOT = `/internal/fleet`; + export const API_ROOT = `/api/fleet`; export const EPM_API_ROOT = `${API_ROOT}/epm`; export const DATA_STREAM_API_ROOT = `${API_ROOT}/data_streams`; @@ -133,4 +135,6 @@ export const INSTALL_SCRIPT_API_ROUTES = `${API_ROOT}/install/{osType}`; // Policy preconfig API routes export const PRECONFIGURATION_API_ROUTES = { UPDATE_PATTERN: `${API_ROOT}/setup/preconfiguration`, + RESET_PATTERN: `${INTERNAL_ROOT}/reset_preconfigured_agent_policies`, + RESET_ONE_PATTERN: `${INTERNAL_ROOT}/reset_preconfigured_agent_policies/{agentPolicyId}`, }; diff --git a/x-pack/plugins/fleet/server/integration_tests/reset_preconfiguration.test.ts b/x-pack/plugins/fleet/server/integration_tests/reset_preconfiguration.test.ts new file mode 100644 index 00000000000000..4096035f840e08 --- /dev/null +++ b/x-pack/plugins/fleet/server/integration_tests/reset_preconfiguration.test.ts @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Path from 'path'; + +import * as kbnTestServer from 'src/core/test_helpers/kbn_server'; + +import type { AgentPolicySOAttributes } from '../types'; + +const logFilePath = Path.join(__dirname, 'logs.log'); + +type Root = ReturnType; + +const waitForFleetSetup = async (root: Root) => { + const isFleetSetupRunning = async () => { + const statusApi = kbnTestServer.getSupertest(root, 'get', '/api/status'); + const resp = await statusApi.send(); + const fleetStatus = resp.body?.status?.plugins?.fleet; + if (fleetStatus?.meta?.error) { + throw new Error(`Setup failed: ${JSON.stringify(fleetStatus)}`); + } + + return !fleetStatus || fleetStatus?.summary === 'Fleet is setting up'; + }; + + while (await isFleetSetupRunning()) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +}; + +describe('Fleet preconfiguration rest', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let kbnServer: kbnTestServer.TestKibanaUtils; + + const startServers = async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + }, + kbn: {}, + }, + }); + + esServer = await startES(); + const startKibana = async () => { + const root = kbnTestServer.createRootWithCorePlugins( + { + xpack: { + fleet: { + agentPolicies: [ + { + name: 'Elastic Cloud agent policy 0001', + description: 'Default agent policy for agents hosted on Elastic Cloud', + is_default: false, + is_managed: true, + id: 'test-12345', + namespace: 'default', + monitoring_enabled: [], + package_policies: [ + { + name: 'fleet_server123456789', + package: { + name: 'fleet_server', + }, + inputs: [ + { + type: 'fleet-server', + keep_enabled: true, + vars: [ + { + name: 'host', + value: '127.0.0.1', + frozen: true, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + { + name: 'plugins.fleet', + level: 'all', + }, + ], + }, + }, + { oss: false } + ); + + await root.preboot(); + const coreSetup = await root.setup(); + const coreStart = await root.start(); + + return { + root, + coreSetup, + coreStart, + stop: async () => await root.shutdown(), + }; + }; + kbnServer = await startKibana(); + await waitForFleetSetup(kbnServer.root); + }; + + const stopServers = async () => { + if (kbnServer) { + await kbnServer.stop(); + } + + if (esServer) { + await esServer.stop(); + } + + await new Promise((res) => setTimeout(res, 10000)); + }; + + beforeEach(async () => { + await startServers(); + }); + + afterEach(async () => { + await stopServers(); + }); + + describe('Reset all policy', () => { + it('Works and reset all preconfigured policies', async () => { + const resetAPI = kbnTestServer.getSupertest( + kbnServer.root, + 'post', + '/internal/fleet/reset_preconfigured_agent_policies' + ); + await resetAPI.set('kbn-sxrf', 'xx').send(); + + const agentPolicies = await kbnServer.coreStart.savedObjects + .createInternalRepository() + .find({ + type: 'ingest-agent-policies', + perPage: 10000, + }); + expect(agentPolicies.saved_objects).toHaveLength(1); + expect(agentPolicies.saved_objects.map((ap) => ap.attributes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Elastic Cloud agent policy 0001', + }), + ]) + ); + }); + }); + + describe('Reset one preconfigured policy', () => { + const POLICY_ID = 'test-12345'; + + it('Works and reset one preconfigured policies if the policy is already deleted (with a ghost package policy)', async () => { + const soClient = kbnServer.coreStart.savedObjects.createInternalRepository(); + + await soClient.delete('ingest-agent-policies', POLICY_ID); + + const resetAPI = kbnTestServer.getSupertest( + kbnServer.root, + 'post', + '/internal/fleet/reset_preconfigured_agent_policies/test-12345' + ); + await resetAPI.set('kbn-sxrf', 'xx').send(); + + const agentPolicies = await kbnServer.coreStart.savedObjects + .createInternalRepository() + .find({ + type: 'ingest-agent-policies', + perPage: 10000, + }); + expect(agentPolicies.saved_objects).toHaveLength(1); + expect(agentPolicies.saved_objects.map((ap) => ap.attributes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Elastic Cloud agent policy 0001', + }), + ]) + ); + }); + + it('Works if the preconfigured policies already exists with a missing package policy', async () => { + const soClient = kbnServer.coreStart.savedObjects.createInternalRepository(); + + await soClient.update('ingest-agent-policies', POLICY_ID, { + package_policies: [], + }); + + const resetAPI = kbnTestServer.getSupertest( + kbnServer.root, + 'post', + '/internal/fleet/reset_preconfigured_agent_policies/test-12345' + ); + await resetAPI.set('kbn-sxrf', 'xx').send(); + + const agentPolicies = await soClient.find({ + type: 'ingest-agent-policies', + perPage: 10000, + }); + expect(agentPolicies.saved_objects).toHaveLength(1); + expect(agentPolicies.saved_objects.map((ap) => ap.attributes)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Elastic Cloud agent policy 0001', + package_policies: expect.arrayContaining([expect.stringMatching(/.*/)]), + }), + ]) + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts new file mode 100644 index 00000000000000..6e2e320db322e7 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; + +import type { PreconfiguredAgentPolicy } from '../../../common'; + +import type { FleetRequestHandler } from '../../types'; +import type { + PutPreconfigurationSchema, + PostResetOnePreconfiguredAgentPolicies, +} from '../../types'; +import { defaultIngestErrorHandler } from '../../errors'; +import { ensurePreconfiguredPackagesAndPolicies, outputService } from '../../services'; +import { resetPreconfiguredAgentPolicies } from '../../services/preconfiguration/index'; + +export const updatePreconfigurationHandler: FleetRequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const esClient = context.core.elasticsearch.client.asInternalUser; + const defaultOutput = await outputService.ensureDefaultOutput(soClient); + const spaceId = context.fleet.spaceId; + const { agentPolicies, packages } = request.body; + + try { + const body = await ensurePreconfiguredPackagesAndPolicies( + soClient, + esClient, + (agentPolicies as PreconfiguredAgentPolicy[]) ?? [], + packages ?? [], + defaultOutput, + spaceId + ); + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const resetPreconfigurationHandler: FleetRequestHandler< + TypeOf, + undefined, + undefined +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const esClient = context.core.elasticsearch.client.asInternalUser; + + try { + await resetPreconfiguredAgentPolicies(soClient, esClient, request.params.agentPolicyid); + return response.ok({}); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const resetOnePreconfigurationHandler: FleetRequestHandler< + undefined, + undefined, + undefined +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const esClient = context.core.elasticsearch.client.asInternalUser; + + try { + await resetPreconfiguredAgentPolicies(soClient, esClient); + return response.ok({}); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts index 56cbbc9435a57f..ec904e64a18dec 100644 --- a/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/index.ts @@ -5,45 +5,38 @@ * 2.0. */ -import type { RequestHandler } from 'src/core/server'; -import type { TypeOf } from '@kbn/config-schema'; - -import type { PreconfiguredAgentPolicy } from '../../../common'; - import { PRECONFIGURATION_API_ROUTES } from '../../constants'; -import type { FleetRequestHandler } from '../../types'; import { PutPreconfigurationSchema } from '../../types'; -import { defaultIngestErrorHandler } from '../../errors'; -import { ensurePreconfiguredPackagesAndPolicies, outputService } from '../../services'; import type { FleetAuthzRouter } from '../security'; -export const updatePreconfigurationHandler: FleetRequestHandler< - undefined, - undefined, - TypeOf -> = async (context, request, response) => { - const soClient = context.core.savedObjects.client; - const esClient = context.core.elasticsearch.client.asInternalUser; - const defaultOutput = await outputService.ensureDefaultOutput(soClient); - const spaceId = context.fleet.spaceId; - const { agentPolicies, packages } = request.body; - - try { - const body = await ensurePreconfiguredPackagesAndPolicies( - soClient, - esClient, - (agentPolicies as PreconfiguredAgentPolicy[]) ?? [], - packages ?? [], - defaultOutput, - spaceId - ); - return response.ok({ body }); - } catch (error) { - return defaultIngestErrorHandler({ error, response }); - } -}; +import { + updatePreconfigurationHandler, + resetPreconfigurationHandler, + resetOnePreconfigurationHandler, +} from './handler'; export const registerRoutes = (router: FleetAuthzRouter) => { + router.post( + { + path: PRECONFIGURATION_API_ROUTES.RESET_PATTERN, + validate: false, + fleetAuthz: { + fleet: { all: true }, + }, + }, + resetPreconfigurationHandler + ); + router.post( + { + path: PRECONFIGURATION_API_ROUTES.RESET_ONE_PATTERN, + validate: false, + fleetAuthz: { + fleet: { all: true }, + }, + }, + resetOnePreconfigurationHandler + ); + router.put( { path: PRECONFIGURATION_API_ROUTES.UPDATE_PATTERN, @@ -52,6 +45,6 @@ export const registerRoutes = (router: FleetAuthzRouter) => { fleet: { all: true }, }, }, - updatePreconfigurationHandler as RequestHandler + updatePreconfigurationHandler ); }; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 20fb4e3c73e77a..041b0a45643e04 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -618,22 +618,23 @@ class AgentPolicyService { public async delete( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - id: string + id: string, + options?: { force?: boolean; removeFleetServerDocuments?: boolean } ): Promise { const agentPolicy = await this.get(soClient, id, false); if (!agentPolicy) { throw new Error('Agent policy not found'); } - if (agentPolicy.is_managed) { + if (agentPolicy.is_managed && !options?.force) { throw new HostedAgentPolicyRestrictionRelatedError(`Cannot delete hosted agent policy ${id}`); } - if (agentPolicy.is_default) { + if (agentPolicy.is_default && !options?.force) { throw new Error('The default agent policy cannot be deleted'); } - if (agentPolicy.is_default_fleet_server) { + if (agentPolicy.is_default_fleet_server && !options?.force) { throw new Error('The default fleet server agent policy cannot be deleted'); } @@ -655,6 +656,7 @@ class AgentPolicyService { esClient, agentPolicy.package_policies as string[], { + force: options?.force, skipUnassignFromAgentPolicies: true, } ); @@ -667,7 +669,7 @@ class AgentPolicyService { } } - if (agentPolicy.is_preconfigured) { + if (agentPolicy.is_preconfigured && !options?.force) { await soClient.create(PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, { id: String(id), }); @@ -675,6 +677,11 @@ class AgentPolicyService { await soClient.delete(SAVED_OBJECT_TYPE, id); await this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'deleted', id); + + if (options?.removeFleetServerDocuments) { + this.deleteFleetServerPoliciesForPolicyId(esClient, id); + } + return { id, name: agentPolicy.name, @@ -720,6 +727,23 @@ class AgentPolicyService { }); } + public async deleteFleetServerPoliciesForPolicyId( + esClient: ElasticsearchClient, + agentPolicyId: string + ) { + await esClient.deleteByQuery({ + index: AGENT_POLICY_INDEX, + ignore_unavailable: true, + body: { + query: { + term: { + policy_id: agentPolicyId, + }, + }, + }, + }); + } + public async getLatestFleetPolicy(esClient: ElasticsearchClient, agentPolicyId: string) { const res = await esClient.search({ index: AGENT_POLICY_INDEX, diff --git a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts index 568a8e45e1dffc..76e2d02970de09 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/enrollment_api_key.ts @@ -98,21 +98,33 @@ export async function getEnrollmentAPIKey( * Invalidate an api key and mark it as inactive * @param id */ -export async function deleteEnrollmentApiKey(esClient: ElasticsearchClient, id: string) { +export async function deleteEnrollmentApiKey( + esClient: ElasticsearchClient, + id: string, + forceDelete = false +) { const enrollmentApiKey = await getEnrollmentAPIKey(esClient, id); await invalidateAPIKeys([enrollmentApiKey.api_key_id]); - await esClient.update({ - index: ENROLLMENT_API_KEYS_INDEX, - id, - body: { - doc: { - active: false, + if (forceDelete) { + await esClient.delete({ + index: ENROLLMENT_API_KEYS_INDEX, + id, + refresh: 'wait_for', + }); + } else { + await esClient.update({ + index: ENROLLMENT_API_KEYS_INDEX, + id, + body: { + doc: { + active: false, + }, }, - }, - refresh: 'wait_for', - }); + refresh: 'wait_for', + }); + } } export async function deleteEnrollmentApiKeyForAgentPolicyId( diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/index.ts b/x-pack/plugins/fleet/server/services/preconfiguration/index.ts new file mode 100644 index 00000000000000..ccd550759337b2 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/preconfiguration/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { resetPreconfiguredAgentPolicies } from './reset_agent_policies'; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts b/x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts new file mode 100644 index 00000000000000..4285b62899d349 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import pMap from 'p-map'; +import type { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'src/core/server'; + +import { appContextService } from '../app_context'; +import { setupFleet } from '../setup'; +import { + AGENT_POLICY_SAVED_OBJECT_TYPE, + SO_SEARCH_LIMIT, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, +} from '../../constants'; +import { agentPolicyService } from '../agent_policy'; +import { packagePolicyService } from '../package_policy'; +import { getAgentsByKuery, forceUnenrollAgent } from '../agents'; +import { listEnrollmentApiKeys, deleteEnrollmentApiKey } from '../api_keys'; +import type { AgentPolicy } from '../../types'; + +export async function resetPreconfiguredAgentPolicies( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + agentPolicyId?: string +) { + const logger = appContextService.getLogger(); + logger.warn('Reseting Fleet preconfigured agent policies'); + await _deleteExistingData(soClient, esClient, logger, agentPolicyId); + await _deleteGhostPackagePolicies(soClient, esClient, logger); + + await setupFleet(soClient, esClient); +} + +/** + * Delete all package policies that are not used in any agent policies + */ +async function _deleteGhostPackagePolicies( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + logger: Logger +) { + const { items: packagePolicies } = await packagePolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + }); + + const policyIds = Array.from( + packagePolicies.reduce((acc, packagePolicy) => { + acc.add(packagePolicy.policy_id); + + return acc; + }, new Set()) + ); + + const objects = policyIds.map((id) => ({ id, type: AGENT_POLICY_SAVED_OBJECT_TYPE })); + const agentPolicyExistsMap = (await soClient.bulkGet(objects)).saved_objects.reduce((acc, so) => { + if (so.error && so.error.statusCode === 404) { + acc.set(so.id, false); + } else { + acc.set(so.id, true); + } + return acc; + }, new Map()); + + await pMap( + packagePolicies, + (packagePolicy) => { + if (agentPolicyExistsMap.get(packagePolicy.policy_id) === false) { + logger.info(`Deleting ghost package policy ${packagePolicy.name} (${packagePolicy.id})`); + return soClient.delete(PACKAGE_POLICY_SAVED_OBJECT_TYPE, packagePolicy.id); + } + }, + { + concurrency: 20, + } + ); +} + +async function _deleteExistingData( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + logger: Logger, + agentPolicyId?: string +) { + let existingPolicies: AgentPolicy[]; + + if (agentPolicyId) { + const policy = await agentPolicyService.get(soClient, agentPolicyId); + if (!policy || !policy.is_preconfigured) { + throw new Error('Invalid policy'); + } + existingPolicies = [policy]; + } + { + existingPolicies = ( + await agentPolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + kuery: `${AGENT_POLICY_SAVED_OBJECT_TYPE}.is_preconfigured:true`, + }) + ).items; + } + + // unenroll all the agents enroled in this policies + const { agents } = await getAgentsByKuery(esClient, { + showInactive: false, + perPage: SO_SEARCH_LIMIT, + kuery: existingPolicies.map((policy) => `policy_id:"${policy.id}"`).join(' or '), + }); + + // Delete + if (agents.length > 0) { + logger.info(`Force unenrolling ${agents.length} agents`); + await pMap(agents, (agent) => forceUnenrollAgent(soClient, esClient, agent.id), { + concurrency: 20, + }); + } + + const { items: enrollmentApiKeys } = await listEnrollmentApiKeys(esClient, { + perPage: SO_SEARCH_LIMIT, + showInactive: true, + }); + + if (enrollmentApiKeys.length > 0) { + logger.info(`Deleting ${enrollmentApiKeys.length} enrollment api keys`); + await pMap( + enrollmentApiKeys, + (enrollmentKey) => deleteEnrollmentApiKey(esClient, enrollmentKey.id, true), + { + concurrency: 20, + } + ); + } + if (existingPolicies.length > 0) { + logger.info(`Deleting ${existingPolicies.length} agent policies`); + await pMap( + existingPolicies, + (policy) => + agentPolicyService.delete(soClient, esClient, policy.id, { + force: true, + removeFleetServerDocuments: true, + }), + { + concurrency: 20, + } + ); + } +} diff --git a/x-pack/plugins/fleet/server/types/rest_spec/preconfiguration.ts b/x-pack/plugins/fleet/server/types/rest_spec/preconfiguration.ts index dc802b89f1894c..936469a16100fa 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/preconfiguration.ts @@ -15,3 +15,9 @@ export const PutPreconfigurationSchema = { packages: schema.maybe(PreconfiguredPackagesSchema), }), }; + +export const PostResetOnePreconfiguredAgentPolicies = { + params: schema.object({ + agentPolicyid: schema.string(), + }), +}; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index 3491c92ef59533..579e4ddb213376 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -49,7 +49,31 @@ export class ReindexWorker { private readonly log: Logger; private readonly security: SecurityPluginStart; - constructor( + public static create( + client: SavedObjectsClientContract, + credentialStore: CredentialStore, + clusterClient: IClusterClient, + log: Logger, + licensing: LicensingPluginSetup, + security: SecurityPluginStart + ): ReindexWorker { + if (ReindexWorker.workerSingleton) { + log.debug(`More than one ReindexWorker cannot be created, returning existing worker.`); + } else { + ReindexWorker.workerSingleton = new ReindexWorker( + client, + credentialStore, + clusterClient, + log, + licensing, + security + ); + } + + return ReindexWorker.workerSingleton; + } + + private constructor( private client: SavedObjectsClientContract, private credentialStore: CredentialStore, private clusterClient: IClusterClient, @@ -60,10 +84,6 @@ export class ReindexWorker { this.log = log.get('reindex_worker'); this.security = security; - if (ReindexWorker.workerSingleton) { - throw new Error(`More than one ReindexWorker cannot be created.`); - } - const callAsInternalUser = this.clusterClient.asInternalUser; this.reindexService = reindexServiceFactory( @@ -72,8 +92,6 @@ export class ReindexWorker { log, this.licensing ); - - ReindexWorker.workerSingleton = this; } /** diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/create_reindex_worker.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/create_reindex_worker.ts index 72d68fc132cb68..d20912e56fe662 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/create_reindex_worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/create_reindex_worker.ts @@ -34,5 +34,5 @@ export function createReindexWorker({ security, }: CreateReindexWorker) { const esClient = elasticsearchService.client; - return new ReindexWorker(savedObjects, credentialStore, esClient, logger, licensing, security); + return ReindexWorker.create(savedObjects, credentialStore, esClient, logger, licensing, security); } From f1f35660f0bb0c951a663f42fbeb960e63b20e68 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 13 Jan 2022 15:28:28 +0100 Subject: [PATCH 24/29] hide time_in_millis column (#122936) --- .../pipelines/expanded_row.tsx | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/expanded_row.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/expanded_row.tsx index 272f3e4346a5ba..e4d18a7f1b0c4b 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/expanded_row.tsx @@ -76,35 +76,42 @@ export const ProcessorsStats: FC = ({ stats }) => { truncateText: true, 'data-test-subj': 'mlProcessorStatsCount', }, - { - field: 'stats.time_in_millis', - name: ( - - - - - - - } - /> - - - ), - width: '100px', - truncateText: false, - 'data-test-subj': 'mlProcessorStatsTimePerDoc', - render: (v: number) => { - return durationFormatter(v); - }, - }, + /** + * TODO Display when https://github.com/elastic/elasticsearch/issues/81037 is resolved + */ + ...(true + ? [] + : [ + { + field: 'stats.time_in_millis', + name: ( + + + + + + + } + /> + + + ), + width: '100px', + truncateText: false, + 'data-test-subj': 'mlProcessorStatsTimePerDoc', + render: (v: number) => { + return durationFormatter(v); + }, + }, + ]), { field: 'stats.current', name: ( From 6dc31d768d75870c9c7ba833317b2197480465ce Mon Sep 17 00:00:00 2001 From: Shivindera Singh Date: Thu, 13 Jan 2022 15:30:10 +0100 Subject: [PATCH 25/29] add KibanaThemeProvider support for kibana-app-services (#122370) add KibanaThemeProvider support for kibana-app-services --- .../embeddable/dashboard_container.test.tsx | 4 +- .../dashboard_listing.test.tsx.snap | 56 ++++++++++++ .../application/listing/dashboard_listing.tsx | 1 + .../application/top_nav/dashboard_top_nav.tsx | 2 + .../public/actions/apply_filter_action.ts | 7 +- src/plugins/data/public/plugin.ts | 4 +- .../search/fetch/handle_response.test.ts | 9 +- .../public/search/fetch/handle_response.tsx | 17 +++- .../search_interceptor.test.ts | 3 +- .../search_interceptor/search_interceptor.ts | 13 +-- .../data/public/search/search_service.ts | 10 ++- src/plugins/data/public/services.ts | 4 +- .../query_string_input/query_bar_top_row.tsx | 1 - .../query_string_input/query_string_input.tsx | 4 +- .../shard_failure_open_modal_button.test.tsx | 4 + .../shard_failure_open_modal_button.tsx | 6 +- .../data_view_editor/public/open_editor.tsx | 3 +- .../public/open_delete_modal.tsx | 3 +- .../public/open_editor.tsx | 3 +- .../components/table/table.test.tsx | 5 +- .../components/table/table.tsx | 16 +++- .../indexed_fields_table.tsx | 4 +- .../edit_index_pattern/tabs/tabs.tsx | 4 +- .../mount_management_section.tsx | 44 +++++----- .../data_views/redirect_no_index_pattern.tsx | 7 +- src/plugins/data_views/public/plugin.ts | 5 +- .../application/main/discover_main_route.tsx | 2 + .../lib/embeddables/error_embeddable.tsx | 23 +++-- .../lib/panel/embeddable_panel.test.tsx | 12 ++- .../public/lib/panel/embeddable_panel.tsx | 14 +-- .../add_panel/add_panel_action.test.tsx | 9 +- .../add_panel/add_panel_action.ts | 4 +- .../add_panel/open_add_panel_flyout.tsx | 7 +- src/plugins/embeddable/public/mocks.tsx | 5 +- src/plugins/embeddable/public/plugin.tsx | 3 + src/plugins/embeddable/public/services.ts | 12 +++ src/plugins/inspector/public/plugin.tsx | 3 +- .../notifications/create_notifications.tsx | 4 +- .../public/overlays/create_react_overlays.tsx | 10 ++- .../table_list_view/table_list_view.test.tsx | 2 + .../table_list_view/table_list_view.tsx | 6 +- .../public/theme/kibana_theme_provider.tsx | 5 +- .../kibana_react/public/theme/utils.ts | 5 +- .../public/history/redirect_when_missing.tsx | 11 ++- .../kibana_utils/public/theme/index.ts | 9 ++ .../theme/kibana_theme_provider.test.tsx | 88 +++++++++++++++++++ .../public/theme/kibana_theme_provider.tsx | 33 +++++++ .../kibana_utils/public/theme/utils.test.ts | 19 ++++ .../kibana_utils/public/theme/utils.ts | 19 ++++ src/plugins/kibana_utils/tsconfig.json | 4 +- src/plugins/share/kibana.json | 2 +- .../public/services/share_menu_manager.tsx | 56 ++++++------ .../url_service/redirect/components/page.tsx | 37 ++++---- .../url_service/redirect/redirect_manager.ts | 2 +- src/plugins/share/tsconfig.json | 3 +- .../public/context_menu/open_context_menu.tsx | 32 ++++--- src/plugins/ui_actions/public/plugin.ts | 2 + src/plugins/ui_actions/public/services.ts | 12 +++ .../components/visualize_listing.tsx | 2 + .../public/visualize_app/types.ts | 2 + .../public/visualize_app/utils/utils.ts | 1 + x-pack/examples/reporting_example/kibana.json | 9 +- .../reporting_example/public/application.tsx | 15 ++-- .../examples/reporting_example/tsconfig.json | 6 +- x-pack/plugins/data_enhanced/public/plugin.ts | 3 +- .../components/actions/delete_button.tsx | 3 +- .../components/actions/extend_button.tsx | 3 +- .../components/actions/inspect_button.tsx | 2 +- .../components/actions/rename_button.tsx | 3 +- .../graph/public/apps/listing_route.tsx | 1 + .../embeddable/embeddable_component.tsx | 7 +- x-pack/plugins/maps/public/kibana_services.ts | 1 + .../routes/list_page/maps_list_view.tsx | 2 + .../public/lib/stream_handler.test.ts | 20 +++-- .../reporting/public/lib/stream_handler.ts | 26 ++++-- .../public/notifier/general_error.tsx | 11 ++- .../reporting/public/notifier/job_failure.tsx | 11 ++- .../reporting/public/notifier/job_success.tsx | 11 ++- .../public/notifier/job_warning_formulas.tsx | 11 ++- .../public/notifier/job_warning_max_size.tsx | 11 ++- x-pack/plugins/reporting/public/plugin.ts | 16 +++- .../public/share_context_menu/index.ts | 3 +- .../register_csv_reporting.tsx | 2 + .../register_pdf_png_reporting.tsx | 3 + .../reporting_panel_content.test.tsx | 4 + .../reporting_panel_content.tsx | 6 +- .../screen_capture_panel_content.test.tsx | 10 ++- .../public/shared/get_shared_components.tsx | 3 + x-pack/plugins/reporting/tsconfig.json | 12 +-- x-pack/plugins/runtime_fields/README.md | 25 +++--- .../runtime_fields/public/load_editor.tsx | 5 +- .../runtime_fields/public/plugin.test.ts | 3 +- 92 files changed, 716 insertions(+), 231 deletions(-) create mode 100644 src/plugins/embeddable/public/services.ts create mode 100644 src/plugins/kibana_utils/public/theme/index.ts create mode 100644 src/plugins/kibana_utils/public/theme/kibana_theme_provider.test.tsx create mode 100644 src/plugins/kibana_utils/public/theme/kibana_theme_provider.tsx create mode 100644 src/plugins/kibana_utils/public/theme/utils.test.ts create mode 100644 src/plugins/kibana_utils/public/theme/utils.ts create mode 100644 src/plugins/ui_actions/public/services.ts diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index d5eef0c05129d0..5f50cfd842b67a 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -41,6 +41,7 @@ import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; import { getStubPluginServices } from '../../../../presentation_util/public'; const presentationUtil = getStubPluginServices(); +const theme = coreMock.createStart().theme; const options: DashboardContainerServices = { // TODO: clean up use of any @@ -55,7 +56,7 @@ const options: DashboardContainerServices = { uiActions: {} as any, uiSettings: uiSettingsServiceMock.createStartContract(), http: coreMock.createStart().http, - theme: coreMock.createStart().theme, + theme, presentationUtil, }; @@ -251,6 +252,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} + theme={theme} /> diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap index 2f383adb3f5c3b..598254ad2173fa 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -96,6 +96,14 @@ exports[`after fetch When given a title that matches multiple dashboards, filter ] } tableListTitle="Dashboards" + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } toastNotifications={ Object { "add": [MockFunction], @@ -208,6 +216,14 @@ exports[`after fetch initialFilter 1`] = ` ] } tableListTitle="Dashboards" + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } toastNotifications={ Object { "add": [MockFunction], @@ -319,6 +335,14 @@ exports[`after fetch renders all table rows 1`] = ` ] } tableListTitle="Dashboards" + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } toastNotifications={ Object { "add": [MockFunction], @@ -430,6 +454,14 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` ] } tableListTitle="Dashboards" + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } toastNotifications={ Object { "add": [MockFunction], @@ -552,6 +584,14 @@ exports[`after fetch renders call to action with continue when no dashboards exi ] } tableListTitle="Dashboards" + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } toastNotifications={ Object { "add": [MockFunction], @@ -663,6 +703,14 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` ] } tableListTitle="Dashboards" + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } toastNotifications={ Object { "add": [MockFunction], @@ -744,6 +792,14 @@ exports[`after fetch showWriteControls 1`] = ` ] } tableListTitle="Dashboards" + theme={ + Object { + "theme$": Observable { + "_isScalar": false, + "_subscribe": [Function], + }, + } + } toastNotifications={ Object { "add": [MockFunction], diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index deb8671edb97d9..5b53fc47e06a41 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -297,6 +297,7 @@ export const DashboardListing = ({ listingLimit, tableColumns, }} + theme={core.theme} > { return { @@ -22,6 +23,8 @@ jest.mock('@kbn/i18n', () => { }; }); +const theme = themeServiceMock.createStartContract(); + describe('handleResponse', () => { const notifications = notificationServiceMock.createStartContract(); @@ -37,7 +40,7 @@ describe('handleResponse', () => { timed_out: true, }, } as IKibanaSearchResponse; - const result = handleResponse(request, response); + const result = handleResponse(request, response, theme); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); expect((notifications.toasts.addWarning as jest.Mock).mock.calls[0][0].title).toMatch( @@ -57,7 +60,7 @@ describe('handleResponse', () => { }, }, } as IKibanaSearchResponse; - const result = handleResponse(request, response); + const result = handleResponse(request, response, theme); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); expect((notifications.toasts.addWarning as jest.Mock).mock.calls[0][0].title).toMatch( @@ -70,7 +73,7 @@ describe('handleResponse', () => { const response = { rawResponse: {}, } as IKibanaSearchResponse; - const result = handleResponse(request, response); + const result = handleResponse(request, response, theme); expect(result).toBe(response); }); }); diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 10b2f69a2a3202..618efcb702ec45 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -11,11 +11,16 @@ import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; import { IKibanaSearchResponse } from 'src/plugins/data/common'; import { ShardFailureOpenModalButton } from '../../ui/shard_failure_modal'; +import { ThemeServiceStart } from '../../../../../core/public'; import { toMountPoint } from '../../../../kibana_react/public'; import { getNotifications } from '../../services'; import type { SearchRequest } from '..'; -export function handleResponse(request: SearchRequest, response: IKibanaSearchResponse) { +export function handleResponse( + request: SearchRequest, + response: IKibanaSearchResponse, + theme: ThemeServiceStart +) { const { rawResponse } = response; if (rawResponse.timed_out) { @@ -45,8 +50,14 @@ export function handleResponse(request: SearchRequest, response: IKibanaSearchRe <> {description} - - + + , + { theme$: theme.theme$ } ); getNotifications().toasts.addWarning({ title, text }); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index 142fa94c961622..968dd870489fe0 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -8,7 +8,7 @@ import type { MockedKeys } from '@kbn/utility-types/jest'; import { CoreSetup, CoreStart } from '../../../../../core/public'; -import { coreMock } from '../../../../../core/public/mocks'; +import { coreMock, themeServiceMock } from '../../../../../core/public/mocks'; import { IEsSearchRequest } from '../../../common/search'; import { SearchInterceptor } from './search_interceptor'; import { AbortError } from '../../../../kibana_utils/public'; @@ -120,6 +120,7 @@ describe('SearchInterceptor', () => { uiSettings: mockCoreSetup.uiSettings, http: mockCoreSetup.http, session: sessionService, + theme: themeServiceMock.createSetupContract(), }); }); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts index 9e968c9bae8a08..8c7bfe68fd54b7 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.ts @@ -21,7 +21,7 @@ import { tap, } from 'rxjs/operators'; import { PublicMethodsOf } from '@kbn/utility-types'; -import { CoreSetup, CoreStart, ToastsSetup } from 'kibana/public'; +import { CoreSetup, CoreStart, ThemeServiceSetup, ToastsSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; import { @@ -60,6 +60,7 @@ export interface SearchInterceptorDeps { toasts: ToastsSetup; usageCollector?: SearchUsageCollector; session: ISessionService; + theme: ThemeServiceSetup; } const MAX_CACHE_ITEMS = 50; @@ -377,7 +378,7 @@ export class SearchInterceptor { private showTimeoutErrorToast = (e: SearchTimeoutError, sessionId?: string) => { this.deps.toasts.addDanger({ title: 'Timed out', - text: toMountPoint(e.getErrorMessage(this.application)), + text: toMountPoint(e.getErrorMessage(this.application), { theme$: this.deps.theme.theme$ }), }); }; @@ -392,7 +393,9 @@ export class SearchInterceptor { this.deps.toasts.addWarning( { title: 'Your search session is still running', - text: toMountPoint(SearchSessionIncompleteWarning(this.docLinks)), + text: toMountPoint(SearchSessionIncompleteWarning(this.docLinks), { + theme$: this.deps.theme.theme$, + }), }, { toastLifeTimeMs: 60000, @@ -423,14 +426,14 @@ export class SearchInterceptor { title: i18n.translate('data.search.esErrorTitle', { defaultMessage: 'Cannot retrieve search results', }), - text: toMountPoint(e.getErrorMessage(this.application)), + text: toMountPoint(e.getErrorMessage(this.application), { theme$: this.deps.theme.theme$ }), }); } else if (e.constructor.name === 'HttpFetchError') { this.deps.toasts.addDanger({ title: i18n.translate('data.search.httpErrorTitle', { defaultMessage: 'Cannot retrieve your data', }), - text: toMountPoint(getHttpError(e.message)), + text: toMountPoint(getHttpError(e.message), { theme$: this.deps.theme.theme$ }), }); } else { this.deps.toasts.addError(e, { diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 76aae8582287dd..311a863a749330 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -46,7 +46,7 @@ import { esRawResponse, } from '../../common/search'; import { AggsService, AggsStartDependencies } from './aggs'; -import { IndexPatternsContract } from '..'; +import { IKibanaSearchResponse, IndexPatternsContract, SearchRequest } from '..'; import { ISearchInterceptor, SearchInterceptor } from './search_interceptor'; import { SearchUsageCollector, createUsageCollector } from './collectors'; import { UsageCollectionSetup } from '../../../usage_collection/public'; @@ -88,7 +88,7 @@ export class SearchService implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} public setup( - { http, getStartServices, notifications, uiSettings }: CoreSetup, + { http, getStartServices, notifications, uiSettings, theme }: CoreSetup, { bfetch, expressions, usageCollection, nowProvider }: SearchServiceSetupDependencies ): ISearchSetup { this.usageCollector = createUsageCollector(getStartServices, usageCollection); @@ -112,6 +112,7 @@ export class SearchService implements Plugin { startServices: getStartServices(), usageCollector: this.usageCollector!, session: this.sessionService, + theme, }); expressions.registerFunction( @@ -173,7 +174,7 @@ export class SearchService implements Plugin { } public start( - { http, uiSettings }: CoreStart, + { http, theme, uiSettings }: CoreStart, { fieldFormats, indexPatterns }: SearchServiceStartDependencies ): ISearchStart { const search = ((request, options = {}) => { @@ -186,7 +187,8 @@ export class SearchService implements Plugin { const searchSourceDependencies: SearchSourceDependencies = { getConfig: uiSettings.get.bind(uiSettings), search, - onResponse: handleResponse, + onResponse: (request: SearchRequest, response: IKibanaSearchResponse) => + handleResponse(request, response, theme), }; return { diff --git a/src/plugins/data/public/services.ts b/src/plugins/data/public/services.ts index c1a0ae1ac1b536..5c52a1e695359c 100644 --- a/src/plugins/data/public/services.ts +++ b/src/plugins/data/public/services.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { NotificationsStart, CoreStart } from 'src/core/public'; +import { NotificationsStart, CoreStart, ThemeServiceStart } from 'src/core/public'; import { createGetterSetter } from '../../kibana_utils/public'; import { IndexPatternsContract } from './data_views'; import { DataPublicPluginStart } from './types'; @@ -24,3 +24,5 @@ export const [getIndexPatterns, setIndexPatterns] = export const [getSearchService, setSearchService] = createGetterSetter('Search'); + +export const [getTheme, setTheme] = createGetterSetter('Theme'); diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index bb5e61bdb19467..e5da2bb9f089db 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -24,7 +24,6 @@ import { EuiSuperUpdateButton, OnRefreshProps, } from '@elastic/eui'; - import { IDataPluginServices, IIndexPattern, TimeRange, TimeHistoryContract, Query } from '../..'; import { useKibana, withKibana } from '../../../../kibana_react/public'; import QueryStringInputUI from './query_string_input'; diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index a0b214d1be8c7f..6464f02dd7cb72 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -40,6 +40,7 @@ import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { SuggestionsComponent } from '..'; import { getFieldSubtypeNested, KIBANA_USER_QUERY_LANGUAGE_KEY } from '../../../common'; import { onRaf } from '../utils'; +import { getTheme } from '../../services'; export interface QueryStringInputProps { indexPatterns: Array; @@ -487,7 +488,8 @@ export default class QueryStringInputUI extends PureComponent { -
+ , + { theme$: getTheme().theme$ } ), }); } diff --git a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx index 28822cbd71ca77..b8289bc23cf016 100644 --- a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx +++ b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx @@ -8,18 +8,22 @@ import { openModal } from './shard_failure_open_modal_button.test.mocks'; import React from 'react'; +import { themeServiceMock } from 'src/core/public/mocks'; import { mountWithIntl } from '@kbn/test/jest'; import ShardFailureOpenModalButton from './shard_failure_open_modal_button'; import { shardFailureRequest } from './__mocks__/shard_failure_request'; import { shardFailureResponse } from './__mocks__/shard_failure_response'; import { findTestSubject } from '@elastic/eui/lib/test'; +const theme = themeServiceMock.createStartContract(); + describe('ShardFailureOpenModalButton', () => { it('triggers the openModal function when "Show details" button is clicked', () => { const component = mountWithIntl( ); diff --git a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx index 32ebd83aa47f09..585268824fb93e 100644 --- a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx +++ b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.tsx @@ -12,6 +12,7 @@ import { EuiButton, EuiTextAlign } from '@elastic/eui'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { getOverlays } from '../../services'; +import { ThemeServiceStart } from '../../../../../core/public'; import { toMountPoint } from '../../../../kibana_react/public'; import { ShardFailureModal } from './shard_failure_modal'; import { ShardFailureRequest } from './shard_failure_types'; @@ -20,6 +21,7 @@ import { ShardFailureRequest } from './shard_failure_types'; export interface ShardFailureOpenModalButtonProps { request: ShardFailureRequest; response: estypes.SearchResponse; + theme: ThemeServiceStart; title: string; } @@ -28,6 +30,7 @@ export interface ShardFailureOpenModalButtonProps { export default function ShardFailureOpenModalButton({ request, response, + theme, title, }: ShardFailureOpenModalButtonProps) { function onClick() { @@ -38,7 +41,8 @@ export default function ShardFailureOpenModalButton({ response={response} title={title} onClose={() => modal.close()} - /> + />, + { theme$: theme.theme$ } ), { className: 'shardFailureModal', diff --git a/src/plugins/data_view_editor/public/open_editor.tsx b/src/plugins/data_view_editor/public/open_editor.tsx index 98843d6d1698ac..fcf0fad5a32b08 100644 --- a/src/plugins/data_view_editor/public/open_editor.tsx +++ b/src/plugins/data_view_editor/public/open_editor.tsx @@ -79,7 +79,8 @@ export const getEditorOpener = requireTimestampField={requireTimestampField} /> - + , + { theme$: core.theme.theme$ } ), { hideCloseButton: true, diff --git a/src/plugins/data_view_field_editor/public/open_delete_modal.tsx b/src/plugins/data_view_field_editor/public/open_delete_modal.tsx index 84e3885ddb605a..f44367d16d08d1 100644 --- a/src/plugins/data_view_field_editor/public/open_delete_modal.tsx +++ b/src/plugins/data_view_field_editor/public/open_delete_modal.tsx @@ -75,7 +75,8 @@ export const getFieldDeleteModalOpener = fieldsToDelete={fieldsToDelete} closeModal={closeModal} confirmDelete={onConfirmDelete} - /> + />, + { theme$: core.theme.theme$ } ) ); diff --git a/src/plugins/data_view_field_editor/public/open_editor.tsx b/src/plugins/data_view_field_editor/public/open_editor.tsx index 277d7f5c549ae9..c66e8183b9ab64 100644 --- a/src/plugins/data_view_field_editor/public/open_editor.tsx +++ b/src/plugins/data_view_field_editor/public/open_editor.tsx @@ -128,7 +128,8 @@ export const getFieldEditorOpener = fieldFormats={fieldFormats} uiSettings={uiSettings} /> - + , + { theme$: core.theme.theme$ } ), { className: euiFlyoutClassname, diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx index dd78b00f9775e7..f85f7bb2548263 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.test.tsx @@ -11,7 +11,9 @@ import { shallow } from 'enzyme'; import { IndexPattern } from 'src/plugins/data/public'; import { IndexedFieldItem } from '../../types'; import { Table, renderFieldName, getConflictModalContent } from './table'; -import { overlayServiceMock } from 'src/core/public/mocks'; +import { overlayServiceMock, themeServiceMock } from 'src/core/public/mocks'; + +const theme = themeServiceMock.createStartContract(); const indexPattern = { timeFieldName: 'timestamp', @@ -89,6 +91,7 @@ const renderTable = ( editField={editField} deleteField={() => {}} openModal={overlayServiceMock.createStartContract().openModal} + theme={theme} /> ); diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx index 6a82d0380629c0..7e915e3c930a57 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/components/table/table.tsx @@ -7,7 +7,7 @@ */ import React, { PureComponent } from 'react'; -import { OverlayModalStart } from 'src/core/public'; +import { OverlayModalStart, ThemeServiceStart } from 'src/core/public'; import { EuiIcon, @@ -179,6 +179,7 @@ interface IndexedFieldProps { editField: (field: IndexedFieldItem) => void; deleteField: (fieldName: string) => void; openModal: OverlayModalStart['open']; + theme: ThemeServiceStart; } const getItems = (conflictDescriptions: IndexedFieldItem['conflictDescriptions']) => { @@ -311,7 +312,8 @@ export const getConflictModalContent = ({ const getConflictBtn = ( fieldName: string, conflictDescriptions: IndexedFieldItem['conflictDescriptions'], - openModal: IndexedFieldProps['openModal'] + openModal: IndexedFieldProps['openModal'], + theme: ThemeServiceStart ) => { const onClick = () => { const overlayRef = openModal( @@ -322,7 +324,8 @@ const getConflictBtn = ( }, fieldName, conflictDescriptions, - }) + }), + { theme$: theme.theme$ } ) ); }; @@ -355,7 +358,12 @@ export class Table extends PureComponent { {type === 'conflict' && conflictDescription ? '' : type} {field.conflictDescriptions - ? getConflictBtn(field.name, field.conflictDescriptions, this.props.openModal) + ? getConflictBtn( + field.name, + field.conflictDescriptions, + this.props.openModal, + this.props.theme + ) : ''} ); diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx index a72c87655fd63b..29b8d82a997045 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx @@ -8,7 +8,7 @@ import React, { Component } from 'react'; import { createSelector } from 'reselect'; -import { OverlayStart } from 'src/core/public'; +import { OverlayStart, ThemeServiceStart } from 'src/core/public'; import { IndexPatternField, IndexPattern } from '../../../../../../plugins/data/public'; import { useKibana } from '../../../../../../plugins/kibana_react/public'; import { Table } from './components/table'; @@ -28,6 +28,7 @@ interface IndexedFieldsTableProps { fieldWildcardMatcher: (filters: any[]) => (val: any) => boolean; userEditPermission: boolean; openModal: OverlayStart['openModal']; + theme: ThemeServiceStart; } interface IndexedFieldsTableState { @@ -129,6 +130,7 @@ class IndexedFields extends Component this.props.helpers.editField(field.name)} deleteField={(fieldName) => this.props.helpers.deleteField(fieldName)} openModal={this.props.openModal} + theme={this.props.theme} /> ); diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx index b5940fa8d1bb0d..58b064fa79893d 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx @@ -80,7 +80,7 @@ export function Tabs({ location, refreshFields, }: TabsProps) { - const { application, uiSettings, docLinks, dataViewFieldEditor, overlays } = + const { application, uiSettings, docLinks, dataViewFieldEditor, overlays, theme } = useKibana().services; const [fieldFilter, setFieldFilter] = useState(''); const [indexedFieldTypeFilter, setIndexedFieldTypeFilter] = useState(''); @@ -236,6 +236,7 @@ export function Tabs({ getFieldInfo, }} openModal={overlays.openModal} + theme={theme} /> )} @@ -295,6 +296,7 @@ export function Tabs({ DeleteRuntimeFieldProvider, refreshFields, overlays, + theme, ] ); diff --git a/src/plugins/data_view_management/public/management_app/mount_management_section.tsx b/src/plugins/data_view_management/public/management_app/mount_management_section.tsx index 6e0e7ffc9091d6..4bc0a204f68a1c 100644 --- a/src/plugins/data_view_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_view_management/public/management_app/mount_management_section.tsx @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n-react'; import { StartServicesAccessor } from 'src/core/public'; -import { KibanaContextProvider } from '../../../kibana_react/public'; +import { KibanaContextProvider, KibanaThemeProvider } from '../../../kibana_react/public'; import { ManagementAppMountParams } from '../../../management/public'; import { IndexPatternTableWithRouter, @@ -39,7 +39,7 @@ export async function mountManagementSection( params: ManagementAppMountParams ) { const [ - { chrome, application, uiSettings, notifications, overlays, http, docLinks }, + { chrome, application, uiSettings, notifications, overlays, http, docLinks, theme }, { data, dataViewFieldEditor, dataViewEditor }, indexPatternManagementStart, ] = await getStartServices(); @@ -67,25 +67,27 @@ export async function mountManagementSection( ReactDOM.render( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/data_views/public/data_views/redirect_no_index_pattern.tsx b/src/plugins/data_views/public/data_views/redirect_no_index_pattern.tsx index 086cd92a92d82d..fc7b8c9eb42b67 100644 --- a/src/plugins/data_views/public/data_views/redirect_no_index_pattern.tsx +++ b/src/plugins/data_views/public/data_views/redirect_no_index_pattern.tsx @@ -18,7 +18,8 @@ export const onRedirectNoIndexPattern = ( capabilities: CoreStart['application']['capabilities'], navigateToApp: CoreStart['application']['navigateToApp'], - overlays: CoreStart['overlays'] + overlays: CoreStart['overlays'], + theme: CoreStart['theme'] ) => () => { const canManageIndexPatterns = capabilities.management.kibana.indexPatterns; @@ -38,7 +39,9 @@ export const onRedirectNoIndexPattern = // give them a friendly info message instead of a terse error message bannerId = overlays.banners.replace( bannerId, - toMountPoint() + toMountPoint(, { + theme$: theme.theme$, + }) ); // hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around diff --git a/src/plugins/data_views/public/plugin.ts b/src/plugins/data_views/public/plugin.ts index 4a00ea91a47bd1..bf092d3fae1771 100644 --- a/src/plugins/data_views/public/plugin.ts +++ b/src/plugins/data_views/public/plugin.ts @@ -45,7 +45,7 @@ export class DataViewsPublicPlugin core: CoreStart, { fieldFormats }: DataViewsPublicStartDependencies ): DataViewsPublicPluginStart { - const { uiSettings, http, notifications, savedObjects, overlays, application } = core; + const { uiSettings, http, notifications, savedObjects, theme, overlays, application } = core; return new DataViewsService({ uiSettings: new UiSettingsPublicToCommon(uiSettings), @@ -59,7 +59,8 @@ export class DataViewsPublicPlugin onRedirectNoIndexPattern: onRedirectNoIndexPattern( application.capabilities, application.navigateToApp, - overlays + overlays, + theme ), getCanSave: () => Promise.resolve(application.capabilities.indexPatterns.save === true), }); diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index b2576a3b5d582e..dd1d036b811a20 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -122,6 +122,7 @@ export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { onBeforeRedirect() { getUrlTracker().setTrackedUrl('/'); }, + theme: core.theme, })(e); } } @@ -139,6 +140,7 @@ export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { id, services, toastNotifications, + core.theme, ]); useEffect(() => { diff --git a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx index f4c650507add9f..70c30d314fc824 100644 --- a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.tsx @@ -9,10 +9,11 @@ import { EuiText, EuiIcon, EuiSpacer } from '@elastic/eui'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Markdown } from '../../../../kibana_react/public'; +import { KibanaThemeProvider, Markdown } from '../../../../kibana_react/public'; import { Embeddable } from './embeddable'; import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { IContainer } from '../containers'; +import { getTheme } from '../../services'; export const ERROR_EMBEDDABLE_TYPE = 'error'; @@ -37,8 +38,13 @@ export class ErrorEmbeddable extends Embeddable @@ -49,9 +55,16 @@ export class ErrorEmbeddable extends Embeddable - , - dom + ); + const content = + theme && theme.theme$ ? ( + {node} + ) : ( + node + ); + + ReactDOM.render(content, dom); } public destroy() { diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 78bd337b21e526..8d313030556c62 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -31,7 +31,7 @@ import { import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; import { embeddablePluginMock } from '../../mocks'; -import { applicationServiceMock } from '../../../../../core/public/mocks'; +import { applicationServiceMock, themeServiceMock } from '../../../../../core/public/mocks'; const actionRegistry = new Map(); const triggerRegistry = new Map(); @@ -44,6 +44,7 @@ const trigger: Trigger = { }; const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any); const applicationMock = applicationServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); actionRegistry.set(editModeAction.id, editModeAction); triggerRegistry.set(trigger.id, trigger); @@ -152,6 +153,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => { overlays={{} as any} inspector={inspector} SavedObjectFinder={() => null} + theme={theme} /> ); @@ -191,6 +193,7 @@ const renderInEditModeAndOpenContextMenu = async ( application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} + theme={theme} /> ); @@ -298,6 +301,7 @@ test('HelloWorldContainer in edit mode shows edit mode actions', async () => { application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} + theme={theme} /> ); @@ -360,6 +364,7 @@ test('Panel title customize link does not exist in view mode', async () => { application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} + theme={theme} /> ); @@ -395,6 +400,7 @@ test('Runs customize panel action on title click when in edit mode', async () => application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} + theme={theme} /> ); @@ -443,6 +449,7 @@ test('Updates when hidePanelTitles is toggled', async () => { application={applicationMock} inspector={inspector} SavedObjectFinder={() => null} + theme={theme} /> ); @@ -497,6 +504,7 @@ test('Check when hide header option is false', async () => { inspector={inspector} SavedObjectFinder={() => null} hideHeader={false} + theme={theme} /> ); @@ -535,6 +543,7 @@ test('Check when hide header option is true', async () => { inspector={inspector} SavedObjectFinder={() => null} hideHeader={true} + theme={theme} /> ); @@ -567,6 +576,7 @@ test('Should work in minimal way rendering only the inspector action', async () getActions={() => Promise.resolve([])} inspector={inspector} hideHeader={false} + theme={theme} /> ); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 6748e9f3b1d083..2e501984dfa763 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { Subscription } from 'rxjs'; import deepEqual from 'fast-deep-equal'; import { buildContextMenuForActions, UiActionsService, Action } from '../ui_actions'; -import { CoreStart, OverlayStart } from '../../../../../core/public'; +import { CoreStart, OverlayStart, ThemeServiceStart } from '../../../../../core/public'; import { toMountPoint } from '../../../../kibana_react/public'; import { UsageCollectionStart } from '../../../../usage_collection/public'; @@ -83,6 +83,7 @@ interface Props { showBadges?: boolean; showNotifications?: boolean; containerContext?: EmbeddableContainerContext; + theme: ThemeServiceStart; } interface State { @@ -347,8 +348,7 @@ export class EmbeddablePanel extends React.Component { ) { return actions; } - - const createGetUserData = (overlays: OverlayStart) => + const createGetUserData = (overlays: OverlayStart, theme: ThemeServiceStart) => async function getUserData(context: { embeddable: IEmbeddable }) { return new Promise<{ title: string | undefined; hideTitle?: boolean }>((resolve) => { const session = overlays.openModal( @@ -360,7 +360,8 @@ export class EmbeddablePanel extends React.Component { resolve({ title, hideTitle }); }} cancel={() => session.close()} - /> + />, + { theme$: theme.theme$ } ), { 'data-test-subj': 'customizePanel', @@ -373,13 +374,16 @@ export class EmbeddablePanel extends React.Component { // registry. return { ...actions, - customizePanelTitle: new CustomizePanelTitleAction(createGetUserData(this.props.overlays)), + customizePanelTitle: new CustomizePanelTitleAction( + createGetUserData(this.props.overlays, this.props.theme) + ), addPanel: new AddPanelAction( this.props.getEmbeddableFactory, this.props.getAllEmbeddableFactories, this.props.overlays, this.props.notifications, this.props.SavedObjectFinder, + this.props.theme, this.props.reportUiCounter ), removePanel: new RemovePanelAction(), diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx index 224cb80478769d..fe6a9ea3c22b3a 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx @@ -16,7 +16,7 @@ import { } from '../../../../test_samples/embeddables/filterable_embeddable'; import { FilterableEmbeddableFactory } from '../../../../test_samples/embeddables/filterable_embeddable_factory'; import { FilterableContainer } from '../../../../test_samples/embeddables/filterable_container'; -import { coreMock } from '../../../../../../../../core/public/mocks'; +import { coreMock, themeServiceMock } from '../../../../../../../../core/public/mocks'; import { ContactCardEmbeddable } from '../../../../test_samples'; import { EmbeddableStart } from '../../../../../plugin'; import { embeddablePluginMock } from '../../../../../mocks'; @@ -25,6 +25,7 @@ import { defaultTrigger } from '../../../../../../../ui_actions/public/triggers' const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory(FILTERABLE_EMBEDDABLE, new FilterableEmbeddableFactory()); const getFactory = doStart().getEmbeddableFactory; +const theme = themeServiceMock.createStartContract(); let container: FilterableContainer; let embeddable: FilterableEmbeddable; @@ -37,7 +38,8 @@ beforeEach(async () => { () => [] as any, start.overlays, start.notifications, - () => null + () => null, + theme ); const derivedFilter: MockFilter = { @@ -72,7 +74,8 @@ test('Is not compatible when container is in view mode', async () => { () => [] as any, start.overlays, start.notifications, - () => null + () => null, + theme ); container.updateInput({ viewMode: ViewMode.VIEW }); expect( diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts index 49be1c3ce01233..d766c509782a01 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { Action, ActionExecutionContext } from 'src/plugins/ui_actions/public'; -import { NotificationsStart, OverlayStart } from 'src/core/public'; +import { NotificationsStart, OverlayStart, ThemeServiceStart } from 'src/core/public'; import { EmbeddableStart } from 'src/plugins/embeddable/public/plugin'; import { ViewMode } from '../../../../types'; import { openAddPanelFlyout } from './open_add_panel_flyout'; @@ -31,6 +31,7 @@ export class AddPanelAction implements Action { private readonly overlays: OverlayStart, private readonly notifications: NotificationsStart, private readonly SavedObjectFinder: React.ComponentType, + private readonly theme: ThemeServiceStart, private readonly reportUiCounter?: UsageCollectionStart['reportUiCounter'] ) {} @@ -63,6 +64,7 @@ export class AddPanelAction implements Action { notifications: this.notifications, SavedObjectFinder: this.SavedObjectFinder, reportUiCounter: this.reportUiCounter, + theme: this.theme, }); } } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx index fe54b3d134aa0b..00c6f99abda092 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { NotificationsStart, OverlayRef, OverlayStart } from 'src/core/public'; +import { NotificationsStart, OverlayRef, OverlayStart, ThemeServiceStart } from 'src/core/public'; import { EmbeddableStart } from '../../../../../plugin'; import { toMountPoint } from '../../../../../../../kibana_react/public'; import { IContainer } from '../../../../containers'; @@ -23,6 +23,7 @@ export function openAddPanelFlyout(options: { SavedObjectFinder: React.ComponentType; showCreateNewMenu?: boolean; reportUiCounter?: UsageCollectionStart['reportUiCounter']; + theme: ThemeServiceStart; }): OverlayRef { const { embeddable, @@ -33,6 +34,7 @@ export function openAddPanelFlyout(options: { SavedObjectFinder, showCreateNewMenu, reportUiCounter, + theme, } = options; const flyoutSession = overlays.openFlyout( toMountPoint( @@ -49,7 +51,8 @@ export function openAddPanelFlyout(options: { reportUiCounter={reportUiCounter} SavedObjectFinder={SavedObjectFinder} showCreateNewMenu={showCreateNewMenu} - /> + />, + { theme$: theme.theme$ } ), { 'data-test-subj': 'dashboardAddPanel', diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index 94eb5e5cc6a029..44d2b395a48c35 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -20,7 +20,7 @@ import { ReferenceOrValueEmbeddable, } from '.'; import { EmbeddablePublicPlugin } from './plugin'; -import { coreMock } from '../../../core/public/mocks'; +import { coreMock, themeServiceMock } from '../../../core/public/mocks'; import { UiActionsService } from './lib/ui_actions'; import { CoreStart } from '../../../core/public'; import { Start as InspectorStart } from '../../inspector/public'; @@ -43,6 +43,8 @@ interface CreateEmbeddablePanelMockArgs { SavedObjectFinder: React.ComponentType; } +const theme = themeServiceMock.createStartContract(); + export const createEmbeddablePanelMock = ({ getActions, getEmbeddableFactory, @@ -64,6 +66,7 @@ export const createEmbeddablePanelMock = ({ overlays={overlays || ({} as any)} inspector={inspector || ({} as any)} SavedObjectFinder={SavedObjectFinder || (() => null)} + theme={theme} /> ); }; diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 465c5d741d5a91..041207f2f23803 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -52,6 +52,7 @@ import { getTelemetryFunction, } from '../common/lib'; import { getAllMigrations } from '../common/lib/get_all_migrations'; +import { setTheme } from './services'; export interface EmbeddableSetupDependencies { uiActions: UiActionsSetup; @@ -119,6 +120,7 @@ export class EmbeddablePublicPlugin implements Plugin ); diff --git a/src/plugins/embeddable/public/services.ts b/src/plugins/embeddable/public/services.ts new file mode 100644 index 00000000000000..96088e086a7718 --- /dev/null +++ b/src/plugins/embeddable/public/services.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ThemeServiceSetup } from 'src/core/public'; +import { createGetterSetter } from '../../kibana_utils/public'; + +export const [getTheme, setTheme] = createGetterSetter('Theme'); diff --git a/src/plugins/inspector/public/plugin.tsx b/src/plugins/inspector/public/plugin.tsx index e561a9719b3fbc..14a141f7c2ec17 100644 --- a/src/plugins/inspector/public/plugin.tsx +++ b/src/plugins/inspector/public/plugin.tsx @@ -106,7 +106,8 @@ export class InspectorPublicPlugin implements Plugin { uiSettings: core.uiSettings, share: startDeps.share, }} - /> + />, + { theme$: core.theme.theme$ } ), { 'data-test-subj': 'inspectorPanel', diff --git a/src/plugins/kibana_react/public/notifications/create_notifications.tsx b/src/plugins/kibana_react/public/notifications/create_notifications.tsx index 2e59e611fc4217..8eb16a5580ab37 100644 --- a/src/plugins/kibana_react/public/notifications/create_notifications.tsx +++ b/src/plugins/kibana_react/public/notifications/create_notifications.tsx @@ -24,8 +24,8 @@ export const createNotifications = (services: KibanaServices): KibanaReactNotifi throw new TypeError('Could not show notification as notifications service is not available.'); } services.notifications!.toasts.add({ - title: toMountPoint(title), - text: toMountPoint(<>{body || null}), + title: toMountPoint(title, { theme$: services.theme?.theme$ }), + text: toMountPoint(<>{body || null}, { theme$: services.theme?.theme$ }), color, iconType, toastLifeTimeMs, diff --git a/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx b/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx index 3274699e4bd69e..4349e39d04fd57 100644 --- a/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx +++ b/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx @@ -20,12 +20,18 @@ export const createReactOverlays = (services: KibanaServices): KibanaReactOverla const openFlyout: KibanaReactOverlays['openFlyout'] = (node, options?) => { checkCoreService(); - return services.overlays!.openFlyout(toMountPoint(<>{node}), options); + return services.overlays!.openFlyout( + toMountPoint(<>{node}, { theme$: services.theme?.theme$ }), + options + ); }; const openModal: KibanaReactOverlays['openModal'] = (node, options?) => { checkCoreService(); - return services.overlays!.openModal(toMountPoint(<>{node}), options); + return services.overlays!.openModal( + toMountPoint(<>{node}, { theme$: services.theme?.theme$ }), + options + ); }; const overlays: KibanaReactOverlays = { diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx index 3663f156c69cb8..bdc5ca30216bc4 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx @@ -10,6 +10,7 @@ import { EuiEmptyPrompt } from '@elastic/eui'; import { shallowWithIntl } from '@kbn/test/jest'; import { ToastsStart } from 'kibana/public'; import React from 'react'; +import { themeServiceMock } from '../../../../../src/core/public/mocks'; import { TableListView } from './table_list_view'; const requiredProps = { @@ -24,6 +25,7 @@ const requiredProps = { tableCaption: 'test caption', toastNotifications: {} as ToastsStart, findItems: jest.fn(() => Promise.resolve({ total: 0, hits: [] })), + theme: themeServiceMock.createStartContract(), }; describe('TableListView', () => { diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index 65c62543538d0f..dd023d522dbb6f 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { HttpFetchError, ToastsStart } from 'kibana/public'; +import { ThemeServiceStart, HttpFetchError, ToastsStart } from 'kibana/public'; import { debounce, keyBy, sortBy, uniq } from 'lodash'; import React from 'react'; import { KibanaPageTemplate } from '../page_template'; @@ -57,6 +57,7 @@ export interface TableListViewProps { */ tableCaption: string; searchFilters?: SearchFilterConfig[]; + theme: ThemeServiceStart; } export interface TableListViewState { @@ -177,7 +178,8 @@ class TableListView extends React.Component< id="kibana-react.tableListView.listing.unableToDeleteDangerMessage" defaultMessage="Unable to delete {entityName}(s)" values={{ entityName: this.props.entityName }} - /> + />, + { theme$: this.props.theme.theme$ } ), text: `${error}`, }); diff --git a/src/plugins/kibana_react/public/theme/kibana_theme_provider.tsx b/src/plugins/kibana_react/public/theme/kibana_theme_provider.tsx index 65d640f34a2cad..56ca7642f1cde7 100644 --- a/src/plugins/kibana_react/public/theme/kibana_theme_provider.tsx +++ b/src/plugins/kibana_react/public/theme/kibana_theme_provider.tsx @@ -21,8 +21,9 @@ const defaultTheme: CoreTheme = { darkMode: false, }; -// IMPORTANT: This code has been copied to the `interactive_setup` plugin, any changes here should be applied there too. -// That copy and this comment can be removed once https://github.com/elastic/kibana/issues/119204 is implemented. +/* IMPORTANT: This code has been copied to the `interactive_setup` plugin, any changes here should be applied there too. +That copy and this comment can be removed once https://github.com/elastic/kibana/issues/119204 is implemented.*/ +// IMPORTANT: This code has been copied to the `kibana_utils` plugin, to avoid cyclical dependency, any changes here should be applied there too. export const KibanaThemeProvider: FC = ({ theme$, children }) => { const theme = useObservable(theme$, defaultTheme); diff --git a/src/plugins/kibana_react/public/theme/utils.ts b/src/plugins/kibana_react/public/theme/utils.ts index 161f3a5e36b768..fe8092949d4315 100644 --- a/src/plugins/kibana_react/public/theme/utils.ts +++ b/src/plugins/kibana_react/public/theme/utils.ts @@ -10,8 +10,9 @@ import { COLOR_MODES_STANDARD } from '@elastic/eui'; import type { EuiThemeColorModeStandard } from '@elastic/eui'; import type { CoreTheme } from '../../../../core/public'; -// IMPORTANT: This code has been copied to the `interactive_setup` plugin, any changes here should be applied there too. -// That copy and this comment can be removed once https://github.com/elastic/kibana/issues/119204 is implemented. +/* IMPORTANT: This code has been copied to the `interactive_setup` plugin, any changes here should be applied there too. +That copy and this comment can be removed once https://github.com/elastic/kibana/issues/119204 is implemented.*/ +// IMPORTANT: This code has been copied to the `kibana_utils` plugin, to avoid cyclical dependency, any changes here should be applied there too. export const getColorMode = (theme: CoreTheme): EuiThemeColorModeStandard => { return theme.darkMode ? COLOR_MODES_STANDARD.dark : COLOR_MODES_STANDARD.light; diff --git a/src/plugins/kibana_utils/public/history/redirect_when_missing.tsx b/src/plugins/kibana_utils/public/history/redirect_when_missing.tsx index c64ac35e6f83f2..6913c94a6bb5f1 100644 --- a/src/plugins/kibana_utils/public/history/redirect_when_missing.tsx +++ b/src/plugins/kibana_utils/public/history/redirect_when_missing.tsx @@ -13,7 +13,9 @@ import { EuiLoadingSpinner } from '@elastic/eui'; import ReactDOM from 'react-dom'; import { ApplicationStart, HttpStart, ToastsSetup } from 'kibana/public'; +import type { ThemeServiceStart } from '../../../../core/public'; import { SavedObjectNotFound } from '..'; +import { KibanaThemeProvider } from '../theme'; const ReactMarkdown = React.lazy(() => import('react-markdown')); const ErrorRenderer = (props: { children: string }) => ( @@ -45,6 +47,7 @@ export function redirectWhenMissing({ mapping, toastNotifications, onBeforeRedirect, + theme, }: { history: History; navigateToApp: ApplicationStart['navigateToApp']; @@ -62,6 +65,7 @@ export function redirectWhenMissing({ * Optional callback invoked directly before a redirect is triggered */ onBeforeRedirect?: (error: SavedObjectNotFound) => void; + theme: ThemeServiceStart; }) { let localMappingObject: Mapping; @@ -92,7 +96,12 @@ export function redirectWhenMissing({ defaultMessage: 'Saved object is missing', }), text: (element: HTMLElement) => { - ReactDOM.render({error.message}, element); + ReactDOM.render( + + {error.message} + , + element + ); return () => ReactDOM.unmountComponentAtNode(element); }, }); diff --git a/src/plugins/kibana_utils/public/theme/index.ts b/src/plugins/kibana_utils/public/theme/index.ts new file mode 100644 index 00000000000000..165c5ef9195c2a --- /dev/null +++ b/src/plugins/kibana_utils/public/theme/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { KibanaThemeProvider } from './kibana_theme_provider'; diff --git a/src/plugins/kibana_utils/public/theme/kibana_theme_provider.test.tsx b/src/plugins/kibana_utils/public/theme/kibana_theme_provider.test.tsx new file mode 100644 index 00000000000000..21059bd4a8236c --- /dev/null +++ b/src/plugins/kibana_utils/public/theme/kibana_theme_provider.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useEuiTheme } from '@elastic/eui'; +import type { ReactWrapper } from 'enzyme'; +import type { FC } from 'react'; +import React, { useEffect } from 'react'; +import { act } from 'react-dom/test-utils'; +import { BehaviorSubject, of } from 'rxjs'; + +import { mountWithIntl } from '@kbn/test/jest'; +import type { CoreTheme } from 'src/core/public'; + +import { KibanaThemeProvider } from './kibana_theme_provider'; + +describe('KibanaThemeProvider', () => { + let euiTheme: ReturnType | undefined; + + beforeEach(() => { + euiTheme = undefined; + }); + + const flushPromises = async () => { + await new Promise(async (resolve, reject) => { + try { + setImmediate(() => resolve()); + } catch (error) { + reject(error); + } + }); + }; + + const InnerComponent: FC = () => { + const theme = useEuiTheme(); + useEffect(() => { + euiTheme = theme; + }, [theme]); + return
foo
; + }; + + const refresh = async (wrapper: ReactWrapper) => { + await act(async () => { + await flushPromises(); + wrapper.update(); + }); + }; + + it('exposes the EUI theme provider', async () => { + const coreTheme: CoreTheme = { darkMode: true }; + + const wrapper = mountWithIntl( + + + + ); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('DARK'); + }); + + it('propagates changes of the coreTheme observable', async () => { + const coreTheme$ = new BehaviorSubject({ darkMode: true }); + + const wrapper = mountWithIntl( + + + + ); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('DARK'); + + await act(async () => { + coreTheme$.next({ darkMode: false }); + }); + + await refresh(wrapper); + + expect(euiTheme!.colorMode).toEqual('LIGHT'); + }); +}); diff --git a/src/plugins/kibana_utils/public/theme/kibana_theme_provider.tsx b/src/plugins/kibana_utils/public/theme/kibana_theme_provider.tsx new file mode 100644 index 00000000000000..7c7963eff984bb --- /dev/null +++ b/src/plugins/kibana_utils/public/theme/kibana_theme_provider.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiThemeProvider } from '@elastic/eui'; +import type { FC } from 'react'; +import React, { useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; + +import type { CoreTheme } from '../../../../core/public'; +import { getColorMode } from './utils'; + +interface KibanaThemeProviderProps { + theme$: Observable; +} + +const defaultTheme: CoreTheme = { + darkMode: false, +}; + +/** + * Copied from the `kibana_react` plugin, to avoid cyclical dependency + */ +export const KibanaThemeProvider: FC = ({ theme$, children }) => { + const theme = useObservable(theme$, defaultTheme); + const colorMode = useMemo(() => getColorMode(theme), [theme]); + return {children}; +}; diff --git a/src/plugins/kibana_utils/public/theme/utils.test.ts b/src/plugins/kibana_utils/public/theme/utils.test.ts new file mode 100644 index 00000000000000..57b37f4fb2f629 --- /dev/null +++ b/src/plugins/kibana_utils/public/theme/utils.test.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getColorMode } from './utils'; + +describe('getColorMode', () => { + it('returns the correct `colorMode` when `darkMode` is enabled', () => { + expect(getColorMode({ darkMode: true })).toEqual('DARK'); + }); + + it('returns the correct `colorMode` when `darkMode` is disabled', () => { + expect(getColorMode({ darkMode: false })).toEqual('LIGHT'); + }); +}); diff --git a/src/plugins/kibana_utils/public/theme/utils.ts b/src/plugins/kibana_utils/public/theme/utils.ts new file mode 100644 index 00000000000000..887e4fe61fbe13 --- /dev/null +++ b/src/plugins/kibana_utils/public/theme/utils.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { COLOR_MODES_STANDARD } from '@elastic/eui'; +import type { EuiThemeColorModeStandard } from '@elastic/eui'; + +import type { CoreTheme } from '../../../../core/public'; + +/** + * Copied from the `kibana_react` plugin, to avoid cyclical dependency + */ +export const getColorMode = (theme: CoreTheme): EuiThemeColorModeStandard => { + return theme.darkMode ? COLOR_MODES_STANDARD.dark : COLOR_MODES_STANDARD.light; +}; diff --git a/src/plugins/kibana_utils/tsconfig.json b/src/plugins/kibana_utils/tsconfig.json index 0538b145a5d62f..0fba68be6aa57b 100644 --- a/src/plugins/kibana_utils/tsconfig.json +++ b/src/plugins/kibana_utils/tsconfig.json @@ -14,7 +14,5 @@ "index.ts", "../../../typings/**/*" ], - "references": [ - { "path": "../../core/tsconfig.json" } - ] + "references": [{ "path": "../../core/tsconfig.json" }] } diff --git a/src/plugins/share/kibana.json b/src/plugins/share/kibana.json index 2e34da1da0287c..08365d895f4b4d 100644 --- a/src/plugins/share/kibana.json +++ b/src/plugins/share/kibana.json @@ -8,6 +8,6 @@ "githubTeam": "kibana-app-services" }, "description": "Adds URL Service and sharing capabilities to Kibana", - "requiredBundles": ["kibanaUtils"], + "requiredBundles": ["kibanaReact", "kibanaUtils"], "optionalPlugins": [] } diff --git a/src/plugins/share/public/services/share_menu_manager.tsx b/src/plugins/share/public/services/share_menu_manager.tsx index 52f000512aa075..237e71009d2052 100644 --- a/src/plugins/share/public/services/share_menu_manager.tsx +++ b/src/plugins/share/public/services/share_menu_manager.tsx @@ -11,7 +11,8 @@ import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n-react'; import { EuiWrappingPopover } from '@elastic/eui'; -import { CoreStart, HttpStart } from 'kibana/public'; +import { CoreStart, HttpStart, ThemeServiceStart } from 'kibana/public'; +import { KibanaThemeProvider } from '../../../kibana_react/public'; import { ShareContextMenu } from '../components/share_context_menu'; import { ShareMenuItem, ShowShareMenuOptions } from '../types'; import { ShareMenuRegistryStart } from './share_menu_registry'; @@ -42,6 +43,7 @@ export class ShareMenuManager { post: core.http.post, basePath: core.http.basePath.get(), anonymousAccess, + theme: core.theme, }); }, }; @@ -65,12 +67,14 @@ export class ShareMenuManager { basePath, embedUrlParamExtensions, anonymousAccess, + theme, showPublicUrlSwitch, }: ShowShareMenuOptions & { menuItems: ShareMenuItem[]; post: HttpStart['post']; basePath: string; anonymousAccess: AnonymousAccessServiceContract | undefined; + theme: ThemeServiceStart; }) { if (this.isOpen) { this.onClose(); @@ -82,30 +86,32 @@ export class ShareMenuManager { document.body.appendChild(this.container); const element = ( - - - + + + + + ); ReactDOM.render(element, this.container); diff --git a/src/plugins/share/public/url_service/redirect/components/page.tsx b/src/plugins/share/public/url_service/redirect/components/page.tsx index 805213b73fdd06..f6aa4d62767c56 100644 --- a/src/plugins/share/public/url_service/redirect/components/page.tsx +++ b/src/plugins/share/public/url_service/redirect/components/page.tsx @@ -9,38 +9,45 @@ import * as React from 'react'; import useObservable from 'react-use/lib/useObservable'; import { EuiPageTemplate } from '@elastic/eui'; +import { ThemeServiceSetup } from 'kibana/public'; import { Error } from './error'; import { RedirectManager } from '../redirect_manager'; import { Spinner } from './spinner'; +import { KibanaThemeProvider } from '../../../../../kibana_react/public'; export interface PageProps { manager: Pick; + theme: ThemeServiceSetup; } -export const Page: React.FC = ({ manager }) => { +export const Page: React.FC = ({ manager, theme }) => { const error = useObservable(manager.error$); if (error) { return ( + + + + + + ); + } + + return ( + - + - ); - } - - return ( - - - + ); }; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts index e6f524347e48cf..9d7357eab310c5 100644 --- a/src/plugins/share/public/url_service/redirect/redirect_manager.ts +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -29,7 +29,7 @@ export class RedirectManager { chromeless: true, mount: async (params) => { const { render } = await import('./render'); - const unmount = render(params.element, { manager: this }); + const unmount = render(params.element, { manager: this, theme: core.theme }); this.onMount(params.history.location.search); return () => { unmount(); diff --git a/src/plugins/share/tsconfig.json b/src/plugins/share/tsconfig.json index 1f9c438f03fc40..2633d840895d66 100644 --- a/src/plugins/share/tsconfig.json +++ b/src/plugins/share/tsconfig.json @@ -9,6 +9,7 @@ "include": ["common/**/*", "public/**/*", "server/**/*"], "references": [ { "path": "../../core/tsconfig.json" }, - { "path": "../kibana_utils/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../kibana_utils/tsconfig.json" } ] } diff --git a/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx b/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx index 91cb8099e8b3cc..04449d7b656bcd 100644 --- a/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx +++ b/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { EuiContextMenu, EuiContextMenuPanelDescriptor, EuiPopover } from '@elastic/eui'; import { EventEmitter } from 'events'; import ReactDOM from 'react-dom'; +import { KibanaThemeProvider } from '../../../kibana_react/public'; +import { getTheme } from '../services'; let activeSession: ContextMenuSession | null = null; @@ -168,20 +170,22 @@ export function openContextMenu( }; ReactDOM.render( - - - , + + + + + , container ); diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index ea6a7e42815cbd..2a2ad100a53d36 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -10,6 +10,7 @@ import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core import { PublicMethodsOf } from '@kbn/utility-types'; import { UiActionsService } from './service'; import { rowClickTrigger, visualizeFieldTrigger, visualizeGeoFieldTrigger } from './triggers'; +import { setTheme } from './services'; export type UiActionsSetup = Pick< UiActionsService, @@ -29,6 +30,7 @@ export class UiActionsPlugin implements Plugin { constructor(initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup): UiActionsSetup { + setTheme(core.theme); this.service.registerTrigger(rowClickTrigger); this.service.registerTrigger(visualizeFieldTrigger); this.service.registerTrigger(visualizeGeoFieldTrigger); diff --git a/src/plugins/ui_actions/public/services.ts b/src/plugins/ui_actions/public/services.ts new file mode 100644 index 00000000000000..96088e086a7718 --- /dev/null +++ b/src/plugins/ui_actions/public/services.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ThemeServiceSetup } from 'src/core/public'; +import { createGetterSetter } from '../../kibana_utils/public'; + +export const [getTheme, setTheme] = createGetterSetter('Theme'); diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx index 24f0a871a12f7d..cf219b1cda117d 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -42,6 +42,7 @@ export const VisualizeListing = () => { visualizeCapabilities, dashboardCapabilities, kbnUrlStateStorage, + theme, }, } = useKibana(); const { pathname } = useLocation(); @@ -201,6 +202,7 @@ export const VisualizeListing = () => { })} toastNotifications={toastNotifications} searchFilters={searchFilters} + theme={theme} > {dashboardCapabilities.createNew && ( <> diff --git a/src/plugins/visualizations/public/visualize_app/types.ts b/src/plugins/visualizations/public/visualize_app/types.ts index cca4d9a48d1045..a414dd2e61762b 100644 --- a/src/plugins/visualizations/public/visualize_app/types.ts +++ b/src/plugins/visualizations/public/visualize_app/types.ts @@ -17,6 +17,7 @@ import type { ToastsStart, ScopedHistory, AppMountParameters, + ThemeServiceStart, } from 'kibana/public'; import type { @@ -105,6 +106,7 @@ export interface VisualizeServices extends CoreStart { usageCollection?: UsageCollectionStart; getKibanaVersion: () => string; spaces?: SpacesPluginStart; + theme: ThemeServiceStart; visEditorsRegistry: VisEditorsRegistry; } diff --git a/src/plugins/visualizations/public/visualize_app/utils/utils.ts b/src/plugins/visualizations/public/visualize_app/utils/utils.ts index a99b756fe8714c..b3257f03354a61 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/utils.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/utils.ts @@ -92,5 +92,6 @@ export const redirectToSavedObjectPage = ( onBeforeRedirect() { setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH); }, + theme: services.theme, })(error); }; diff --git a/x-pack/examples/reporting_example/kibana.json b/x-pack/examples/reporting_example/kibana.json index 94780f1df0b36f..489b2bcd9f506c 100644 --- a/x-pack/examples/reporting_example/kibana.json +++ b/x-pack/examples/reporting_example/kibana.json @@ -10,6 +10,13 @@ }, "description": "Example integration code for applications to feature reports.", "optionalPlugins": [], - "requiredPlugins": ["reporting", "developerExamples", "navigation", "screenshotMode", "share"], + "requiredPlugins": [ + "reporting", + "developerExamples", + "kibanaReact", + "navigation", + "screenshotMode", + "share" + ], "requiredBundles": ["screenshotting"] } diff --git a/x-pack/examples/reporting_example/public/application.tsx b/x-pack/examples/reporting_example/public/application.tsx index 3e1afd7c517a29..9b044ac801773c 100644 --- a/x-pack/examples/reporting_example/public/application.tsx +++ b/x-pack/examples/reporting_example/public/application.tsx @@ -9,6 +9,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Router, Route, Switch } from 'react-router-dom'; import { AppMountParameters, CoreStart } from '../../../../src/core/public'; +import { KibanaThemeProvider } from '../../../../../kibana/src/plugins/kibana_react/public'; import { CaptureTest } from './containers/capture_test'; import { Main } from './containers/main'; import { ApplicationContextProvider } from './application_context'; @@ -23,12 +24,14 @@ export const renderApp = ( ) => { ReactDOM.render( - - - } /> -
} /> - - + + + + } /> +
} /> + + + , element ); diff --git a/x-pack/examples/reporting_example/tsconfig.json b/x-pack/examples/reporting_example/tsconfig.json index 4c4016911e0c55..1b097d8e528685 100644 --- a/x-pack/examples/reporting_example/tsconfig.json +++ b/x-pack/examples/reporting_example/tsconfig.json @@ -9,15 +9,15 @@ "public/**/*.tsx", "server/**/*.ts", "common/**/*.ts", - "../../../typings/**/*", + "../../../typings/**/*" ], "exclude": [], "references": [ { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/navigation/tsconfig.json" }, { "path": "../../../src/plugins/screenshot_mode/tsconfig.json" }, { "path": "../../../examples/developer_examples/tsconfig.json" }, - { "path": "../../plugins/reporting/tsconfig.json" }, + { "path": "../../plugins/reporting/tsconfig.json" } ] } - diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 6ec645c932e053..ee76cce9b9d2b4 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -81,7 +81,8 @@ export class DataEnhancedPlugin usageCollector: this.usageCollector, tourDisabled: plugins.screenshotMode.isScreenshotMode(), }) - ) + ), + { theme$: core.theme.theme$ } ), }); } diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx index 3d1a3052e720bf..127a63b647a249 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx @@ -74,7 +74,8 @@ export const createDeleteActionDescriptor = ( onClick: async () => { const ref = core.overlays.openModal( toMountPoint( - ref?.close()} searchSession={uiSession} api={api} /> + ref?.close()} searchSession={uiSession} api={api} />, + { theme$: core.theme.theme$ } ) ); await ref.onClose; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx index 6989caeca359ef..d8b5e9de16688d 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx @@ -81,7 +81,8 @@ export const createExtendActionDescriptor = ( onClick: async () => { const ref = core.overlays.openModal( toMountPoint( - ref?.close()} searchSession={uiSession} api={api} /> + ref?.close()} searchSession={uiSession} api={api} />, + { theme$: core.theme.theme$ } ) ); await ref.onClose; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx index 23c010e0fbc67e..2b917c28c4b3b1 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx @@ -97,7 +97,7 @@ export const createInspectActionDescriptor = ( ), onClick: async () => { const flyout = ; - const overlay = core.overlays.openFlyout(toMountPoint(flyout)); + const overlay = core.overlays.openFlyout(toMountPoint(flyout, { theme$: core.theme.theme$ })); await overlay.onClose; }, }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/rename_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/rename_button.tsx index beb773e057cb9f..d663d0da5cad70 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/rename_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/rename_button.tsx @@ -113,7 +113,8 @@ export const createRenameActionDescriptor = ( onClick: async () => { const ref = core.overlays.openModal( toMountPoint( - ref?.close()} api={api} searchSession={uiSession} /> + ref?.close()} api={api} searchSession={uiSession} />, + { theme$: core.theme.theme$ } ) ); await ref.onClose; diff --git a/x-pack/plugins/graph/public/apps/listing_route.tsx b/x-pack/plugins/graph/public/apps/listing_route.tsx index 4ed0789f33fdf1..dc70d84155bf96 100644 --- a/x-pack/plugins/graph/public/apps/listing_route.tsx +++ b/x-pack/plugins/graph/public/apps/listing_route.tsx @@ -102,6 +102,7 @@ export function ListingRoute({ tableListTitle={i18n.translate('xpack.graph.listing.graphsTitle', { defaultMessage: 'Graphs', })} + theme={coreStart.theme} /> ); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx index 7f65e50bf44292..e501138648b14e 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx @@ -6,7 +6,7 @@ */ import React, { FC, useEffect } from 'react'; -import type { CoreStart } from 'kibana/public'; +import type { CoreStart, ThemeServiceStart } from 'kibana/public'; import type { UiActionsStart } from 'src/plugins/ui_actions/public'; import type { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { EuiLoadingChart } from '@elastic/eui'; @@ -68,6 +68,7 @@ export function getEmbeddableComponent(core: CoreStart, plugins: PluginsStartDep const input = { ...props }; const [embeddable, loading, error] = useEmbeddableFactory({ factory, input }); const hasActions = props.withActions === true; + const theme = core.theme; if (loading) { return ; @@ -81,6 +82,7 @@ export function getEmbeddableComponent(core: CoreStart, plugins: PluginsStartDep inspector={inspector} actionPredicate={() => hasActions} input={input} + theme={theme} /> ); } @@ -95,6 +97,7 @@ interface EmbeddablePanelWrapperProps { inspector: PluginsStartDependencies['inspector']; actionPredicate: (id: string) => boolean; input: EmbeddableComponentProps; + theme: ThemeServiceStart; } const EmbeddablePanelWrapper: FC = ({ @@ -103,6 +106,7 @@ const EmbeddablePanelWrapper: FC = ({ actionPredicate, inspector, input, + theme, }) => { useEffect(() => { embeddable.updateInput(input); @@ -118,6 +122,7 @@ const EmbeddablePanelWrapper: FC = ({ showShadow={false} showBadges={false} showNotifications={false} + theme={theme} /> ); }; diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index ddc2851f595b06..027981de32295e 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -54,6 +54,7 @@ export const getSavedObjectsTagging = () => pluginsStart.savedObjectsTagging; export const getPresentationUtilContext = () => pluginsStart.presentationUtil.ContextProvider; export const getSecurityService = () => pluginsStart.security; export const getSpacesApi = () => pluginsStart.spaces; +export const getTheme = () => coreStart.theme; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 7dc8c9c88d4ca9..571cba64a06c45 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -21,6 +21,7 @@ import { getSavedObjectsClient, getSavedObjectsTagging, getSavedObjects, + getTheme, } from '../../kibana_services'; import { getAppTitle } from '../../../common/i18n_getters'; import { MapSavedObjectAttributes } from '../../../common/map_saved_object_type'; @@ -148,6 +149,7 @@ export function MapsListView() { tableListTitle={getAppTitle()} toastNotifications={getToasts()} searchFilters={searchFilters} + theme={getTheme()} /> ); } diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 1bb8c3229407da..78742af7fe8790 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -7,7 +7,7 @@ import sinon, { stub } from 'sinon'; import { NotificationsStart } from 'src/core/public'; -import { coreMock } from '../../../../../src/core/public/mocks'; +import { coreMock, themeServiceMock } from '../../../../../src/core/public/mocks'; import { JobSummary, ReportApiJSON } from '../../common/types'; import { Job } from './job'; import { ReportingAPIClient } from './reporting_api_client'; @@ -46,19 +46,21 @@ const notificationsMock = { }, } as unknown as NotificationsStart; +const theme = themeServiceMock.createStartContract(); + describe('stream handler', () => { afterEach(() => { sinon.reset(); }); it('constructs', () => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); expect(sh).not.toBe(null); }); describe('findChangedStatusJobs', () => { it('finds no changed status jobs from empty', (done) => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); const findJobs = sh.findChangedStatusJobs([]); findJobs.subscribe((data) => { expect(data).toEqual({ completed: [], failed: [] }); @@ -67,7 +69,7 @@ describe('stream handler', () => { }); it('finds changed status jobs', (done) => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); const findJobs = sh.findChangedStatusJobs([ 'job-source-mock1', 'job-source-mock2', @@ -83,7 +85,7 @@ describe('stream handler', () => { describe('showNotifications', () => { it('show success', (done) => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); sh.showNotifications({ completed: [ { @@ -104,7 +106,7 @@ describe('stream handler', () => { }); it('show max length warning', (done) => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); sh.showNotifications({ completed: [ { @@ -126,7 +128,7 @@ describe('stream handler', () => { }); it('show csv formulas warning', (done) => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); sh.showNotifications({ completed: [ { @@ -148,7 +150,7 @@ describe('stream handler', () => { }); it('show failed job toast', (done) => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); sh.showNotifications({ completed: [], failed: [ @@ -169,7 +171,7 @@ describe('stream handler', () => { }); it('show multiple toast', (done) => { - const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock); + const sh = new ReportingNotifierStreamHandler(notificationsMock, jobQueueClientMock, theme); sh.showNotifications({ completed: [ { diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 304b4fb73374df..27e220221156e9 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import * as Rx from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { NotificationsSetup } from 'src/core/public'; +import { NotificationsSetup, ThemeServiceStart } from 'src/core/public'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JOB_STATUSES } from '../../common/constants'; import { JobId, JobSummary, JobSummarySet } from '../../common/types'; import { @@ -37,7 +37,11 @@ function getReportStatus(src: Job): JobSummary { } export class ReportingNotifierStreamHandler { - constructor(private notifications: NotificationsSetup, private apiClient: ReportingAPIClient) {} + constructor( + private notifications: NotificationsSetup, + private apiClient: ReportingAPIClient, + private theme: ThemeServiceStart + ) {} /* * Use Kibana Toast API to show our messages @@ -54,7 +58,8 @@ export class ReportingNotifierStreamHandler { getWarningFormulasToast( job, this.apiClient.getManagementLink, - this.apiClient.getDownloadLink + this.apiClient.getDownloadLink, + this.theme ) ); } else if (job.maxSizeReached) { @@ -62,12 +67,18 @@ export class ReportingNotifierStreamHandler { getWarningMaxSizeToast( job, this.apiClient.getManagementLink, - this.apiClient.getDownloadLink + this.apiClient.getDownloadLink, + this.theme ) ); } else { this.notifications.toasts.addSuccess( - getSuccessToast(job, this.apiClient.getManagementLink, this.apiClient.getDownloadLink) + getSuccessToast( + job, + this.apiClient.getManagementLink, + this.apiClient.getDownloadLink, + this.theme + ) ); } } @@ -76,7 +87,7 @@ export class ReportingNotifierStreamHandler { for (const job of failedJobs) { const errorText = await this.apiClient.getError(job.id); this.notifications.toasts.addDanger( - getFailureToast(errorText, job, this.apiClient.getManagementLink) + getFailureToast(errorText, job, this.apiClient.getManagementLink, this.theme) ); } return { completed: completedJobs, failed: failedJobs }; @@ -120,7 +131,8 @@ export class ReportingNotifierStreamHandler { i18n.translate('xpack.reporting.publicNotifier.httpErrorMessage', { defaultMessage: 'Could not check Reporting job status!', }), - err + err, + this.theme ) ); // prettier-ignore window.console.error(err); diff --git a/x-pack/plugins/reporting/public/notifier/general_error.tsx b/x-pack/plugins/reporting/public/notifier/general_error.tsx index 141b7b49444b0f..66fff4d00ceeb2 100644 --- a/x-pack/plugins/reporting/public/notifier/general_error.tsx +++ b/x-pack/plugins/reporting/public/notifier/general_error.tsx @@ -8,10 +8,14 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { ToastInput } from 'src/core/public'; +import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -export const getGeneralErrorToast = (errorText: string, err: Error): ToastInput => ({ +export const getGeneralErrorToast = ( + errorText: string, + err: Error, + theme: ThemeServiceStart +): ToastInput => ({ text: toMountPoint( @@ -24,7 +28,8 @@ export const getGeneralErrorToast = (errorText: string, err: Error): ToastInput id="xpack.reporting.publicNotifier.error.tryRefresh" defaultMessage="Try refreshing the page." /> - + , + { theme$: theme.theme$ } ), iconType: undefined, }); diff --git a/x-pack/plugins/reporting/public/notifier/job_failure.tsx b/x-pack/plugins/reporting/public/notifier/job_failure.tsx index a9e7b78c7e12f6..87fbc72d29ab82 100644 --- a/x-pack/plugins/reporting/public/notifier/job_failure.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_failure.tsx @@ -9,14 +9,15 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { Fragment } from 'react'; -import { ToastInput } from 'src/core/public'; +import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobSummary, ManagementLinkFn } from '../../common/types'; export const getFailureToast = ( errorText: string, job: JobSummary, - getManagmenetLink: ManagementLinkFn + getManagmenetLink: ManagementLinkFn, + theme: ThemeServiceStart ): ToastInput => { return { title: toMountPoint( @@ -24,7 +25,8 @@ export const getFailureToast = ( id="xpack.reporting.publicNotifier.error.couldNotCreateReportTitle" defaultMessage="Could not create report for {reportObjectType} '{reportObjectTitle}'." values={{ reportObjectType: job.jobtype, reportObjectTitle: job.title }} - /> + />, + { theme$: theme.theme$ } ), text: toMountPoint( @@ -58,7 +60,8 @@ export const getFailureToast = ( }} />

-
+ , + { theme$: theme.theme$ } ), iconType: undefined, 'data-test-subj': 'completeReportFailure', diff --git a/x-pack/plugins/reporting/public/notifier/job_success.tsx b/x-pack/plugins/reporting/public/notifier/job_success.tsx index c1de9a7625858e..f949c27f6fedbf 100644 --- a/x-pack/plugins/reporting/public/notifier/job_success.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_success.tsx @@ -7,7 +7,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import React, { Fragment } from 'react'; -import { ToastInput } from 'src/core/public'; +import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../common/types'; import { DownloadButton } from './job_download_button'; @@ -16,14 +16,16 @@ import { ReportLink } from './report_link'; export const getSuccessToast = ( job: JobSummary, getReportLink: () => string, - getDownloadLink: (jobId: JobId) => string + getDownloadLink: (jobId: JobId) => string, + theme: ThemeServiceStart ): ToastInput => ({ title: toMountPoint( + />, + { theme$: theme.theme$ } ), color: 'success', text: toMountPoint( @@ -32,7 +34,8 @@ export const getSuccessToast = (

- + , + { theme$: theme.theme$ } ), 'data-test-subj': 'completeReportSuccess', }); diff --git a/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx index c835203813b866..08c87a40a829ac 100644 --- a/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_warning_formulas.tsx @@ -7,7 +7,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import React, { Fragment } from 'react'; -import { ToastInput } from 'src/core/public'; +import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../common/types'; import { DownloadButton } from './job_download_button'; @@ -16,14 +16,16 @@ import { ReportLink } from './report_link'; export const getWarningFormulasToast = ( job: JobSummary, getReportLink: () => string, - getDownloadLink: (jobId: JobId) => string + getDownloadLink: (jobId: JobId) => string, + theme: ThemeServiceStart ): ToastInput => ({ title: toMountPoint( + />, + { theme$: theme.theme$ } ), text: toMountPoint( @@ -37,7 +39,8 @@ export const getWarningFormulasToast = (

-
+ , + { theme$: theme.theme$ } ), 'data-test-subj': 'completeReportCsvFormulasWarning', }); diff --git a/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx index f7cc8e2219df90..629ac44adeae86 100644 --- a/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_warning_max_size.tsx @@ -7,7 +7,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import React, { Fragment } from 'react'; -import { ToastInput } from 'src/core/public'; +import { ThemeServiceStart, ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../common/types'; import { DownloadButton } from './job_download_button'; @@ -16,14 +16,16 @@ import { ReportLink } from './report_link'; export const getWarningMaxSizeToast = ( job: JobSummary, getReportLink: () => string, - getDownloadLink: (jobId: JobId) => string + getDownloadLink: (jobId: JobId) => string, + theme: ThemeServiceStart ): ToastInput => ({ title: toMountPoint( + />, + { theme$: theme.theme$ } ), text: toMountPoint( @@ -37,7 +39,8 @@ export const getWarningMaxSizeToast = (

-
+ , + { theme$: theme.theme$ } ), 'data-test-subj': 'completeReportMaxSizeWarning', }); diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index b1f9b63e66cbe2..77c8489bb89926 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -17,6 +17,7 @@ import { NotificationsSetup, Plugin, PluginInitializerContext, + ThemeServiceStart, } from 'src/core/public'; import type { ScreenshottingSetup } from '../../screenshotting/public'; import { CONTEXT_MENU_TRIGGER } from '../../../../src/plugins/embeddable/public'; @@ -56,13 +57,18 @@ function getStored(): JobId[] { return sessionValue ? JSON.parse(sessionValue) : []; } -function handleError(notifications: NotificationsSetup, err: Error): Rx.Observable { +function handleError( + notifications: NotificationsSetup, + err: Error, + theme: ThemeServiceStart +): Rx.Observable { notifications.toasts.addDanger( getGeneralErrorToast( i18n.translate('xpack.reporting.publicNotifier.pollingErrorMessage', { defaultMessage: 'Reporting notifier error!', }), - err + err, + theme ) ); window.console.error(err); @@ -235,6 +241,7 @@ export class ReportingPublicPlugin startServices$, uiSettings, usesUiCapabilities, + theme: core.theme, }) ); @@ -246,6 +253,7 @@ export class ReportingPublicPlugin startServices$, uiSettings, usesUiCapabilities, + theme: core.theme, }) ); @@ -255,7 +263,7 @@ export class ReportingPublicPlugin public start(core: CoreStart) { const { notifications } = core; const apiClient = this.getApiClient(core.http, core.uiSettings); - const streamHandler = new StreamHandler(notifications, apiClient); + const streamHandler = new StreamHandler(notifications, apiClient, core.theme); const interval = durationToNumber(this.config.poll.jobsRefresh.interval); Rx.timer(0, interval) .pipe( @@ -264,7 +272,7 @@ export class ReportingPublicPlugin filter((storedJobs) => storedJobs.length > 0), // stop the pipeline here if there are none pending mergeMap((storedJobs) => streamHandler.findChangedStatusJobs(storedJobs)), // look up the latest status of all pending jobs on the server mergeMap(({ completed, failed }) => streamHandler.showNotifications({ completed, failed })), - catchError((err) => handleError(notifications, err)) + catchError((err) => handleError(notifications, err, core.theme)) ) .subscribe(); diff --git a/x-pack/plugins/reporting/public/share_context_menu/index.ts b/x-pack/plugins/reporting/public/share_context_menu/index.ts index 321a5a29281af9..6a5dbf970e0b4d 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/index.ts +++ b/x-pack/plugins/reporting/public/share_context_menu/index.ts @@ -6,7 +6,7 @@ */ import * as Rx from 'rxjs'; -import type { IUiSettingsClient, ToastsSetup } from 'src/core/public'; +import type { IUiSettingsClient, ThemeServiceSetup, ToastsSetup } from 'src/core/public'; import { CoreStart } from 'src/core/public'; import type { LayoutParams } from '../../../screenshotting/common'; import type { LicensingPluginSetup } from '../../../licensing/public'; @@ -19,6 +19,7 @@ export interface ExportPanelShareOpts { license$: LicensingPluginSetup['license$']; // FIXME: 'license$' is deprecated startServices$: Rx.Observable<[CoreStart, object, unknown]>; usesUiCapabilities: boolean; + theme: ThemeServiceSetup; } export interface ReportingSharingData { diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 8859d01e4fe9a5..b264c963611222 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -21,6 +21,7 @@ export const ReportingCsvShareProvider = ({ license$, startServices$, usesUiCapabilities, + theme, }: ExportPanelShareOpts) => { let licenseToolTipContent = ''; let licenseHasCsvReporting = false; @@ -96,6 +97,7 @@ export const ReportingCsvShareProvider = ({ objectId={objectId} getJobParams={getJobParams} onClose={onClose} + theme={theme} /> ), }, diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index 610781f3b6ea07..3cc8cbacc7921b 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -63,6 +63,7 @@ export const reportingScreenshotShareProvider = ({ license$, startServices$, usesUiCapabilities, + theme, }: ExportPanelShareOpts) => { let licenseToolTipContent = ''; let licenseDisabled = true; @@ -156,6 +157,7 @@ export const reportingScreenshotShareProvider = ({ getJobParams={getJobParams(apiClient, jobProviderOptions, pngReportType)} isDirty={isDirty} onClose={onClose} + theme={theme} /> ), }, @@ -191,6 +193,7 @@ export const reportingScreenshotShareProvider = ({ getJobParams={getJobParams(apiClient, jobProviderOptions, pdfReportType)} isDirty={isDirty} onClose={onClose} + theme={theme} /> ), }, diff --git a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.test.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.test.tsx index e9dd584e51f82c..ef3e9940238c11 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.test.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.test.tsx @@ -10,6 +10,7 @@ import { mountWithIntl } from '@kbn/test/jest'; import { httpServiceMock, notificationServiceMock, + themeServiceMock, uiSettingsServiceMock, } from 'src/core/public/mocks'; import { ReportingAPIClient } from '../../lib/reporting_api_client'; @@ -21,6 +22,8 @@ jest.mock('./constants', () => ({ })); import * as constants from './constants'; +const theme = themeServiceMock.createSetupContract(); + describe('ReportingPanelContent', () => { const props: Partial = { layoutId: 'super_cool_layout_id_X', @@ -58,6 +61,7 @@ describe('ReportingPanelContent', () => { apiClient={apiClient} toasts={toasts} uiSettings={uiSettings} + theme={theme} {...props} {...newProps} /> diff --git a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx index 73ccbc2b13d753..e1fa1198cf1f80 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx @@ -18,7 +18,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; import React, { Component, ReactElement } from 'react'; -import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; +import { IUiSettingsClient, ThemeServiceSetup, ToastsSetup } from 'src/core/public'; import url from 'url'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { @@ -46,6 +46,7 @@ export interface ReportingPanelProps { options?: ReactElement | null; isDirty?: boolean; onClose?: () => void; + theme: ThemeServiceSetup; } export type Props = ReportingPanelProps & { intl: InjectedIntl }; @@ -291,7 +292,8 @@ class ReportingPanelContentUi extends Component { ), }} - /> + />, + { theme$: this.props.theme.theme$ } ), 'data-test-subj': 'queueReportSuccess', }); diff --git a/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.test.tsx b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.test.tsx index 7a2fa52d010e31..ebf741c79bd86a 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.test.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.test.tsx @@ -8,7 +8,7 @@ import { mount } from 'enzyme'; import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; -import { coreMock } from 'src/core/public/mocks'; +import { coreMock, themeServiceMock } from 'src/core/public/mocks'; import { ReportingAPIClient } from '../lib/reporting_api_client'; import { ScreenCapturePanelContent } from './screen_capture_panel_content'; @@ -27,6 +27,8 @@ const getJobParamsDefault = () => ({ browserTimezone: 'America/New_York', }); +const theme = themeServiceMock.createSetupContract(); + test('ScreenCapturePanelContent renders the default view properly', () => { const component = mount( @@ -37,6 +39,7 @@ test('ScreenCapturePanelContent renders the default view properly', () => { uiSettings={uiSettings} toasts={coreSetup.notifications.toasts} getJobParams={getJobParamsDefault} + theme={theme} /> ); @@ -56,6 +59,7 @@ test('ScreenCapturePanelContent properly renders a view with "canvas" layout opt uiSettings={uiSettings} toasts={coreSetup.notifications.toasts} getJobParams={getJobParamsDefault} + theme={theme} /> ); @@ -75,6 +79,7 @@ test('ScreenCapturePanelContent allows POST URL to be copied when objectId is pr toasts={coreSetup.notifications.toasts} getJobParams={getJobParamsDefault} objectId={'1234-5'} + theme={theme} /> ); @@ -93,6 +98,7 @@ test('ScreenCapturePanelContent does not allow POST URL to be copied when object uiSettings={uiSettings} toasts={coreSetup.notifications.toasts} getJobParams={getJobParamsDefault} + theme={theme} /> ); @@ -111,6 +117,7 @@ test('ScreenCapturePanelContent properly renders a view with "print" layout opti uiSettings={uiSettings} toasts={coreSetup.notifications.toasts} getJobParams={getJobParamsDefault} + theme={theme} /> ); @@ -130,6 +137,7 @@ test('ScreenCapturePanelContent decorated job params are visible in the POST URL uiSettings={uiSettings} toasts={coreSetup.notifications.toasts} getJobParams={getJobParamsDefault} + theme={theme} /> ); diff --git a/x-pack/plugins/reporting/public/shared/get_shared_components.tsx b/x-pack/plugins/reporting/public/shared/get_shared_components.tsx index b08036e8b1c806..0906bf85c9538a 100644 --- a/x-pack/plugins/reporting/public/shared/get_shared_components.tsx +++ b/x-pack/plugins/reporting/public/shared/get_shared_components.tsx @@ -35,6 +35,7 @@ export function getSharedComponents(core: CoreSetup, apiClient: ReportingAPIClie apiClient={apiClient} toasts={core.notifications.toasts} uiSettings={core.uiSettings} + theme={core.theme} {...props} /> ); @@ -48,6 +49,7 @@ export function getSharedComponents(core: CoreSetup, apiClient: ReportingAPIClie apiClient={apiClient} toasts={core.notifications.toasts} uiSettings={core.uiSettings} + theme={core.theme} {...props} /> ); @@ -61,6 +63,7 @@ export function getSharedComponents(core: CoreSetup, apiClient: ReportingAPIClie apiClient={apiClient} toasts={core.notifications.toasts} uiSettings={core.uiSettings} + theme={core.theme} {...props} /> ); diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json index 66d528cd83a22e..24db8258566273 100644 --- a/x-pack/plugins/reporting/tsconfig.json +++ b/x-pack/plugins/reporting/tsconfig.json @@ -6,18 +6,14 @@ "declaration": true, "declarationMap": true }, - "include": [ - "common/**/*", - "public/**/*", - "server/**/*", - "../../../typings/**/*" - ], + "include": ["common/**/*", "public/**/*", "server/**/*", "../../../typings/**/*"], "references": [ { "path": "../../../src/core/tsconfig.json" }, - { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../../../src/plugins/discover/tsconfig.json" }, { "path": "../../../src/plugins/embeddable/tsconfig.json" }, { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, { "path": "../../../src/plugins/screenshot_mode/tsconfig.json" }, { "path": "../../../src/plugins/share/tsconfig.json" }, @@ -29,6 +25,6 @@ { "path": "../licensing/tsconfig.json" }, { "path": "../screenshotting/tsconfig.json" }, { "path": "../security/tsconfig.json" }, - { "path": "../spaces/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" } ] } diff --git a/x-pack/plugins/runtime_fields/README.md b/x-pack/plugins/runtime_fields/README.md index eb7b31e6e1154e..9c0e0e03f2fe5d 100644 --- a/x-pack/plugins/runtime_fields/README.md +++ b/x-pack/plugins/runtime_fields/README.md @@ -72,7 +72,7 @@ interface RuntimeField { type: RuntimeType; // 'long' | 'boolean' ... script: { source: string; - } + }; } ``` @@ -103,8 +103,8 @@ interface Context { The runtime field editor is also exported as static React component that you can import into your components. The editor is exported in 2 flavours: -* As the content of a `` (it contains a flyout header and footer) -* As a standalone component that you can inline anywhere +- As the content of a `` (it contains a flyout header and footer) +- As a standalone component that you can inline anywhere **Note:** The runtime field editor uses the `` that has a dependency on the `Provider` from the `"kibana_react"` plugin. If your app is not already wrapped by this provider you will need to add it at least around the runtime field editor. You can see an example in the ["Using the core.overlays.openFlyout()"](#using-the-coreoverlaysopenflyout) example below. @@ -118,7 +118,7 @@ import { RuntimeFieldEditorFlyoutContent, RuntimeField } from '../runtime_fields const MyComponent = () => { const { docLinksStart } = useCoreContext(); // access the core start service const [isFlyoutVisilbe, setIsFlyoutVisible] = useState(false); - + const saveRuntimeField = useCallback((field: RuntimeField) => { // Do something with the field }, []); @@ -139,7 +139,7 @@ const MyComponent = () => { )} - ) + ) } ``` @@ -157,11 +157,11 @@ import { RuntimeFieldEditorFlyoutContent, RuntimeField } from '../runtime_fields const MyComponent = () => { // Access the core start service - const { docLinksStart, overlays, uiSettings } = useCoreContext(); + const { docLinksStart, theme, overlays, uiSettings } = useCoreContext(); const flyoutEditor = useRef(null); const { openFlyout } = overlays; - + const saveRuntimeField = useCallback((field: RuntimeField) => { // Do something with the field }, []); @@ -179,7 +179,8 @@ const MyComponent = () => { defaultValue={defaultRuntimeField} ctx={/*optional context object -- see section above*/} /> - + , + { theme$: theme.theme$ } ) ); }, [openFlyout, saveRuntimeField, uiSettings]); @@ -188,7 +189,7 @@ const MyComponent = () => { <> Create field - ) + ) } ``` @@ -208,7 +209,7 @@ const MyComponent = () => { }); const { submit, isValid: isFormValid, isSubmitted } = runtimeFieldFormState; - + const saveRuntimeField = useCallback(async () => { const { isValid, data } = await submit(); if (isValid) { @@ -233,6 +234,6 @@ const MyComponent = () => { Save field - ) + ) } -``` \ No newline at end of file +``` diff --git a/x-pack/plugins/runtime_fields/public/load_editor.tsx b/x-pack/plugins/runtime_fields/public/load_editor.tsx index 0cea90f33a54de..6aec33b90466f4 100644 --- a/x-pack/plugins/runtime_fields/public/load_editor.tsx +++ b/x-pack/plugins/runtime_fields/public/load_editor.tsx @@ -22,7 +22,7 @@ export const getRuntimeFieldEditorLoader = (coreSetup: CoreSetup) => async (): Promise => { const { RuntimeFieldEditorFlyoutContent } = await import('./components'); const [core] = await coreSetup.getStartServices(); - const { uiSettings, overlays, docLinks } = core; + const { uiSettings, theme, overlays, docLinks } = core; const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ uiSettings }); let overlayRef: OverlayRef | null = null; @@ -50,7 +50,8 @@ export const getRuntimeFieldEditorLoader = defaultValue={defaultValue} ctx={ctx} /> - + , + { theme$: theme.theme$ } ) ); diff --git a/x-pack/plugins/runtime_fields/public/plugin.test.ts b/x-pack/plugins/runtime_fields/public/plugin.test.ts index 0f72d99ec5d4f2..fc36eecc12f0a1 100644 --- a/x-pack/plugins/runtime_fields/public/plugin.test.ts +++ b/x-pack/plugins/runtime_fields/public/plugin.test.ts @@ -6,7 +6,7 @@ */ import { CoreSetup } from 'src/core/public'; -import { coreMock } from 'src/core/public/mocks'; +import { coreMock, themeServiceMock } from 'src/core/public/mocks'; jest.mock('../../../../src/plugins/kibana_react/public', () => { const original = jest.requireActual('../../../../src/plugins/kibana_react/public'); @@ -52,6 +52,7 @@ describe('RuntimeFieldsPlugin', () => { openFlyout, }, uiSettings: {}, + theme: themeServiceMock.createStartContract(), }; coreSetup.getStartServices = async () => [mockCore] as any; const setupApi = await plugin.setup(coreSetup, {}); From 8aa3660a2aa7ca5df26d57e37edc3087d7aeec52 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Thu, 13 Jan 2022 08:35:26 -0600 Subject: [PATCH 26/29] [Fleet] Add package service to fleet plugin (#121589) --- .../get_apm_package_policy_definition.ts | 7 +- x-pack/plugins/fleet/server/index.ts | 1 + x-pack/plugins/fleet/server/mocks/index.ts | 13 +- x-pack/plugins/fleet/server/plugin.ts | 40 +++- .../epm/elasticsearch/transform/install.ts | 2 +- .../fleet/server/services/epm/index.ts | 10 + .../services/epm/package_service.mock.ts | 26 +++ .../services/epm/package_service.test.ts | 206 ++++++++++++++++++ .../server/services/epm/package_service.ts | 164 ++++++++++++++ x-pack/plugins/fleet/server/services/index.ts | 10 +- .../routes/status/create_status_route.ts | 9 +- .../server/endpoint/mocks.ts | 15 +- .../endpoint/routes/actions/isolation.test.ts | 51 +++-- .../endpoint/routes/metadata/metadata.test.ts | 54 +++-- .../services/endpoint_fleet_services.ts | 12 +- .../synthetics_service/synthetics_service.ts | 24 +- x-pack/plugins/uptime/server/plugin.ts | 2 +- .../install_index_templates.ts | 30 +-- 18 files changed, 531 insertions(+), 145 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/epm/index.ts create mode 100644 x-pack/plugins/fleet/server/services/epm/package_service.mock.ts create mode 100644 x-pack/plugins/fleet/server/services/epm/package_service.test.ts create mode 100644 x-pack/plugins/fleet/server/services/epm/package_service.ts diff --git a/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts index d5cee57b11a822..939feb7c9e22fe 100644 --- a/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts +++ b/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts @@ -61,9 +61,10 @@ async function getApmPackageVersion( ) { if (fleetPluginStart && isPrereleaseVersion(kibanaVersion)) { try { - const latestApmPackage = await fleetPluginStart.fetchFindLatestPackage( - 'apm' - ); + const latestApmPackage = + await fleetPluginStart.packageService.asInternalUser.fetchFindLatestPackage( + 'apm' + ); return latestApmPackage.version; } catch (error) { return SUPPORTED_APM_PACKAGE_VERSION; diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 8cbfa311081d23..f66bf00a5a054c 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -22,6 +22,7 @@ export type { AgentClient, ESIndexPatternService, PackageService, + PackageClient, AgentPolicyServiceInterface, ArtifactsClientInterface, Artifact, diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index 7e47c8b59ac7ae..684f84a9b48a5d 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -18,12 +18,13 @@ import { licensingMock } from '../../../../plugins/licensing/server/mocks'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { securityMock } from '../../../security/server/mocks'; import type { PackagePolicyServiceInterface } from '../services/package_policy'; -import type { AgentPolicyServiceInterface, PackageService } from '../services'; +import type { AgentPolicyServiceInterface } from '../services'; import type { FleetAppContext } from '../plugin'; import { createMockTelemetryEventsSender } from '../telemetry/__mocks__'; import { createFleetAuthzMock } from '../../common'; import { agentServiceMock } from '../services/agents/agent_service.mock'; import type { FleetRequestHandlerContext } from '../types'; +import { packageServiceMock } from '../services/epm/package_service.mock'; // Export all mocks from artifacts export * from '../services/artifacts/mocks'; @@ -142,9 +143,7 @@ export const createMockAgentService = () => agentServiceMock.create(); */ export const createMockAgentClient = () => agentServiceMock.createClient(); -export const createMockPackageService = (): PackageService => { - return { - getInstallation: jest.fn(), - ensureInstalledPackage: jest.fn(), - }; -}; +/** + * Creates a mock PackageService + */ +export const createMockPackageService = () => packageServiceMock.create(); diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index d719d9d33fa791..51802c96791b14 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -19,6 +19,7 @@ import type { KibanaRequest, ServiceStatus, ElasticsearchClient, + SavedObjectsClientContract, } from 'kibana/server'; import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -80,15 +81,14 @@ import { agentPolicyService, packagePolicyService, AgentServiceImpl, + PackageServiceImpl, } from './services'; import { registerFleetUsageCollector } from './collectors/register'; -import { getInstallation, ensureInstalledPackage } from './services/epm/packages'; import { getAuthzFromRequest, makeRouterWithFleetAuthz } from './routes/security'; import { FleetArtifactsClient } from './services/artifacts'; import type { FleetRouter } from './types/request_context'; import { TelemetryEventsSender } from './telemetry/sender'; import { setupFleet } from './services/setup'; -import { fetchFindLatestPackage } from './services/epm/registry'; export interface FleetSetupDeps { licensing: LicensingPluginSetup; @@ -170,8 +170,6 @@ export interface FleetStartContract { * @param packageName */ createArtifactsClient: (packageName: string) => FleetArtifactsClient; - - fetchFindLatestPackage: typeof fetchFindLatestPackage; } export class FleetPlugin @@ -193,6 +191,7 @@ export class FleetPlugin private readonly fleetStatus$: BehaviorSubject; private agentService?: AgentService; + private packageService?: PackageService; constructor(private readonly initializerContext: PluginInitializerContext) { this.config$ = this.initializerContext.config.create(); @@ -407,10 +406,10 @@ export class FleetPlugin }, fleetSetupCompleted: () => fleetSetupPromise, esIndexPatternService: new ESIndexPatternSavedObjectService(), - packageService: { - getInstallation, - ensureInstalledPackage, - }, + packageService: this.setupPackageService( + core.elasticsearch.client.asInternalUser, + new SavedObjectsClient(core.savedObjects.createInternalRepository()) + ), agentService: this.setupAgentService(core.elasticsearch.client.asInternalUser), agentPolicyService: { get: agentPolicyService.get, @@ -426,7 +425,6 @@ export class FleetPlugin createArtifactsClient(packageName: string) { return new FleetArtifactsClient(core.elasticsearch.client.asInternalUser, packageName); }, - fetchFindLatestPackage, }; } @@ -445,4 +443,28 @@ export class FleetPlugin this.agentService = new AgentServiceImpl(internalEsClient); return this.agentService; } + + private setupPackageService( + internalEsClient: ElasticsearchClient, + internalSoClient: SavedObjectsClientContract + ): PackageService { + if (this.packageService) { + return this.packageService; + } + + this.packageService = new PackageServiceImpl( + internalEsClient, + internalSoClient, + this.getLogger() + ); + return this.packageService; + } + + private getLogger(): Logger { + if (!this.logger) { + this.logger = this.initializerContext.logger.get(); + } + + return this.logger; + } } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts index 197d463797cac8..144f994646fe9c 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts @@ -112,7 +112,7 @@ export const installTransform = async ( return installedTransforms; }; -const isTransform = (path: string) => { +export const isTransform = (path: string) => { const pathParts = getPathParts(path); return !path.endsWith('/') && pathParts.type === ElasticsearchAssetType.transform; }; diff --git a/x-pack/plugins/fleet/server/services/epm/index.ts b/x-pack/plugins/fleet/server/services/epm/index.ts new file mode 100644 index 00000000000000..f9d026dd0e0928 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PackageServiceImpl } from './package_service'; + +export type { PackageService, PackageClient } from './package_service'; diff --git a/x-pack/plugins/fleet/server/services/epm/package_service.mock.ts b/x-pack/plugins/fleet/server/services/epm/package_service.mock.ts new file mode 100644 index 00000000000000..f703399ca6df79 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/package_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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PackageClient, PackageService } from './package_service'; + +const createClientMock = (): jest.Mocked => ({ + getInstallation: jest.fn(), + ensureInstalledPackage: jest.fn(), + fetchFindLatestPackage: jest.fn(), + getRegistryPackage: jest.fn(), + reinstallEsAssets: jest.fn(), +}); + +const createServiceMock = (): PackageService => ({ + asScoped: jest.fn(createClientMock), + asInternalUser: createClientMock(), +}); + +export const packageServiceMock = { + createClient: createClientMock, + create: createServiceMock, +}; diff --git a/x-pack/plugins/fleet/server/services/epm/package_service.test.ts b/x-pack/plugins/fleet/server/services/epm/package_service.test.ts new file mode 100644 index 00000000000000..fb92b341928da4 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/package_service.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('../../routes/security'); + +import type { MockedLogger } from '@kbn/logging/target_types/mocks'; + +import type { + ElasticsearchClient, + SavedObjectsClientContract, +} from '../../../../../../src/core/server'; +import { + elasticsearchServiceMock, + httpServerMock, + loggingSystemMock, + savedObjectsClientMock, +} from '../../../../../../src/core/server/mocks'; + +import { FleetUnauthorizedError } from '../../errors'; +import type { InstallablePackage } from '../../types'; + +import type { PackageClient, PackageService } from './package_service'; +import { PackageServiceImpl } from './package_service'; +import * as epmPackagesGet from './packages/get'; +import * as epmPackagesInstall from './packages/install'; +import * as epmRegistry from './registry'; +import * as epmTransformsInstall from './elasticsearch/transform/install'; + +const testKeys = [ + 'getInstallation', + 'ensureInstalledPackage', + 'fetchFindLatestPackage', + 'getRegistryPackage', + 'reinstallEsAssets', +]; + +function getTest( + mocks: { + packageClient: PackageClient; + esClient?: ElasticsearchClient; + soClient?: SavedObjectsClientContract; + logger?: MockedLogger; + }, + testKey: string +) { + let test: { + method: Function; + args: any[]; + spy: jest.SpyInstance; + spyArgs: any[]; + spyResponse: any; + }; + + switch (testKey) { + case testKeys[0]: + test = { + method: mocks.packageClient.getInstallation.bind(mocks.packageClient), + args: ['package name'], + spy: jest.spyOn(epmPackagesGet, 'getInstallation'), + spyArgs: [ + { + pkgName: 'package name', + savedObjectsClient: mocks.soClient, + }, + ], + spyResponse: { name: 'getInstallation test' }, + }; + break; + case testKeys[1]: + test = { + method: mocks.packageClient.ensureInstalledPackage.bind(mocks.packageClient), + args: [{ pkgName: 'package name', pkgVersion: '8.0.0', spaceId: 'spaceId' }], + spy: jest.spyOn(epmPackagesInstall, 'ensureInstalledPackage'), + spyArgs: [ + { + pkgName: 'package name', + pkgVersion: '8.0.0', + spaceId: 'spaceId', + esClient: mocks.esClient, + savedObjectsClient: mocks.soClient, + }, + ], + spyResponse: { name: 'ensureInstalledPackage test' }, + }; + break; + case testKeys[2]: + test = { + method: mocks.packageClient.fetchFindLatestPackage.bind(mocks.packageClient), + args: ['package name'], + spy: jest.spyOn(epmRegistry, 'fetchFindLatestPackage'), + spyArgs: ['package name'], + spyResponse: { name: 'fetchFindLatestPackage test' }, + }; + break; + case testKeys[3]: + test = { + method: mocks.packageClient.getRegistryPackage.bind(mocks.packageClient), + args: ['package name', '8.0.0'], + spy: jest.spyOn(epmRegistry, 'getRegistryPackage'), + spyArgs: ['package name', '8.0.0'], + spyResponse: { + packageInfo: { name: 'getRegistryPackage test' }, + paths: ['/some/test/path'], + }, + }; + break; + case testKeys[4]: + const pkg: InstallablePackage = { + format_version: '1.0.0', + name: 'package name', + title: 'package title', + description: 'package description', + version: '8.0.0', + release: 'ga', + owner: { github: 'elastic' }, + }; + const paths = ['some/test/transform/path']; + + test = { + method: mocks.packageClient.reinstallEsAssets.bind(mocks.packageClient), + args: [pkg, paths], + spy: jest.spyOn(epmTransformsInstall, 'installTransform'), + spyArgs: [pkg, paths, mocks.esClient, mocks.soClient, mocks.logger], + spyResponse: [ + { + name: 'package name', + }, + ], + }; + break; + default: + throw new Error('invalid test key'); + } + + return test; +} + +describe('PackageService', () => { + let mockPackageService: PackageService; + let mockEsClient: ElasticsearchClient; + let mockSoClient: SavedObjectsClientContract; + let mockLogger: MockedLogger; + + beforeEach(() => { + mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + mockSoClient = savedObjectsClientMock.create(); + mockLogger = loggingSystemMock.createLogger(); + mockPackageService = new PackageServiceImpl(mockEsClient, mockSoClient, mockLogger); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('asScoped', () => { + describe.each(testKeys)('without required privileges', (testKey: string) => { + const unauthError = new FleetUnauthorizedError( + `User does not have adequate permissions to access Fleet packages.` + ); + + it(`rejects on ${testKey}`, async () => { + const { method, args } = getTest( + { packageClient: mockPackageService.asScoped(httpServerMock.createKibanaRequest()) }, + testKey + ); + await expect(method(...args)).rejects.toThrowError(unauthError); + }); + }); + + describe.each(testKeys)('with required privileges', (testKey: string) => { + it(`calls ${testKey} and returns results`, async () => { + const mockClients = { + packageClient: mockPackageService.asInternalUser, + esClient: mockEsClient, + soClient: mockSoClient, + logger: mockLogger, + }; + const { method, args, spy, spyArgs, spyResponse } = getTest(mockClients, testKey); + spy.mockResolvedValue(spyResponse); + + await expect(method(...args)).resolves.toEqual(spyResponse); + expect(spy).toHaveBeenCalledWith(...spyArgs); + }); + }); + }); + + describe.each(testKeys)('asInternalUser', (testKey: string) => { + it(`calls ${testKey} and returns results`, async () => { + const mockClients = { + packageClient: mockPackageService.asInternalUser, + esClient: mockEsClient, + soClient: mockSoClient, + logger: mockLogger, + }; + const { method, args, spy, spyArgs, spyResponse } = getTest(mockClients, testKey); + spy.mockResolvedValue(spyResponse); + + await expect(method(...args)).resolves.toEqual(spyResponse); + expect(spy).toHaveBeenCalledWith(...spyArgs); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/package_service.ts b/x-pack/plugins/fleet/server/services/epm/package_service.ts new file mode 100644 index 00000000000000..0d9b8cb74b5036 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/package_service.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable max-classes-per-file */ + +import type { + KibanaRequest, + ElasticsearchClient, + SavedObjectsClientContract, + Logger, +} from 'kibana/server'; + +import type { + EsAssetReference, + InstallablePackage, + Installation, + RegistryPackage, + RegistrySearchResult, +} from '../../types'; +import { checkSuperuser } from '../../routes/security'; +import { FleetUnauthorizedError } from '../../errors'; + +import { installTransform, isTransform } from './elasticsearch/transform/install'; +import { fetchFindLatestPackage, getRegistryPackage } from './registry'; +import { ensureInstalledPackage, getInstallation } from './packages'; + +export type InstalledAssetType = EsAssetReference; + +export interface PackageService { + asScoped(request: KibanaRequest): PackageClient; + asInternalUser: PackageClient; +} + +export interface PackageClient { + getInstallation(pkgName: string): Promise; + + ensureInstalledPackage(options: { + pkgName: string; + pkgVersion?: string; + spaceId?: string; + }): Promise; + + fetchFindLatestPackage(packageName: string): Promise; + + getRegistryPackage( + packageName: string, + packageVersion: string + ): Promise<{ packageInfo: RegistryPackage; paths: string[] }>; + + reinstallEsAssets( + packageInfo: InstallablePackage, + assetPaths: string[] + ): Promise; +} + +export class PackageServiceImpl implements PackageService { + constructor( + private readonly internalEsClient: ElasticsearchClient, + private readonly internalSoClient: SavedObjectsClientContract, + private readonly logger: Logger + ) {} + + public asScoped(request: KibanaRequest) { + const preflightCheck = () => { + if (!checkSuperuser(request)) { + throw new FleetUnauthorizedError( + `User does not have adequate permissions to access Fleet packages.` + ); + } + }; + + return new PackageClientImpl( + this.internalEsClient, + this.internalSoClient, + this.logger, + preflightCheck + ); + } + + public get asInternalUser() { + return new PackageClientImpl(this.internalEsClient, this.internalSoClient, this.logger); + } +} + +class PackageClientImpl implements PackageClient { + constructor( + private readonly internalEsClient: ElasticsearchClient, + private readonly internalSoClient: SavedObjectsClientContract, + private readonly logger: Logger, + private readonly preflightCheck?: () => void | Promise + ) {} + + public async getInstallation(pkgName: string) { + await this.#runPreflight(); + return getInstallation({ + pkgName, + savedObjectsClient: this.internalSoClient, + }); + } + + public async ensureInstalledPackage(options: { + pkgName: string; + pkgVersion?: string; + spaceId?: string; + }): Promise { + await this.#runPreflight(); + return ensureInstalledPackage({ + ...options, + esClient: this.internalEsClient, + savedObjectsClient: this.internalSoClient, + }); + } + + public async fetchFindLatestPackage(packageName: string) { + await this.#runPreflight(); + return fetchFindLatestPackage(packageName); + } + + public async getRegistryPackage(packageName: string, packageVersion: string) { + await this.#runPreflight(); + return getRegistryPackage(packageName, packageVersion); + } + + public async reinstallEsAssets( + packageInfo: InstallablePackage, + assetPaths: string[] + ): Promise { + await this.#runPreflight(); + let installedAssets: InstalledAssetType[] = []; + + const transformPaths = assetPaths.filter(isTransform); + + if (transformPaths.length !== assetPaths.length) { + throw new Error('reinstallEsAssets is currently only implemented for transform assets'); + } + + if (transformPaths.length) { + const installedTransformAssets = await this.#reinstallTransforms(packageInfo, transformPaths); + installedAssets = [...installedAssets, ...installedTransformAssets]; + } + + return installedAssets; + } + + #reinstallTransforms(packageInfo: InstallablePackage, paths: string[]) { + return installTransform( + packageInfo, + paths, + this.internalEsClient, + this.internalSoClient, + this.logger + ); + } + + #runPreflight() { + if (this.preflightCheck) { + return this.preflightCheck(); + } + } +} diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index 7e615f923b2213..41b7d6c4e6ac81 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -9,7 +9,6 @@ import type { SavedObjectsClientContract } from 'kibana/server'; import type { agentPolicyService } from './agent_policy'; import * as settingsService from './settings'; -import type { getInstallation, ensureInstalledPackage } from './epm/packages'; export { ESIndexPatternSavedObjectService } from './es_index_pattern'; export { getRegistryUrl } from './epm/registry/registry_url'; @@ -29,11 +28,6 @@ export interface ESIndexPatternService { * Service that provides exported function that return information about EPM packages */ -export interface PackageService { - getInstallation: typeof getInstallation; - ensureInstalledPackage: typeof ensureInstalledPackage; -} - export interface AgentPolicyServiceInterface { get: typeof agentPolicyService['get']; list: typeof agentPolicyService['list']; @@ -61,3 +55,7 @@ export * from './artifacts'; // Policy preconfiguration functions export { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; + +// Package Services +export { PackageServiceImpl } from './epm'; +export type { PackageService, PackageClient } from './epm'; diff --git a/x-pack/plugins/osquery/server/routes/status/create_status_route.ts b/x-pack/plugins/osquery/server/routes/status/create_status_route.ts index ae79ef851bed97..871e112abc6c2b 100644 --- a/x-pack/plugins/osquery/server/routes/status/create_status_route.ts +++ b/x-pack/plugins/osquery/server/routes/status/create_status_route.ts @@ -31,14 +31,11 @@ export const createStatusRoute = (router: IRouter, osqueryContext: OsqueryAppCon const internalSavedObjectsClient = await getInternalSavedObjectsClient( osqueryContext.getStartServices ); - const packageService = osqueryContext.service.getPackageService(); + const packageService = osqueryContext.service.getPackageService()?.asInternalUser; const packagePolicyService = osqueryContext.service.getPackagePolicyService(); const agentPolicyService = osqueryContext.service.getAgentPolicyService(); - const packageInfo = await osqueryContext.service.getPackageService()?.getInstallation({ - savedObjectsClient: internalSavedObjectsClient, - pkgName: OSQUERY_INTEGRATION_NAME, - }); + const packageInfo = await packageService?.getInstallation(OSQUERY_INTEGRATION_NAME); if (packageInfo?.install_version && satisfies(packageInfo?.install_version, '<0.6.0')) { try { @@ -102,8 +99,6 @@ export const createStatusRoute = (router: IRouter, osqueryContext: OsqueryAppCon ); await packageService?.ensureInstalledPackage({ - esClient, - savedObjectsClient: internalSavedObjectsClient, pkgName: OSQUERY_INTEGRATION_NAME, }); diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 95a1f92ea94cd0..b697b54994adcc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -11,12 +11,13 @@ import { listMock } from '../../../lists/server/mocks'; import { securityMock } from '../../../security/server/mocks'; import { alertsMock } from '../../../alerting/server/mocks'; import { xpackMocks } from '../fixtures'; -import { FleetStartContract, ExternalCallback, PackageService } from '../../../fleet/server'; +import { FleetStartContract, ExternalCallback } from '../../../fleet/server'; import { createPackagePolicyServiceMock, createMockAgentPolicyService, createMockAgentService, createArtifactsClientMock, + createMockPackageService, } from '../../../fleet/server/mocks'; import { createMockConfig, requestContextMock } from '../lib/detection_engine/routes/__mocks__'; import { @@ -135,17 +136,6 @@ export const createMockEndpointAppContextServiceStartContract = }; }; -/** - * Create mock PackageService - */ - -export const createMockPackageService = (): jest.Mocked => { - return { - getInstallation: jest.fn(), - ensureInstalledPackage: jest.fn(), - }; -}; - /** * Creates a mock IndexPatternService for use in tests that need to interact with the Fleet's * ESIndexPatternService. @@ -168,7 +158,6 @@ export const createMockFleetStartContract = (indexPattern: string): FleetStartCo registerExternalCallback: jest.fn((...args: ExternalCallback) => {}), packagePolicyService: createPackagePolicyServiceMock(), createArtifactsClient: jest.fn().mockReturnValue(createArtifactsClientMock()), - fetchFindLatestPackage: jest.fn().mockReturnValue('8.0.0'), }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 06b85b4d08c8a6..559ecf0a621d64 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -20,7 +20,6 @@ import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, - createMockPackageService, createRouteHandlerContext, } from '../../mocks'; import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; @@ -49,6 +48,8 @@ import { legacyMetadataSearchResponseMock } from '../metadata/support/test_suppo import { AGENT_ACTIONS_INDEX, ElasticsearchAssetType } from '../../../../../fleet/common'; import { CasesClientMock } from '../../../../../cases/server/client/mocks'; import { EndpointAuthz } from '../../../../common/endpoint/types/authz'; +import type { PackageClient } from '../../../../../fleet/server'; +import { createMockPackageService } from '../../../../../fleet/server/mocks'; interface CallRouteInterface { body?: HostIsolationRequestBody; @@ -135,31 +136,29 @@ describe('Host Isolation', () => { endpointAppContextService = new EndpointAppContextService(); const mockSavedObjectClient = savedObjectsClientMock.create(); const mockPackageService = createMockPackageService(); - mockPackageService.getInstallation.mockReturnValue( - Promise.resolve({ - installed_kibana: [], - package_assets: [], - es_index_patterns: {}, - name: '', - version: '', - install_status: 'installed', - install_version: '', - install_started_at: '', - install_source: 'registry', - installed_es: [ - { - dupa: true, - id: 'logs-endpoint.events.security', - type: ElasticsearchAssetType.indexTemplate, - }, - { - id: `${metadataTransformPrefix}-0.16.0-dev.0`, - type: ElasticsearchAssetType.transform, - }, - ], - keep_policies_up_to_date: false, - }) - ); + const mockedPackageClient = mockPackageService.asInternalUser as jest.Mocked; + mockedPackageClient.getInstallation.mockResolvedValue({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + keep_policies_up_to_date: false, + }); licenseEmitter = new Subject(); licenseService = new LicenseService(); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 54bd4532fc0645..ee5a79b1aefff1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -24,7 +24,6 @@ import { registerEndpointRoutes } from './index'; import { createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, - createMockPackageService, createRouteHandlerContext, } from '../../mocks'; import { @@ -38,7 +37,7 @@ import { legacyMetadataSearchResponseMock, unitedMetadataSearchResponseMock, } from './support/test_support'; -import { AgentClient, PackageService } from '../../../../../fleet/server/services'; +import type { AgentClient, PackageService, PackageClient } from '../../../../../fleet/server'; import { HOST_METADATA_GET_ROUTE, HOST_METADATA_LIST_ROUTE, @@ -57,7 +56,7 @@ import { } from '../../../../../../../src/core/server/elasticsearch/client/mocks'; import { EndpointHostNotFoundError } from '../../services/metadata'; import { FleetAgentGenerator } from '../../../../common/endpoint/data_generators/fleet_agent_generator'; -import { createMockAgentClient } from '../../../../../fleet/server/mocks'; +import { createMockAgentClient, createMockPackageService } from '../../../../../fleet/server/mocks'; import { TransformGetTransformStatsResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz'; @@ -76,7 +75,7 @@ describe('test endpoint routes', () => { let mockClusterClient: ClusterClientMock; let mockScopedClient: ScopedClusterClientMock; let mockSavedObjectClient: jest.Mocked; - let mockPackageService: jest.Mocked; + let mockPackageService: PackageService; // eslint-disable-next-line @typescript-eslint/no-explicit-any let routeHandler: RequestHandler; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -121,30 +120,29 @@ describe('test endpoint routes', () => { endpointAppContextService = new EndpointAppContextService(); mockPackageService = createMockPackageService(); - mockPackageService.getInstallation.mockReturnValue( - Promise.resolve({ - installed_kibana: [], - package_assets: [], - es_index_patterns: {}, - name: '', - version: '', - install_status: 'installed', - install_version: '', - install_started_at: '', - install_source: 'registry', - installed_es: [ - { - id: 'logs-endpoint.events.security', - type: ElasticsearchAssetType.indexTemplate, - }, - { - id: `${metadataTransformPrefix}-0.16.0-dev.0`, - type: ElasticsearchAssetType.transform, - }, - ], - keep_policies_up_to_date: false, - }) - ); + const mockPackageClient = mockPackageService.asInternalUser as jest.Mocked; + mockPackageClient.getInstallation.mockResolvedValue({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + keep_policies_up_to_date: false, + }); endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); mockAgentService = startContract.agentService!; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/endpoint_fleet_services.ts b/x-pack/plugins/security_solution/server/endpoint/services/endpoint_fleet_services.ts index 0c26582f920b1a..915070a9b064fd 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/endpoint_fleet_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/endpoint_fleet_services.ts @@ -11,7 +11,7 @@ import type { AgentPolicyServiceInterface, FleetStartContract, PackagePolicyServiceInterface, - PackageService, + PackageClient, } from '../../../../fleet/server'; export interface EndpointFleetServicesFactoryInterface { @@ -33,13 +33,13 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor agentPolicyService: agentPolicy, packagePolicyService: packagePolicy, agentService, - packageService: packages, + packageService, } = this.fleetDependencies; return { agent: agentService.asScoped(req), agentPolicy, - packages, + packages: packageService.asScoped(req), packagePolicy, asInternal: this.asInternalUser.bind(this), @@ -51,13 +51,13 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor agentPolicyService: agentPolicy, packagePolicyService: packagePolicy, agentService, - packageService: packages, + packageService, } = this.fleetDependencies; return { agent: agentService.asInternalUser, agentPolicy, - packages, + packages: packageService.asInternalUser, packagePolicy, asScoped: this.asScoped.bind(this), @@ -71,7 +71,7 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor export interface EndpointFleetServicesInterface { agent: AgentClient; agentPolicy: AgentPolicyServiceInterface; - packages: PackageService; + packages: PackageClient; packagePolicy: PackagePolicyServiceInterface; } diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts index d9aa0c664defaa..82a901192b0eeb 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts @@ -7,12 +7,7 @@ /* eslint-disable max-classes-per-file */ -import { - CoreStart, - KibanaRequest, - Logger, - SavedObjectsClient, -} from '../../../../../../src/core/server'; +import { KibanaRequest, Logger } from '../../../../../../src/core/server'; import { ConcreteTaskInstance, TaskManagerSetupContract, @@ -62,7 +57,7 @@ export class SyntheticsService { this.esHosts = getEsHosts({ config: this.config, cloud: server.cloud }); } - public init(coreStart: CoreStart) { + public init() { // TODO: Figure out fake kibana requests to handle API keys on start up // getAPIKeyForSyntheticsService({ server: this.server }).then((apiKey) => { // if (apiKey) { @@ -70,20 +65,11 @@ export class SyntheticsService { // } // }); - this.setupIndexTemplates(coreStart); + this.setupIndexTemplates(); } - private setupIndexTemplates(coreStart: CoreStart) { - const esClient = coreStart.elasticsearch.client.asInternalUser; - const savedObjectsClient = new SavedObjectsClient( - coreStart.savedObjects.createInternalRepository() - ); - - installSyntheticsIndexTemplates({ - esClient, - server: this.server, - savedObjectsClient, - }).then( + private setupIndexTemplates() { + installSyntheticsIndexTemplates(this.server).then( (result) => { if (result.name === 'synthetics' && result.install_status === 'installed') { this.logger.info('Installed synthetics index templates'); diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index eedb9385d44d8c..692607041ea807 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -112,7 +112,7 @@ export class Plugin implements PluginType { } if (this.server?.config?.unsafe?.service.enabled) { - this.syntheticService?.init(coreStart); + this.syntheticService?.init(); this.syntheticService?.scheduleSyncTask(plugins.taskManager); if (this.server && this.syntheticService) { this.server.syntheticsService = this.syntheticService; diff --git a/x-pack/plugins/uptime/server/rest_api/synthetics_service/install_index_templates.ts b/x-pack/plugins/uptime/server/rest_api/synthetics_service/install_index_templates.ts index 185e526d148feb..5c70729fc76202 100644 --- a/x-pack/plugins/uptime/server/rest_api/synthetics_service/install_index_templates.ts +++ b/x-pack/plugins/uptime/server/rest_api/synthetics_service/install_index_templates.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; + import { UMRestApiRouteFactory } from '../types'; import { API_URLS } from '../../../common/constants'; import { UptimeServerSetup } from '../../lib/adapters'; @@ -13,31 +13,23 @@ export const installIndexTemplatesRoute: UMRestApiRouteFactory = () => ({ method: 'GET', path: API_URLS.INDEX_TEMPLATES, validate: {}, - handler: async ({ server, request, savedObjectsClient, uptimeEsClient }): Promise => { - return installSyntheticsIndexTemplates({ - server, - savedObjectsClient, - esClient: uptimeEsClient.baseESClient, - }); + handler: async ({ server }): Promise => { + return installSyntheticsIndexTemplates(server); }, }); -export async function installSyntheticsIndexTemplates({ - esClient, - server, - savedObjectsClient, -}: { - server: UptimeServerSetup; - esClient: ElasticsearchClient; - savedObjectsClient: SavedObjectsClientContract; -}) { +export async function installSyntheticsIndexTemplates(server: UptimeServerSetup) { // no need to add error handling here since fleetSetupCompleted is already wrapped in try/catch and will log // warning if setup fails to complete await server.fleet.fleetSetupCompleted(); - return await server.fleet.packageService.ensureInstalledPackage({ - esClient, - savedObjectsClient, + const installation = await server.fleet.packageService.asInternalUser.ensureInstalledPackage({ pkgName: 'synthetics', }); + + if (!installation) { + return Promise.reject('No package installation found.'); + } + + return installation; } From 617c0b4dd224497ef8ebd3b7b54a56134c27ae49 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Thu, 13 Jan 2022 15:37:48 +0100 Subject: [PATCH 27/29] [Upgrade Assistant] Skip reindexing api integration tests (#122772) * Skip tests * commit using @elastic.co --- .../upgrade_assistant/reindexing.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js index af2393d7b00d1f..6a326840bc5510 100644 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js @@ -34,7 +34,7 @@ export default function ({ getService }) { return lastState; }; - describe('reindexing', () => { + describe.skip('reindexing', () => { afterEach(() => { // Cleanup saved objects return es.deleteByQuery({ From 934c720ed134693b975608d203d13c4109ad18d2 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 13 Jan 2022 16:54:30 +0200 Subject: [PATCH 28/29] [Cases][Lens] Disable triggers for lens embeddables (#122912) --- .../public/components/markdown_editor/plugins/lens/processor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx index d0e816a06c7df8..9e39cc5cb82185 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/processor.tsx @@ -49,6 +49,7 @@ const LensMarkDownRendererComponent: React.FC = ({ timeRange={timeRange} attributes={attributes} renderMode="view" + disableTriggers /> From 0da915629834acb848f5ffe1cebfce43dc045df3 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Thu, 13 Jan 2022 07:55:55 -0700 Subject: [PATCH 29/29] [Security Solution] Fixes alerts table `Full screen` button overlap (#122901) ## [Security Solution] Fixes alerts table `Full screen` button overlap This PR fixes an issue reported in where the alerts table's `Full screen` button, recently [moved to the right side of `EuiDataGrid`](https://github.com/elastic/eui/pull/5334) in [EUI 43.0.0](https://elastic.github.io/eui/#/package/changelog), overlapped the existing view selector. ### Details In the `8.0` release of the Security Solution, the alerts table `Full screen` button appears above the table on the **left**, per the screenshot below: ![8_0_alerts_table](https://user-images.githubusercontent.com/4459398/149236219-9aac04de-4bbb-4cef-8705-f6bb712fb19e.png) _Above: The alerts table `Full screen` button in `8.0`_ Starting with `8.1` (via [EUI 43.0.0](https://elastic.github.io/eui/#/package/changelog)), `EuiDataGrid`'s `Full screen` button has been [moved to the right side of `EuiDataGrid`](https://github.com/elastic/eui/pull/5334), per the screenshot below: ![data_grid_before_after](https://user-images.githubusercontent.com/4459398/149237831-61aa7a30-695e-48d8-b016-89a0738d4bd9.png) _Above: `EuiDataGrid`'s full screen icon has moved from left to right_ The new location of the `Full screen` button overlapped the existing alerts table view selector, per the `Before` screenshot below: #### Before ![overlapped image](https://user-images.githubusercontent.com/60252716/148024399-24106303-baef-46bf-ad03-c4b53d78bbe8.png) _Above: Overlapping icons reported in _ This PR fixes the overlap, per the `After` screenshots below: #### After Chrome `97.0.4692.71`: ![after_chrome](https://user-images.githubusercontent.com/4459398/149239990-1039d659-67a9-4d09-a910-3f8bdfd179e4.png) Firefox `96.0`: ![after_firefox](https://user-images.githubusercontent.com/4459398/149239483-590108d8-b6db-4c87-a3e7-579fc33e98a5.png) Safari `15.2`: ![after_safari](https://user-images.githubusercontent.com/4459398/149239764-1751b89c-125b-44b8-b9b2-984b630e3925.png) --- .../components/t_grid/integrated/index.test.tsx | 14 ++++++++++++++ .../public/components/t_grid/integrated/index.tsx | 8 +++++++- .../timelines/public/components/t_grid/styles.tsx | 5 ++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.test.tsx index e0c221c95e6a71..0dc8ff58d2ef1a 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { euiDarkVars } from '@kbn/ui-shared-deps-src/theme'; import React from 'react'; import { render, screen } from '@testing-library/react'; import { TGridIntegrated, TGridIntegratedProps } from './index'; @@ -64,4 +65,17 @@ describe('integrated t_grid', () => { ); expect(screen.queryByTestId(dataTestSubj)).not.toBeNull(); }); + + it(`prevents view selection from overlapping EuiDataGrid's 'Full screen' button`, () => { + render( + + + + ); + + expect(screen.queryByTestId('updated-flex-group')).toHaveStyleRule( + `margin-right`, + euiDarkVars.paddingSizes.xl + ); + }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index 7cdee1748ef4b5..1abef58feeadea 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -327,7 +327,13 @@ const TGridIntegratedComponent: React.FC = ({ data-timeline-id={id} data-test-subj={`events-container-loading-${loading}`} > - + diff --git a/x-pack/plugins/timelines/public/components/t_grid/styles.tsx b/x-pack/plugins/timelines/public/components/t_grid/styles.tsx index ceb19837c434d7..7f4767e45fec7f 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/styles.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/styles.tsx @@ -10,6 +10,7 @@ import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-g import { rgba } from 'polished'; import styled, { createGlobalStyle } from 'styled-components'; import type { TimelineEventsType } from '../../../common/types/timeline'; +import type { ViewSelection } from './event_rendered_view/selector'; import { ACTIONS_COLUMN_ARIA_COL_INDEX } from './helpers'; import { EVENTS_TABLE_ARIA_LABEL } from './translations'; @@ -466,7 +467,9 @@ export const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible?: boolean }>` display: ${({ $visible = true }) => ($visible ? 'flex' : 'none')}; `; -export const UpdatedFlexGroup = styled(EuiFlexGroup)` +export const UpdatedFlexGroup = styled(EuiFlexGroup)<{ $view?: ViewSelection }>` + ${({ $view, theme }) => + $view === 'gridView' ? `margin-right: ${theme.eui.paddingSizes.xl};` : ''} position: absolute; z-index: ${({ theme }) => theme.eui.euiZLevel1}; right: 0px;