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/package.json b/package.json index 79a2f47d25ba76..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", @@ -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 3580bcfbf65711..7c8259b2f6857e 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", @@ -119,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/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-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 e32834b3f2e8f9..ce2cbe714a16c8 100644 --- a/packages/kbn-ui-shared-deps-src/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-src/BUILD.bazel @@ -43,13 +43,13 @@ 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", "//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/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/check_origin_conflicts.test.mock.ts b/src/core/server/saved_objects/import/lib/check_origin_conflicts.test.mock.ts new file mode 100644 index 00000000000000..8fb5704af9d821 --- /dev/null +++ b/src/core/server/saved_objects/import/lib/check_origin_conflicts.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_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/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, 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/core/server/saved_objects/import/lib/__mocks__/index.ts b/src/plugins/kibana_utils/public/theme/index.ts similarity index 73% rename from src/core/server/saved_objects/import/lib/__mocks__/index.ts rename to src/plugins/kibana_utils/public/theme/index.ts index c53fc78c8e8870..165c5ef9195c2a 100644 --- a/src/core/server/saved_objects/import/lib/__mocks__/index.ts +++ b/src/plugins/kibana_utils/public/theme/index.ts @@ -6,9 +6,4 @@ * Side Public License, v 1. */ -const mockUuidv4 = jest.fn().mockReturnValue('uuidv4'); -jest.mock('uuid', () => ({ - v4: mockUuidv4, -})); - -export { mockUuidv4 }; +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/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'], + }, + }, +}; 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(), }); 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/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 /> 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; +} 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/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/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/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/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/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/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/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/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/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/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: ( 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/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/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/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..24db8258566273 100644 --- a/x-pack/plugins/reporting/tsconfig.json +++ b/x-pack/plugins/reporting/tsconfig.json @@ -6,28 +6,25 @@ "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" }, { "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" }, { "path": "../security/tsconfig.json" }, - { "path": "../spaces/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" } ] } 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(); +}; 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, {}); 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 }) => { 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/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/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'); }); }); 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/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/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/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/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/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/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/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/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(); }); }); 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/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'; 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/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/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/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/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/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/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'; 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); 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/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: { 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; 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/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.', }, }); } 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); } 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; } 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/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 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'); 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", + }, ] `); }); 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'); 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 { 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({ diff --git a/yarn.lock b/yarn.lock index 61d331ca5cb9f8..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" @@ -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==