From 52787e937903ea30f055486634b1ab8487f74556 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 8 Mar 2021 15:10:16 -0600 Subject: [PATCH 01/12] [Security Solution][Detections]Alerts migrations can be finalized/cleaned up in all spaces (#93809) * Retrieve SOs by ID in a space-aware manner by using bulkGet We were previously using a manual invocation of find(), which was a) tied to the current implementation of how SOs generate their _ids, and b) didn't respect spaces. By replacing this with a call to bulkGet, which automatically respects the space of the current request, and which abstracts away the building of the actual _id based on the SO ID and type, we address the issues above. * Surface SO errors to the finalize/delete APIs Now that we're using bulkGet, we receive an object with errors if the object is not found, which by default breaks our subsequent validation. In order to provider better UX, we re-raise the first of these errors that we find, if present, and return that to the user. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../get_migration_saved_objects_by_id.test.ts | 65 +++++++++++++++++++ .../get_migration_saved_objects_by_id.ts | 46 +++++++++---- .../migrations/saved_objects_client.mock.ts | 1 + .../migrations/saved_objects_client.ts | 12 ++++ .../migrations/saved_objects_schema.mock.ts | 13 ++++ .../migrations/saved_objects_schema.ts | 19 ++++-- .../tests/delete_signals_migrations.ts | 13 ++++ .../tests/finalize_signals_migrations.ts | 9 ++- 8 files changed, 157 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.test.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.test.ts new file mode 100644 index 0000000000000..7e678b308e1f9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { + getSignalsMigrationSavedObjectMock, + getSignalsMigrationSavedObjectErrorMock, +} from './saved_objects_schema.mock'; +import { getMigrationSavedObjectsById } from './get_migration_saved_objects_by_id'; + +describe('getMigrationSavedObjectsById', () => { + let ids: string[]; + let soClient: ReturnType; + + beforeEach(() => { + ids = ['id1']; + soClient = savedObjectsClientMock.create(); + }); + + it('resolves an array of objects, if valid', async () => { + // @ts-expect-error stubbing our SO call + soClient.bulkGet.mockResolvedValue({ saved_objects: [getSignalsMigrationSavedObjectMock()] }); + const result = await getMigrationSavedObjectsById({ + ids, + soClient, + }); + + expect(result).toEqual([getSignalsMigrationSavedObjectMock()]); + }); + + it('rejects if SO client throws', () => { + const error = new Error('whoops'); + soClient.bulkGet.mockRejectedValue(error); + + return expect(getMigrationSavedObjectsById({ ids, soClient })).rejects.toThrow(error); + }); + + it('throws a 404 error if the response includes a 404', async () => { + soClient.bulkGet.mockResolvedValue({ + saved_objects: [ + // @ts-expect-error stubbing our SO call + getSignalsMigrationSavedObjectErrorMock({ statusCode: 404, message: 'not found' }), + ], + }); + + return expect(getMigrationSavedObjectsById({ ids, soClient })).rejects.toThrow( + expect.objectContaining({ statusCode: 404 }) + ); + }); + + it('rejects if response is invalid', () => { + // @ts-expect-error intentionally breaking the type + const badSavedObject = getSignalsMigrationSavedObjectMock({ destinationIndex: 4 }); + // @ts-expect-error stubbing our SO call + soClient.bulkGet.mockResolvedValue({ saved_objects: [badSavedObject] }); + + return expect(() => getMigrationSavedObjectsById({ ids, soClient })).rejects.toThrow( + 'Invalid value "4" supplied to "attributes,destinationIndex"' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts index c0010e4e357d3..7d66dca2ef192 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts @@ -5,10 +5,21 @@ * 2.0. */ +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + import { SavedObjectsClientContract } from 'src/core/server'; -import { findMigrationSavedObjects } from './find_migration_saved_objects'; -import { signalsMigrationType } from './saved_objects'; -import { SignalsMigrationSO } from './saved_objects_schema'; +import { validateEither } from '../../../../common/validate'; +import { signalsMigrationSOClient } from './saved_objects_client'; +import { SignalsMigrationSO, signalsMigrationSOs } from './saved_objects_schema'; + +class MigrationResponseError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} /** * Retrieves a list of migrations SOs by their ID @@ -26,13 +37,22 @@ export const getMigrationSavedObjectsById = async ({ }: { ids: string[]; soClient: SavedObjectsClientContract; -}): Promise => - findMigrationSavedObjects({ - soClient, - options: { - search: ids.map((id) => `${signalsMigrationType}:${id}`).join(' OR '), - rootSearchFields: ['_id'], - sortField: 'updated', - sortOrder: 'desc', - }, - }); +}): Promise => { + const client = signalsMigrationSOClient(soClient); + const objects = ids.map((id) => ({ id })); + const { saved_objects: migrations } = await client.bulkGet(objects); + const error = migrations.find((migration) => migration.error)?.error; + + if (error) { + throw new MigrationResponseError(error.message, error.statusCode); + } + + return pipe( + migrations, + (ms) => validateEither(signalsMigrationSOs, ms), + fold( + (e) => Promise.reject(e), + (a) => Promise.resolve(a) + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts index a8bc20ec69302..ee15775c5cc24 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts @@ -9,6 +9,7 @@ import { SignalsMigrationSOClient } from './saved_objects_client'; const create = () => ({ + bulkGet: jest.fn(), create: jest.fn(), delete: jest.fn(), find: jest.fn(), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts index 2d2cbb8f80948..61f35af8cabc8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts @@ -11,11 +11,18 @@ import { SavedObjectsUpdateResponse, SavedObjectsFindOptions, SavedObjectsFindResponse, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, + SavedObjectsBaseOptions, } from 'src/core/server'; import { signalsMigrationType } from './saved_objects'; import { SignalsMigrationSOAttributes } from './saved_objects_schema'; export interface SignalsMigrationSOClient { + bulkGet: ( + objects: Array>, + options?: SavedObjectsBaseOptions + ) => Promise>; find: ( options?: Omit ) => Promise>; @@ -32,6 +39,11 @@ export interface SignalsMigrationSOClient { export const signalsMigrationSOClient = ( savedObjectsClient: SavedObjectsClientContract ): SignalsMigrationSOClient => ({ + bulkGet: (objects, options) => + savedObjectsClient.bulkGet( + objects.map((o) => ({ ...o, type: signalsMigrationType })), + options + ), find: (options) => savedObjectsClient.find({ ...options, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts index b7a2a5a218de4..9c6eea8df84ac 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts @@ -27,3 +27,16 @@ export const getSignalsMigrationSavedObjectMock = ( ...overrides, }, }); + +export const getSignalsMigrationSavedObjectErrorMock = ( + overrides: Partial = {} +): SignalsMigrationSO => + ({ + id: 'dne-migration', + error: { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [security-solution-signals-migration/dne-migration] not found', + ...overrides, + }, + } as SignalsMigrationSO); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts index d1fe9d34165a8..3ce798ec5a5c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts @@ -28,6 +28,12 @@ const signalsMigrationSOGeneratedAttributes = { updatedBy: t.string, }; +const signalsMigrationSOError = { + statusCode: t.number, + error: t.string, + message: t.string, +}; + /** The attributes necessary to create a Signals Migration Saved Object */ @@ -59,11 +65,14 @@ export const signalsMigrationSOAttributes = t.exact( ); export type SignalsMigrationSOAttributes = t.TypeOf; -export const signalsMigrationSO = t.type({ - id: t.string, - attributes: signalsMigrationSOAttributes, - type: t.string, -}); +export const signalsMigrationSO = t.intersection([ + t.type({ + id: t.string, + attributes: signalsMigrationSOAttributes, + type: t.string, + }), + t.partial({ error: t.type(signalsMigrationSOError) }), +]); export type SignalsMigrationSO = t.TypeOf; export const signalsMigrationSOs = t.array(signalsMigrationSO); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts index bba6ce1125c37..d54b4525459e8 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts @@ -102,6 +102,19 @@ export default ({ getService }: FtrProviderContext): void => { ); }); + it('returns a 404 trying to delete a migration that does not exist', async () => { + const { body } = await supertest + .delete(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: ['dne-migration'] }) + .expect(404); + + expect(body).to.eql({ + message: 'Saved object [security-solution-signals-migration/dne-migration] not found', + status_code: 404, + }); + }); + it('rejects the request if the user does not have sufficient privileges', async () => { await createUserAndRole(getService, ROLES.t1_analyst); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts index 0fd05904d5e33..0aac596cc3adb 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts @@ -221,14 +221,17 @@ export default ({ getService }: FtrProviderContext): void => { expect(indicesAfter).not.to.contain(createdMigration.index); }); - it('returns an empty array indicating a no-op for DNE migrations', async () => { + it('returns a 404 for DNE migrations', async () => { const { body } = await supertest .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) .set('kbn-xsrf', 'true') .send({ migration_ids: ['dne-migration'] }) - .expect(200); + .expect(404); - expect(body).to.eql({ migrations: [] }); + expect(body).to.eql({ + message: 'Saved object [security-solution-signals-migration/dne-migration] not found', + status_code: 404, + }); }); it('rejects the request if the user does not have sufficient privileges', async () => { From 7507d6417ea6cc89da236af38fe86c8982cad6e6 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 8 Mar 2021 14:15:38 -0700 Subject: [PATCH 02/12] Convert file_upload components to typescript (#93814) * Convert file_upload components to typescript * i18n cleanup * api doc updates * fix i18n files * tslint after merge Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- api_docs/file_upload.json | 6 +- .../geojson_file_picker.tsx | 29 ++- .../geojson_upload_form.tsx | 227 ++++++++++++++++++ .../components/geojson_upload_form/index.ts | 9 + .../components/import_complete_view.tsx | 14 +- .../public/components/index_settings.js | 201 ---------------- ...and_parse.js => json_upload_and_parse.tsx} | 160 +++++------- .../geojson_importer/geojson_importer.ts | 4 +- .../file_upload/public/importer/importer.ts | 2 +- .../file_upload/public/importer/types.ts | 2 +- .../public/lazy_load_bundle/lazy/index.ts | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 13 files changed, 328 insertions(+), 329 deletions(-) rename x-pack/plugins/file_upload/public/components/{ => geojson_upload_form}/geojson_file_picker.tsx (89%) create mode 100644 x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx create mode 100644 x-pack/plugins/file_upload/public/components/geojson_upload_form/index.ts delete mode 100644 x-pack/plugins/file_upload/public/components/index_settings.js rename x-pack/plugins/file_upload/public/components/{json_upload_and_parse.js => json_upload_and_parse.tsx} (59%) diff --git a/api_docs/file_upload.json b/api_docs/file_upload.json index 17c15d184a6ba..350bd1aa067f3 100644 --- a/api_docs/file_upload.json +++ b/api_docs/file_upload.json @@ -372,7 +372,7 @@ "type": "Function", "label": "import", "signature": [ - "(id: string, index: string, pipelineId: string, setImportProgress: (progress: number) => void) => Promise<", + "(id: string, index: string, pipelineId: string | undefined, setImportProgress: (progress: number) => void) => Promise<", { "pluginId": "fileUpload", "scope": "public", @@ -413,9 +413,9 @@ { "type": "string", "label": "pipelineId", - "isRequired": true, + "isRequired": false, "signature": [ - "string" + "string | undefined" ], "description": [], "source": { diff --git a/x-pack/plugins/file_upload/public/components/geojson_file_picker.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx similarity index 89% rename from x-pack/plugins/file_upload/public/components/geojson_file_picker.tsx rename to x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx index 724a23a43202f..2f31bc47b899c 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_file_picker.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_file_picker.tsx @@ -8,23 +8,22 @@ import React, { Component } from 'react'; import { EuiFilePicker, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { MB } from '../../common'; -import { getMaxBytesFormatted } from '../get_max_bytes'; -import { validateFile } from '../importer'; -import { GeoJsonImporter, GeoJsonPreview, GEOJSON_FILE_TYPES } from '../importer/geojson_importer'; +import { MB } from '../../../common'; +import { getMaxBytesFormatted } from '../../get_max_bytes'; +import { validateFile } from '../../importer'; +import { + GeoJsonImporter, + GeoJsonPreview, + GEOJSON_FILE_TYPES, +} from '../../importer/geojson_importer'; + +export type OnFileSelectParameters = GeoJsonPreview & { + indexName: string; + importer: GeoJsonImporter; +}; interface Props { - onSelect: ({ - features, - hasPoints, - hasShapes, - importer, - indexName, - previewCoverage, - }: GeoJsonPreview & { - indexName: string; - importer: GeoJsonImporter; - }) => void; + onSelect: (onFileSelectParameters: OnFileSelectParameters) => void; onClear: () => void; } diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx new file mode 100644 index 0000000000000..7ac0685e57700 --- /dev/null +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx @@ -0,0 +1,227 @@ +/* + * 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, { ChangeEvent, Component } from 'react'; +import { EuiForm, EuiFormRow, EuiFieldText, EuiSelect, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { GeoJsonFilePicker, OnFileSelectParameters } from './geojson_file_picker'; +import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; +import { + getExistingIndexNames, + getExistingIndexPatternNames, + checkIndexPatternValid, + // @ts-expect-error +} from '../../util/indexing_service'; + +const GEO_FIELD_TYPE_OPTIONS = [ + { + text: ES_FIELD_TYPES.GEO_POINT, + value: ES_FIELD_TYPES.GEO_POINT, + }, + { + text: ES_FIELD_TYPES.GEO_SHAPE, + value: ES_FIELD_TYPES.GEO_SHAPE, + }, +]; + +interface Props { + geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE; + indexName: string; + indexNameError?: string; + onFileClear: () => void; + onFileSelect: (onFileSelectParameters: OnFileSelectParameters) => void; + onGeoFieldTypeSelect: (geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE) => void; + onIndexNameChange: (name: string, error?: string) => void; +} + +interface State { + hasFile: boolean; + isPointsOnly: boolean; + indexNames: string[]; +} + +export class GeoJsonUploadForm extends Component { + private _isMounted = false; + + state: State = { + hasFile: false, + isPointsOnly: false, + indexNames: [], + }; + + async componentDidMount() { + this._isMounted = true; + this._loadIndexNames(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + _loadIndexNames = async () => { + const indexNameList = await getExistingIndexNames(); + const indexPatternList = await getExistingIndexPatternNames(); + if (this._isMounted) { + this.setState({ + indexNames: [...indexNameList, ...indexPatternList], + }); + } + }; + + _onFileSelect = (onFileSelectParameters: OnFileSelectParameters) => { + this.setState({ + hasFile: true, + isPointsOnly: onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes, + }); + + this.props.onFileSelect(onFileSelectParameters); + + this._onIndexNameChange(onFileSelectParameters.indexName); + + const geoFieldType = + onFileSelectParameters.hasPoints && !onFileSelectParameters.hasShapes + ? ES_FIELD_TYPES.GEO_POINT + : ES_FIELD_TYPES.GEO_SHAPE; + this.props.onGeoFieldTypeSelect(geoFieldType); + }; + + _onFileClear = () => { + this.setState({ + hasFile: false, + isPointsOnly: false, + }); + + this.props.onFileClear(); + + this._onIndexNameChange(''); + }; + + _onGeoFieldTypeSelect = (event: ChangeEvent) => { + return this.props.onGeoFieldTypeSelect( + event.target.value as ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE + ); + }; + + _onIndexNameChange = (name: string) => { + let error: string | undefined; + if (this.state.indexNames.includes(name)) { + error = i18n.translate('xpack.fileUpload.indexSettings.indexNameAlreadyExistsErrorMessage', { + defaultMessage: 'Index name already exists.', + }); + } else if (!checkIndexPatternValid(name)) { + error = i18n.translate( + 'xpack.fileUpload.indexSettings.indexNameContainsIllegalCharactersErrorMessage', + { + defaultMessage: 'Index name contains illegal characters.', + } + ); + } + + this.props.onIndexNameChange(name, error); + }; + + _onIndexNameChangeEvent = (event: ChangeEvent) => { + this._onIndexNameChange(event.target.value); + }; + + _renderGeoFieldTypeSelect() { + return this.state.hasFile && this.state.isPointsOnly ? ( + + + + ) : null; + } + + _renderIndexNameInput() { + const isInvalid = this.props.indexNameError !== undefined; + return this.state.hasFile ? ( + <> + + + + + +
    +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex', { + defaultMessage: 'Must be a new index', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.lowercaseOnly', { + defaultMessage: 'Lowercase only', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotInclude', { + defaultMessage: + 'Cannot include \\\\, /, *, ?, ", <, >, |, \ + " " (space character), , (comma), #', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotStartWith', { + defaultMessage: 'Cannot start with -, _, +', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotBe', { + defaultMessage: 'Cannot be . or ..', + })} +
  • +
  • + {i18n.translate('xpack.fileUpload.indexSettings.guidelines.length', { + defaultMessage: + 'Cannot be longer than 255 bytes (note it is bytes, \ + so multi-byte characters will count towards the 255 \ + limit faster)', + })} +
  • +
+
+ + ) : null; + } + + render() { + return ( + + + {this._renderGeoFieldTypeSelect()} + {this._renderIndexNameInput()} + + ); + } +} diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index.ts b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index.ts new file mode 100644 index 0000000000000..6168835a3a145 --- /dev/null +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { GeoJsonUploadForm } from './geojson_upload_form'; +export { OnFileSelectParameters } from './geojson_file_picker'; diff --git a/x-pack/plugins/file_upload/public/components/import_complete_view.tsx b/x-pack/plugins/file_upload/public/components/import_complete_view.tsx index c6209ae765c72..29aed0cd52f7e 100644 --- a/x-pack/plugins/file_upload/public/components/import_complete_view.tsx +++ b/x-pack/plugins/file_upload/public/components/import_complete_view.tsx @@ -20,14 +20,14 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { CodeEditor, KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { getHttp, getUiSettings } from '../kibana_services'; -import { ImportResponse } from '../../common'; +import { ImportResults } from '../importer'; const services = { uiSettings: getUiSettings(), }; interface Props { - importResp?: ImportResponse; + importResults?: ImportResults; indexPatternResp?: object; } @@ -90,7 +90,7 @@ export class ImportCompleteView extends Component { } _getStatusMsg() { - if (!this.props.importResp || !this.props.importResp.success) { + if (!this.props.importResults || !this.props.importResults.success) { return i18n.translate('xpack.fileUpload.uploadFailureMsg', { defaultMessage: 'File upload failed.', }); @@ -99,15 +99,15 @@ export class ImportCompleteView extends Component { const successMsg = i18n.translate('xpack.fileUpload.uploadSuccessMsg', { defaultMessage: 'File upload complete: indexed {numFeatures} features.', values: { - numFeatures: this.props.importResp.docCount, + numFeatures: this.props.importResults.docCount, }, }); - const failedFeaturesMsg = this.props.importResp.failures.length + const failedFeaturesMsg = this.props.importResults.failures?.length ? i18n.translate('xpack.fileUpload.failedFeaturesMsg', { defaultMessage: 'Unable to index {numFailures} features.', values: { - numFailures: this.props.importResp.failures.length, + numFailures: this.props.importResults.failures.length, }, }) : ''; @@ -122,7 +122,7 @@ export class ImportCompleteView extends Component {

{this._getStatusMsg()}

{this._renderCodeEditor( - this.props.importResp, + this.props.importResults, i18n.translate('xpack.fileUpload.jsonImport.indexingResponse', { defaultMessage: 'Import response', }), diff --git a/x-pack/plugins/file_upload/public/components/index_settings.js b/x-pack/plugins/file_upload/public/components/index_settings.js deleted file mode 100644 index 7d7ab3516e651..0000000000000 --- a/x-pack/plugins/file_upload/public/components/index_settings.js +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment, Component } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiFieldText, EuiSelect, EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - getExistingIndexNames, - getExistingIndexPatternNames, - checkIndexPatternValid, -} from '../util/indexing_service'; - -export class IndexSettings extends Component { - state = { - indexNameError: '', - indexDisabled: true, - indexName: '', - indexNameList: [], - indexPatternList: [], - }; - - async componentDidMount() { - this._isMounted = true; - this.loadExistingIndexData(); - } - - componentWillUnmount() { - this._isMounted = false; - } - - loadExistingIndexData = async () => { - const indexNameList = await getExistingIndexNames(); - const indexPatternList = await getExistingIndexPatternNames(); - if (this._isMounted) { - this.setState({ - indexNameList, - indexPatternList, - }); - } - }; - - componentDidUpdate(prevProps, prevState) { - const { indexNameError, indexName } = this.state; - if (prevState.indexNameError !== indexNameError) { - this.props.setHasIndexErrors(!!indexNameError); - } - const { disabled, indexTypes } = this.props; - const indexDisabled = disabled || !indexTypes || !indexTypes.length; - if (indexDisabled !== this.state.indexDisabled) { - this.setState({ indexDisabled }); - } - if (this.props.indexName !== indexName) { - this._setIndexName(this.props.indexName); - } - } - - _setIndexName = async (name) => { - const errorMessage = await this._isIndexNameAndPatternValid(name); - return this.setState({ - indexName: name, - indexNameError: errorMessage, - }); - }; - - _onIndexChange = async ({ target }) => { - const name = target.value; - await this._setIndexName(name); - this.props.setIndexName(name); - }; - - _isIndexNameAndPatternValid = async (name) => { - const { indexNameList, indexPatternList } = this.state; - const nameAlreadyInUse = [...indexNameList, ...indexPatternList].includes(name); - if (nameAlreadyInUse) { - return ( - - ); - } - - const indexPatternValid = checkIndexPatternValid(name); - if (!indexPatternValid) { - return ( - - ); - } - return ''; - }; - - render() { - const { setSelectedIndexType, indexTypes } = this.props; - const { indexNameError, indexDisabled, indexName } = this.state; - - return ( - - - } - > - ({ - text: indexType, - value: indexType, - }))} - value={this.props.selectedIndexType} - onChange={({ target }) => setSelectedIndexType(target.value)} - /> - - - } - isInvalid={indexNameError !== ''} - error={[indexNameError]} - > - - - {indexDisabled ? null : ( - - - -
    -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.mustBeNewIndex', { - defaultMessage: 'Must be a new index', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.lowercaseOnly', { - defaultMessage: 'Lowercase only', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotInclude', { - defaultMessage: - 'Cannot include \\\\, /, *, ?, ", <, >, |, \ - " " (space character), , (comma), #', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotStartWith', { - defaultMessage: 'Cannot start with -, _, +', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.cannotBe', { - defaultMessage: 'Cannot be . or ..', - })} -
  • -
  • - {i18n.translate('xpack.fileUpload.indexSettings.guidelines.length', { - defaultMessage: - 'Cannot be longer than 255 bytes (note it is bytes, \ - so multi-byte characters will count towards the 255 \ - limit faster)', - })} -
  • -
-
-
- )} -
- ); - } -} diff --git a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.js b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx similarity index 59% rename from x-pack/plugins/file_upload/public/components/json_upload_and_parse.js rename to x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx index ae9f5e9ac8154..fc00dba6ddf55 100644 --- a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.js +++ b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx @@ -7,41 +7,48 @@ import React, { Component, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiForm, EuiProgress, EuiText } from '@elastic/eui'; -import PropTypes from 'prop-types'; -import { IndexSettings } from './index_settings'; +import { EuiProgress, EuiText } from '@elastic/eui'; import { getIndexPatternService } from '../kibana_services'; -import { GeoJsonFilePicker } from './geojson_file_picker'; +import { GeoJsonUploadForm, OnFileSelectParameters } from './geojson_upload_form'; import { ImportCompleteView } from './import_complete_view'; import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; +import { FileUploadComponentProps } from '../lazy_load_bundle'; +import { ImportResults } from '../importer'; +import { GeoJsonImporter } from '../importer/geojson_importer'; +import { IngestPipeline, Settings } from '../../common'; -const PHASE = { - CONFIGURE: 'CONFIGURE', - IMPORT: 'IMPORT', - COMPLETE: 'COMPLETE', -}; +enum PHASE { + CONFIGURE = 'CONFIGURE', + IMPORT = 'IMPORT', + COMPLETE = 'COMPLETE', +} -function getWritingToIndexMsg(progress) { +function getWritingToIndexMsg(progress: number) { return i18n.translate('xpack.fileUpload.jsonUploadAndParse.writingToIndex', { defaultMessage: 'Writing to index: {progress}% complete', values: { progress }, }); } -export class JsonUploadAndParse extends Component { - state = { - // Index state - indexTypes: [], - selectedIndexType: '', - indexName: '', - hasIndexErrors: false, - isIndexReady: false, +interface State { + geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE; + importStatus: string; + importResults?: ImportResults; + indexName: string; + indexNameError?: string; + indexPatternResp?: object; + phase: PHASE; +} - // Progress-tracking state +export class JsonUploadAndParse extends Component { + private _geojsonImporter?: GeoJsonImporter; + private _isMounted = false; + + state: State = { + geoFieldType: ES_FIELD_TYPES.GEO_SHAPE, importStatus: '', + indexName: '', phase: PHASE.CONFIGURE, - importResp: undefined, - indexPatternResp: undefined, }; componentDidMount() { @@ -52,45 +59,35 @@ export class JsonUploadAndParse extends Component { this._isMounted = false; if (this._geojsonImporter) { this._geojsonImporter.destroy(); - this._geojsonImporter = null; + this._geojsonImporter = undefined; } } componentDidUpdate() { - this._setIndexReady(); if (this.props.isIndexingTriggered && this.state.phase === PHASE.CONFIGURE) { this._import(); } } - _setIndexReady = () => { - const isIndexReady = - this._geojsonImporter !== undefined && - !!this.state.selectedIndexType && - !!this.state.indexName && - !this.state.hasIndexErrors && - this.state.phase === PHASE.CONFIGURE; - if (isIndexReady !== this.state.isIndexReady) { - this.setState({ isIndexReady }); - this.props.onIndexReady(isIndexReady); + _import = async () => { + if (!this._geojsonImporter) { + return; } - }; - _import = async () => { // // create index // - const settings = { + const settings = ({ number_of_shards: 1, - }; + } as unknown) as Settings; const mappings = { properties: { coordinates: { - type: this.state.selectedIndexType, + type: this.state.geoFieldType, }, }, }; - const ingestPipeline = {}; + const ingestPipeline = ({} as unknown) as IngestPipeline; this.setState({ importStatus: i18n.translate('xpack.fileUpload.jsonUploadAndParse.dataIndexingStarted', { defaultMessage: 'Creating index: {indexName}', @@ -98,7 +95,7 @@ export class JsonUploadAndParse extends Component { }), phase: PHASE.IMPORT, }); - this._geojsonImporter.setGeoFieldType(this.state.selectedIndexType); + this._geojsonImporter.setGeoFieldType(this.state.geoFieldType); const initializeImportResp = await this._geojsonImporter.initializeImport( this.state.indexName, settings, @@ -122,7 +119,7 @@ export class JsonUploadAndParse extends Component { this.setState({ importStatus: getWritingToIndexMsg(0), }); - const importResp = await this._geojsonImporter.import( + const importResults = await this._geojsonImporter.import( initializeImportResp.id, this.state.indexName, initializeImportResp.pipelineId, @@ -138,9 +135,9 @@ export class JsonUploadAndParse extends Component { return; } - if (!importResp.success) { + if (!importResults.success) { this.setState({ - importResp, + importResults, importStatus: i18n.translate('xpack.fileUpload.jsonUploadAndParse.dataIndexingError', { defaultMessage: 'Data indexing error', }), @@ -154,7 +151,7 @@ export class JsonUploadAndParse extends Component { // create index pattern // this.setState({ - importResp, + importResults, importStatus: i18n.translate('xpack.fileUpload.jsonUploadAndParse.creatingIndexPattern', { defaultMessage: 'Creating index pattern: {indexName}', values: { indexName: this.state.indexName }, @@ -197,35 +194,14 @@ export class JsonUploadAndParse extends Component { importStatus: '', }); this.props.onIndexingComplete({ - indexDataResp: importResp, + indexDataResp: importResults, indexPattern, }); }; - _onFileSelect = ({ features, hasPoints, hasShapes, importer, indexName, previewCoverage }) => { + _onFileSelect = ({ features, importer, indexName, previewCoverage }: OnFileSelectParameters) => { this._geojsonImporter = importer; - const geoFieldTypes = hasPoints - ? [ES_FIELD_TYPES.GEO_POINT, ES_FIELD_TYPES.GEO_SHAPE] - : [ES_FIELD_TYPES.GEO_SHAPE]; - - const newState = { - indexTypes: geoFieldTypes, - indexName, - }; - if (!this.state.selectedIndexType) { - // auto select index type - newState.selectedIndexType = - hasPoints && !hasShapes ? ES_FIELD_TYPES.GEO_POINT : ES_FIELD_TYPES.GEO_SHAPE; - } else if ( - this.state.selectedIndexType && - !geoFieldTypes.includes(this.state.selectedIndexType) - ) { - // unselected indexType if selected type is not longer an option - newState.selectedIndexType = ''; - } - this.setState(newState); - this.props.onFileUpload( { type: 'FeatureCollection', @@ -243,12 +219,20 @@ export class JsonUploadAndParse extends Component { } this.props.onFileRemove(); + }; + _onGeoFieldTypeSelect = (geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE) => { + this.setState({ geoFieldType }); + }; + + _onIndexNameChange = (name: string, error?: string) => { this.setState({ - indexTypes: [], - selectedIndexType: '', - indexName: '', + indexName: name, + indexNameError: error, }); + + const isReadyToImport = !!name && error === undefined; + this.props.onIndexReady(isReadyToImport); }; render() { @@ -266,38 +250,22 @@ export class JsonUploadAndParse extends Component { if (this.state.phase === PHASE.COMPLETE) { return ( ); } return ( - - - this.setState({ indexName })} - indexTypes={this.state.indexTypes} - selectedIndexType={this.state.selectedIndexType} - setSelectedIndexType={(selectedIndexType) => this.setState({ selectedIndexType })} - setHasIndexErrors={(hasIndexErrors) => this.setState({ hasIndexErrors })} - /> - + ); } } - -JsonUploadAndParse.defaultProps = { - isIndexingTriggered: false, -}; - -JsonUploadAndParse.propTypes = { - isIndexingTriggered: PropTypes.bool, - onIndexReadyStatusChange: PropTypes.func, - onIndexingComplete: PropTypes.func, - onIndexingError: PropTypes.func, - onFileUpload: PropTypes.func, - onFileRemove: PropTypes.func, -}; diff --git a/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts b/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts index f0ad083cab34e..d28b049e1214d 100644 --- a/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts +++ b/x-pack/plugins/file_upload/public/importer/geojson_importer/geojson_importer.ts @@ -77,7 +77,7 @@ export class GeoJsonImporter extends Importer { public async import( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, setImportProgress: (progress: number) => void ): Promise { if (!id || !index) { @@ -163,7 +163,7 @@ export class GeoJsonImporter extends Importer { private async _importBlock( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, chunks: ImportDoc[][], blockSizeInBytes: number, setImportProgress: (progress: number) => void diff --git a/x-pack/plugins/file_upload/public/importer/importer.ts b/x-pack/plugins/file_upload/public/importer/importer.ts index a9844b1176baf..4a87d67d0616b 100644 --- a/x-pack/plugins/file_upload/public/importer/importer.ts +++ b/x-pack/plugins/file_upload/public/importer/importer.ts @@ -85,7 +85,7 @@ export abstract class Importer implements IImporter { public async import( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, setImportProgress: (progress: number) => void ): Promise { if (!id || !index) { diff --git a/x-pack/plugins/file_upload/public/importer/types.ts b/x-pack/plugins/file_upload/public/importer/types.ts index e99cdf380216d..7300b7cacfc7f 100644 --- a/x-pack/plugins/file_upload/public/importer/types.ts +++ b/x-pack/plugins/file_upload/public/importer/types.ts @@ -51,7 +51,7 @@ export interface IImporter { import( id: string, index: string, - pipelineId: string, + pipelineId: string | undefined, setImportProgress: (progress: number) => void ): Promise; } diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts index 36df353f65d8c..0a28e9e4dfc93 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/lazy/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -// @ts-expect-error export { JsonUploadAndParse } from '../../components/json_upload_and_parse'; export { importerFactory } from '../../importer'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 22ea4ebd079a8..411ce3043cc8e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8194,7 +8194,6 @@ "xpack.features.ossFeatures.visualizeShortUrlSubFeatureName": "短い URL", "xpack.features.savedObjectsManagementFeatureName": "保存されたオブジェクトの管理", "xpack.features.visualizeFeatureName": "Visualizeライブラリ", - "xpack.fileUpload.enterIndexName": "インデックス名を入力", "xpack.fileUpload.httpService.fetchError": "フェッチ実行エラー:{error}", "xpack.fileUpload.httpService.noUrl": "URLが指定されていません", "xpack.fileUpload.indexNameReqField": "インデックス名、必須フィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 587bedb1183ab..b6ffdcfc8ea72 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8264,7 +8264,6 @@ "xpack.features.ossFeatures.visualizeShortUrlSubFeatureName": "短 URL", "xpack.features.savedObjectsManagementFeatureName": "已保存对象管理", "xpack.features.visualizeFeatureName": "Visualize 库", - "xpack.fileUpload.enterIndexName": "输入索引名称", "xpack.fileUpload.httpService.fetchError": "执行提取时出错:{error}", "xpack.fileUpload.httpService.noUrl": "未提供 URL", "xpack.fileUpload.indexNameReqField": "索引名称,必填字段", From 5e634e993127ed400e078966b2abf34370602d2b Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 8 Mar 2021 14:20:16 -0700 Subject: [PATCH 03/12] remove unused xpack.ml.alertContext.kibanaBasePathUrlDescription i18n key --- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 2 files changed, 2 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 411ce3043cc8e..cd34dbab83961 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13046,7 +13046,6 @@ "xpack.ml.alertContext.anomalyExplorerUrlDescription": "異常エクスプローラーを開くURL", "xpack.ml.alertContext.isInterimDescription": "上位の一致に中間結果が含まれるかどうかを示します", "xpack.ml.alertContext.jobIdsDescription": "アラートインスタンスをトリガーしたジョブIDのリスト。", - "xpack.ml.alertContext.kibanaBasePathUrlDescription": "Kibanaベースパス", "xpack.ml.alertContext.scoreDescription": "異常スコア", "xpack.ml.alertContext.timestampDescription": "異常のタイムスタンプ", "xpack.ml.alertContext.timestampIso8601Description": "ISO8601形式の時刻", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b6ffdcfc8ea72..d8e2f7c3882bf 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13211,7 +13211,6 @@ "xpack.ml.alertContext.anomalyExplorerUrlDescription": "要在 Anomaly Explorer 中打开的 URL", "xpack.ml.alertContext.isInterimDescription": "表示排名靠前的命中是否包含中间结果", "xpack.ml.alertContext.jobIdsDescription": "触发告警实例的作业 ID 的列表", - "xpack.ml.alertContext.kibanaBasePathUrlDescription": "Kibana 基路径", "xpack.ml.alertContext.scoreDescription": "异常分数", "xpack.ml.alertContext.timestampDescription": "异常的时间戳", "xpack.ml.alertContext.timestampIso8601Description": "ISO8601 格式的时间", From 5d96e5f3340b792b606d47914815ec0065dbd086 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 8 Mar 2021 16:28:42 -0500 Subject: [PATCH 04/12] [SECURITY SOLUTION] BUG Manual Alert in case (#93726) * get the data where it belongs * Fix layout when alert is deleted Co-authored-by: Christos Nasikas --- .../components/user_action_tree/helpers.tsx | 4 +-- .../components/user_action_tree/index.tsx | 7 +++-- .../user_action_alert_comment_event.test.tsx | 27 +++---------------- .../user_action_alert_comment_event.tsx | 11 ++++---- 4 files changed, 14 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index 1a59dd06570ce..be32c1c5dd036 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -219,8 +219,8 @@ export const getAlertAttachment = ({ alertId: string; index: string; loadingAlertData: boolean; - ruleId: string; - ruleName: string; + ruleId?: string | null; + ruleName?: string | null; }): EuiCommentProps => { return { username: ( diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index cf68d07859ced..bc7961b75a30a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -379,11 +379,10 @@ export const UserActionTree = React.memo( return comments; } - const ruleId = comment?.rule?.id ?? manualAlertsData[alertId]?.rule?.id?.[0] ?? ''; + const ruleId = + comment?.rule?.id ?? manualAlertsData[alertId]?.signal?.rule?.id?.[0] ?? null; const ruleName = - comment?.rule?.name ?? - manualAlertsData[alertId]?.rule?.name?.[0] ?? - i18n.UNKNOWN_RULE; + comment?.rule?.name ?? manualAlertsData[alertId]?.signal?.rule?.name?.[0] ?? null; return [ ...comments, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx index 228945bacf8a4..f4f610d07e2ff 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx @@ -46,11 +46,10 @@ describe('UserActionAvatar ', () => { expect(wrapper.text()).toBe('added an alert from Awesome rule'); }); - it('does NOT render the link when the alert is undefined', async () => { + it('does NOT render the link when the rule is null', async () => { const wrapper = mount( - {/* @ts-expect-error */} - + ); @@ -58,27 +57,7 @@ describe('UserActionAvatar ', () => { wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() ).toBeFalsy(); - expect(wrapper.text()).toBe('added an alert from '); - }); - - it('does NOT render the link when the rule is undefined', async () => { - const alert = { - alertId: 'alert-id-1', - commentType: CommentType.alert, - }; - - const wrapper = mount( - - {/* @ts-expect-error*/} - - - ); - - expect( - wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() - ).toBeFalsy(); - - expect(wrapper.text()).toBe('added an alert from '); + expect(wrapper.text()).toBe('added an alert from Unknown rule'); }); it('navigate to app on link click', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index 2a604b7c54d6b..57c366d412660 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -6,6 +6,7 @@ */ import React, { memo, useCallback } from 'react'; +import { isEmpty } from 'lodash'; import { EuiText, EuiLoadingSpinner } from '@elastic/eui'; import { APP_ID } from '../../../../common/constants'; @@ -20,8 +21,8 @@ import { LinkAnchor } from '../../../common/components/links'; interface Props { alertId: string; commentType: CommentType; - ruleId: string; - ruleName: string; + ruleId?: string | null; + ruleName?: string | null; alertsCount?: number; loadingAlertData?: boolean; } @@ -51,16 +52,16 @@ const AlertCommentEventComponent: React.FC = ({ <> {`${i18n.ALERT_COMMENT_LABEL_TITLE} `} {loadingAlertData && } - {!loadingAlertData && ruleId !== '' && ( + {!loadingAlertData && !isEmpty(ruleId) && ( - {ruleName} + {ruleName ?? i18n.UNKNOWN_RULE} )} - {!loadingAlertData && ruleId === '' && {ruleName}} + {!loadingAlertData && isEmpty(ruleId) && i18n.UNKNOWN_RULE} ) : ( <> From 4db502d85cdca5810296b54fcd35deb8ca299bad Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Mon, 8 Mar 2021 16:53:36 -0500 Subject: [PATCH 05/12] [Security Solution][Detection Rules] Rules table page size increase (#93993) --- .../pages/detection_engine/rules/all/rules_tables.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 411e817c4407f..b8741197a7ab1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -219,7 +219,7 @@ export const RulesTables = React.memo( pageIndex: pagination.page - 1, pageSize: pagination.perPage, totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20, 50, 100, 200, 300, 400, 500], + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300, 400, 500, 600], }), [pagination] ); From 2d109df88615db8ed3ce87121b4e8976327abb1c Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Mon, 8 Mar 2021 15:54:59 -0600 Subject: [PATCH 06/12] Add runtime field functional test (#93710) Add runtime fields editor functional test --- .../edit_index_pattern/tabs/tabs.tsx | 2 +- .../apps/management/_runtime_fields.js | 52 +++++++++++++++++++ test/functional/apps/management/index.ts | 1 + test/functional/page_objects/settings_page.ts | 52 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 test/functional/apps/management/_runtime_fields.js diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx index 7771c5d54f415..4d99bd504cd0f 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/tabs/tabs.tsx @@ -178,7 +178,7 @@ export function Tabs({ /> - openFieldEditor()}> + openFieldEditor()} data-test-subj="addField"> {addFieldButtonLabel} diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js new file mode 100644 index 0000000000000..4b3533f20c8dc --- /dev/null +++ b/test/functional/apps/management/_runtime_fields.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const log = getService('log'); + const browser = getService('browser'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['settings']); + + describe('runtime fields', function () { + this.tags(['skipFirefox']); + + before(async function () { + await browser.setWindowSize(1200, 800); + await esArchiver.load('discover'); + // delete .kibana index and then wait for Kibana to re-create it + await kibanaServer.uiSettings.replace({}); + await kibanaServer.uiSettings.update({}); + }); + + after(async function afterAll() { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + await PageObjects.settings.removeLogstashIndexPatternIfExist(); + }); + + describe('create runtime field', function describeIndexTests() { + const fieldName = 'atest'; + + it('should create runtime field', async function () { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + await PageObjects.settings.clickIndexPatternLogstash(); + const startingCount = parseInt(await PageObjects.settings.getFieldsTabCount()); + await log.debug('add runtime field'); + await PageObjects.settings.addRuntimeField(fieldName, 'Keyword', "emit('hello world')"); + await retry.try(async function () { + expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1); + }); + }); + }); + }); +} diff --git a/test/functional/apps/management/index.ts b/test/functional/apps/management/index.ts index d31245b5492d1..fcb4e49dc7548 100644 --- a/test/functional/apps/management/index.ts +++ b/test/functional/apps/management/index.ts @@ -33,6 +33,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_mgmt_import_saved_objects')); loadTestFile(require.resolve('./_index_patterns_empty')); loadTestFile(require.resolve('./_scripted_fields')); + loadTestFile(require.resolve('./_runtime_fields')); }); describe('', function () { diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 0a1eaabdf49e8..4151a8c1a1893 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -491,6 +491,58 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await this.clickSaveScriptedField(); } + async addRuntimeField(name: string, type: string, script: string) { + await this.clickAddField(); + await this.setFieldName(name); + await this.setFieldType(type); + if (script) { + await this.setFieldScript(script); + } + await this.clickSaveField(); + await this.closeIndexPatternFieldEditor(); + } + + async closeIndexPatternFieldEditor() { + await retry.waitFor('field editor flyout to close', async () => { + return !(await testSubjects.exists('euiFlyoutCloseButton')); + }); + } + + async clickAddField() { + log.debug('click Add Field'); + await testSubjects.click('addField'); + } + + async clickSaveField() { + log.debug('click Save'); + await testSubjects.click('fieldSaveButton'); + } + + async setFieldName(name: string) { + log.debug('set field name = ' + name); + await testSubjects.setValue('nameField', name); + } + + async setFieldType(type: string) { + log.debug('set type = ' + type); + await testSubjects.setValue('typeField', type); + } + + async setFieldScript(script: string) { + log.debug('set script = ' + script); + const formatRow = await testSubjects.find('valueRow'); + const formatRowToggle = ( + await formatRow.findAllByCssSelector('[data-test-subj="toggle"]') + )[0]; + + await formatRowToggle.click(); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + browser.pressKeys(script); + } + async clickAddScriptedField() { log.debug('click Add Scripted Field'); await testSubjects.click('addScriptedFieldLink'); From 4c58e70a7e5e337e8f27972a0c46811b6023d416 Mon Sep 17 00:00:00 2001 From: Luca Belluccini Date: Mon, 8 Mar 2021 22:58:11 +0000 Subject: [PATCH 07/12] [DOC] Clarify compatibility of Saved Objects across versions (#63090) * [DOC] Clarify compatibility of Saved Objects across versions * Update docs/management/managing-saved-objects.asciidoc * Saved objects compatibility * clarify that same version export/import is compatible * Add compatibility to SO import API docs Co-authored-by: Rudolf Meijering --- docs/api/saved-objects/import.asciidoc | 12 ++++++++++++ docs/management/managing-saved-objects.asciidoc | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/api/saved-objects/import.asciidoc b/docs/api/saved-objects/import.asciidoc index 4df2f07bfcf41..b69180e42d05f 100644 --- a/docs/api/saved-objects/import.asciidoc +++ b/docs/api/saved-objects/import.asciidoc @@ -6,6 +6,18 @@ experimental[] Create sets of {kib} saved objects from a file created by the export API. +==== Compatibility across versions +Saved objects can only be imported into the same version, a newer minor on the same major or the next major. Exported saved objects are not backwards compatible and cannot be imported into an older version of {kib}. See the table below for compatibility examples: + +|======= +| Exporting version | Importing version | Compatible? +| 6.7.0 | 6.8.1 | Yes +| 6.8.1 | 7.3.0 | Yes +| 7.3.0 | 7.11.1 | Yes +| 7.11.1 | 7.6.0 | No +| 6.8.1 | 8.0.0 | No +|======= + [[saved-objects-api-import-request]] ==== Request diff --git a/docs/management/managing-saved-objects.asciidoc b/docs/management/managing-saved-objects.asciidoc index 9e26abca115fc..d37c6f1920bda 100644 --- a/docs/management/managing-saved-objects.asciidoc +++ b/docs/management/managing-saved-objects.asciidoc @@ -43,6 +43,21 @@ have multiple environments for development and production. Import and export also work well when you have a large number of objects to update and want to batch the process. +[float] +==== Compatibility across versions + +With each release, {kib} introduces changes to the way saved objects are stored. When importing a saved object, {kib} will run the necessary migrations to ensure that the imported saved objects are compatible with the current version. + +However, saved objects can only be imported into the same version, a newer minor on the same major or the next major. Exported saved objects are not backwards compatible and cannot be imported into an older version of {kib}. See the table below for compatibility examples: + +|======= +| Exporting version | Importing version | Compatible? +| 6.7.0 | 6.8.1 | Yes +| 6.8.1 | 7.3.0 | Yes +| 7.3.0 | 7.11.1 | Yes +| 7.11.1 | 7.6.0 | No +| 6.8.1 | 8.0.0 | No +|======= [float] ==== Import From a0e88ece91d64a4ffc24b27f8150a1debc725755 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 8 Mar 2021 17:27:17 -0600 Subject: [PATCH 08/12] [Enterprise Search] Fix bug where special chars breaks UI (#94000) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../shared/role_mapping/role_mappings_table.test.tsx | 9 +++++++++ .../shared/role_mapping/role_mappings_table.tsx | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx index 22498bbc50c21..9c47378302890 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.test.tsx @@ -63,6 +63,15 @@ describe('RoleMappingsTable', () => { expect(wrapper.find(EuiTableRow)).toHaveLength(0); }); + it('handles input change with special chars', () => { + const wrapper = shallow(); + const input = wrapper.find(EuiFieldSearch); + const value = '*//username'; + input.simulate('change', { target: { value } }); + + expect(wrapper.find(EuiTableRow)).toHaveLength(1); + }); + it('shows default message when "accessAllEngines" is true', () => { const wrapper = shallow( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index b6110d692bc99..ae4d65fdf3a45 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -83,8 +83,10 @@ export const RoleMappingsTable: React.FC = ({ }); const filterResults = (result: SharedRoleMapping) => { + // Filter out non-alphanumeric characters, except for underscores, hyphens, and spaces + const sanitizedValue = filterValue.replace(/[^\w\s-]/g, ''); const values = Object.values(result); - const regexp = new RegExp(filterValue, 'i'); + const regexp = new RegExp(sanitizedValue, 'i'); return values.filter((x) => regexp.test(x)).length > 0; }; From 29a162bee38233489bbad33e81df14338c510d9a Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 8 Mar 2021 23:51:32 +0000 Subject: [PATCH 09/12] chore(NA): ability to call bazel from yarn scripts (#93983) * chore(NA): ability to call ibazel from yarn scripts * chore(NA): remove ibazel from package.json Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 2c8d5cd395cd4..cd1eaa660292e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "author": "Rashid Khan ", "scripts": { + "bazel": "bazel", "preinstall": "node ./preinstall_check", "kbn": "node scripts/kbn", "es": "node scripts/es", From 9b99f6d3d1846a810133120b855719bfcba8da33 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Mon, 8 Mar 2021 19:03:48 -0500 Subject: [PATCH 10/12] [Security Solution] Prevent EuiFocusTrap from blocking focus to global search (#93377) * Prevent EuiFocusTrap from blocking focus to global search * Only run when flyout is open * Remove unused import * Fix broken cypress test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../timelines/fields_browser.spec.ts | 3 +- .../timelines/flyout_button.spec.ts | 11 ++++ .../timelines/components/flyout/index.tsx | 64 ++++++++++++++++--- 3 files changed, 67 insertions(+), 11 deletions(-) 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 43f3643606015..5d4bbdde5620e 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 @@ -16,6 +16,7 @@ import { FIELDS_BROWSER_SELECTED_CATEGORY_TITLE, FIELDS_BROWSER_SELECTED_CATEGORY_COUNT, FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT, + FIELDS_BROWSER_FILTER_INPUT, } from '../../screens/fields_browser'; import { TIMELINE_FIELDS_BUTTON } from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; @@ -194,7 +195,7 @@ describe('Fields Browser', () => { it('restores focus to the Customize Columns button when Esc is pressed', () => { openTimelineFieldsBrowser(); - cy.get('body').type('{esc}'); + cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{esc}'); cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts index c1fc46bc28683..25b790b57bc06 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts @@ -65,6 +65,17 @@ describe('timeline flyout button', () => { cy.get(CREATE_NEW_TIMELINE).should('not.be.visible'); }); + it('should render the global search dropdown when the input is focused', () => { + openTimelineUsingToggle(); + cy.get('[data-test-subj="nav-search-input"]').focus(); + cy.get('[data-test-subj="nav-search-input"]').should('be.focused'); + cy.get('[data-test-subj="nav-search-option"]').should('be.visible'); + cy.get('[data-test-subj="nav-search-option"]').first().trigger('mouseenter'); + // check that at least one item is visible in the search bar after mousing over, i.e. it's still usable. + cy.get('[data-test-subj="nav-search-option"]').its('length').should('be.gte', 1); + closeTimelineUsingCloseButton(); + }); + it('sets the data providers background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers area', () => { dragFirstHostToTimeline(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index bd7c7fbd1941f..2602ca3f3cc7c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -6,8 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import { EuiFocusTrap } from '@elastic/eui'; -import React, { useEffect, useMemo } from 'react'; +import { EuiFocusTrap, EuiOutsideClickDetector } from '@elastic/eui'; +import React, { useEffect, useMemo, useCallback, useState, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; @@ -30,6 +30,8 @@ interface OwnProps { onAppLeave: (handler: AppLeaveHandler) => void; } +type VoidFunc = () => void; + const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { const dispatch = useDispatch(); const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); @@ -37,6 +39,45 @@ const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { getTimelineShowStatus(state, timelineId) ); + const [focusOwnership, setFocusOwnership] = useState(true); + const [triggerOnBlur, setTriggerOnBlur] = useState(true); + const callbackRef = useRef(null); + const searchRef = useRef(null); + + const handleSearch = useCallback(() => { + if (show && focusOwnership === false) { + setFocusOwnership(true); + } + }, [show, focusOwnership]); + const onOutsideClick = useCallback((event) => { + setFocusOwnership(false); + const classes = event.target.classList; + if (classes.contains('kbnSearchBar')) { + searchRef.current = event.target; + setTriggerOnBlur((prev) => !prev); + window.setTimeout(() => { + if (searchRef.current !== null) { + searchRef.current.focus(); + } + }, 0); + } + }, []); + + useEffect(() => { + if (searchRef.current != null) { + if (callbackRef.current !== null) { + searchRef.current.removeEventListener('blur', callbackRef.current); + } + searchRef.current.addEventListener('blur', handleSearch); + callbackRef.current = handleSearch; + } + return () => { + if (searchRef.current != null && callbackRef.current !== null) { + searchRef.current.removeEventListener('blur', callbackRef.current); + } + }; + }, [handleSearch, triggerOnBlur]); + useEffect(() => { onAppLeave((actions, nextAppId) => { if (show) { @@ -78,15 +119,18 @@ const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { } }); }, [dispatch, onAppLeave, show, timelineStatus, updated]); + return ( - <> - - - - - - - + + <> + + + + + + + + ); }; From 0ce3dc7f10bf7bd912e0d1f2196d3e8ae892ae32 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Mon, 8 Mar 2021 18:09:42 -0600 Subject: [PATCH 11/12] [Fleet] Prevent duplicate enrollment token names (#92735) --- .../services/api_keys/enrollment_api_key.ts | 68 +++++++++++++++++-- x-pack/plugins/fleet/server/services/setup.ts | 1 + 2 files changed, 63 insertions(+), 6 deletions(-) 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 5cfa77c86766d..387d69c5c0ca7 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 @@ -7,11 +7,12 @@ import uuid from 'uuid'; import Boom from '@hapi/boom'; -import { GetResponse } from 'elasticsearch'; +import { i18n } from '@kbn/i18n'; +import type { GetResponse } from 'elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; -import { ESSearchResponse as SearchResponse } from '../../../../../typings/elasticsearch'; +import type { ESSearchResponse as SearchResponse } from '../../../../../typings/elasticsearch'; import type { EnrollmentAPIKey, FleetServerEnrollmentAPIKey } from '../../types'; import { ENROLLMENT_API_KEYS_INDEX } from '../../constants'; import { agentPolicyService } from '../agent_policy'; @@ -19,6 +20,8 @@ import { escapeSearchQueryPhrase } from '../saved_object'; import { createAPIKey, invalidateAPIKeys } from './security'; +const uuidRegex = /^\([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\)$/; + export async function listEnrollmentApiKeys( esClient: ElasticsearchClient, options: { @@ -126,15 +129,55 @@ export async function generateEnrollmentAPIKey( name?: string; expiration?: string; agentPolicyId?: string; + forceRecreate?: boolean; } ): Promise { const id = uuid.v4(); - const { name: providedKeyName } = data; + const { name: providedKeyName, forceRecreate } = data; if (data.agentPolicyId) { await validateAgentPolicyId(soClient, data.agentPolicyId); } const agentPolicyId = data.agentPolicyId ?? (await agentPolicyService.getDefaultAgentPolicyId(soClient)); + + if (providedKeyName && !forceRecreate) { + let hasMore = true; + let page = 1; + let keys: EnrollmentAPIKey[] = []; + while (hasMore) { + const { items } = await listEnrollmentApiKeys(esClient, { + page: page++, + perPage: 100, + kuery: `policy_id:"${agentPolicyId}" AND name:${providedKeyName.replace(/ /g, '\\ ')}*`, + }); + if (items.length === 0) { + hasMore = false; + } else { + keys = keys.concat(items); + } + } + + if ( + keys.length > 0 && + keys.some((k: EnrollmentAPIKey) => + // Prevent false positives when the providedKeyName is a prefix of a token name that already exists + // After removing the providedKeyName and trimming whitespace, the only string left should be a uuid in parens. + k.name?.replace(providedKeyName, '').trim().match(uuidRegex) + ) + ) { + throw new Error( + i18n.translate('xpack.fleet.serverError.enrollmentKeyDuplicate', { + defaultMessage: + 'An enrollment key named {providedKeyName} already exists for agent policy {agentPolicyId}', + values: { + providedKeyName, + agentPolicyId, + }, + }) + ); + } + } + const name = providedKeyName ? `${providedKeyName} (${id})` : id; const key = await createAPIKey(soClient, name, { // Useless role to avoid to have the privilege of the user that created the key @@ -151,7 +194,11 @@ export async function generateEnrollmentAPIKey( }); if (!key) { - throw new Error('Unable to create an enrollment api key'); + throw new Error( + i18n.translate('xpack.fleet.serverError.unableToCreateEnrollmentKey', { + defaultMessage: 'Unable to create an enrollment api key', + }) + ); } const apiKey = Buffer.from(`${key.id}:${key.api_key}`).toString('base64'); @@ -187,7 +234,11 @@ export async function getEnrollmentAPIKeyById(esClient: ElasticsearchClient, api const [enrollmentAPIKey] = res.body.hits.hits.map(esDocToEnrollmentApiKey); if (enrollmentAPIKey?.api_key_id !== apiKeyId) { - throw new Error('find enrollmentKeyById returned an incorrect key'); + throw new Error( + i18n.translate('xpack.fleet.serverError.returnedIncorrectKey', { + defaultMessage: 'find enrollmentKeyById returned an incorrect key', + }) + ); } return enrollmentAPIKey; @@ -198,7 +249,12 @@ async function validateAgentPolicyId(soClient: SavedObjectsClientContract, agent await agentPolicyService.get(soClient, agentPolicyId); } catch (e) { if (e.isBoom && e.output.statusCode === 404) { - throw Boom.badRequest(`Agent policy ${agentPolicyId} does not exist`); + throw Boom.badRequest( + i18n.translate('xpack.fleet.serverError.agentPolicyDoesNotExist', { + defaultMessage: 'Agent policy {agentPolicyId} does not exist', + values: { agentPolicyId }, + }) + ); } throw e; } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index d6bb04f5e572a..6f4ca6e231e9e 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -227,6 +227,7 @@ export async function setupFleet( return generateEnrollmentAPIKey(soClient, esClient, { name: `Default`, agentPolicyId: agentPolicy.id, + forceRecreate: true, // Always generate a new enrollment key when Fleet is being set up }); }) ); From add02f13e86d582a28e3bd3e7dd3d95128d15563 Mon Sep 17 00:00:00 2001 From: James Rucker Date: Mon, 8 Mar 2021 16:17:35 -0800 Subject: [PATCH 12/12] [Workplace Search] Persist OAuth token package during OAuth connect flow (#93210) * Store session data sent from Enterprise Search server This modifies the EnterpriseSearchRequestHandler to remove any data in a response under the _sessionData key and instead persist it on the server side. Ultimately, this data will be persisted in the login session, but for now we'll just store it in a cookie. https://github.com/elastic/kibana/issues/92558 Also uses this functionality to persist Workplace Search's OAuth token package. * Only return a modified response body if _sessionData was found The destructuring I'm doing to remove _sessionData from the response is breaking routes that currently expect an empty response body. This change just leaves those response bodies alone. * Refactor from initial feedback & add tests * Decrease levity * Changes from PR feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../enterprise_search/common/constants.ts | 2 + .../enterprise_search_request_handler.test.ts | 55 ++++++++++++++++++- .../lib/enterprise_search_request_handler.ts | 36 +++++++++++- .../get_oauth_token_package_params.test.ts | 55 +++++++++++++++++++ .../lib/get_oauth_token_package_params.ts | 39 +++++++++++++ .../routes/workplace_search/sources.test.ts | 14 +++++ .../server/routes/workplace_search/sources.ts | 11 +++- 7 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.ts diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 857f6037c53c8..5ccf7c99ba341 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -81,3 +81,5 @@ export const JSON_HEADER = { }; export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode'; + +export const ENTERPRISE_SEARCH_KIBANA_COOKIE = '_enterprise_search'; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 7199067a2c8f4..d6a891e3f6241 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -7,7 +7,11 @@ import { mockConfig, mockLogger } from '../__mocks__'; -import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; +import { + ENTERPRISE_SEARCH_KIBANA_COOKIE, + JSON_HEADER, + READ_ONLY_MODE_HEADER, +} from '../../common/constants'; import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; @@ -171,6 +175,28 @@ describe('EnterpriseSearchRequestHandler', () => { headers: mockExpectedResponseHeaders, }); }); + + it('filters out any _sessionData passed back from Enterprise Search', async () => { + const jsonWithSessionData = { + _sessionData: { + secrets: 'no peeking', + }, + regular: 'data', + }; + + EnterpriseSearchAPI.mockReturn(jsonWithSessionData, { headers: JSON_HEADER }); + + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/prep' }); + await makeAPICall(requestHandler); + + expect(responseMock.custom).toHaveBeenCalledWith({ + statusCode: 200, + body: { + regular: 'data', + }, + headers: mockExpectedResponseHeaders, + }); + }); }); }); @@ -378,6 +404,33 @@ describe('EnterpriseSearchRequestHandler', () => { }); }); + describe('setSessionData', () => { + it('sets the value of wsOAuthTokenPackage in a cookie', async () => { + const tokenPackage = 'some_encrypted_secrets'; + + const mockNow = 'Thu, 04 Mar 2021 22:40:32 GMT'; + const mockInAnHour = 'Thu, 04 Mar 2021 23:40:32 GMT'; + jest.spyOn(global.Date, 'now').mockImplementationOnce(() => { + return new Date(mockNow).valueOf(); + }); + + const sessionDataBody = { + _sessionData: { wsOAuthTokenPackage: tokenPackage }, + regular: 'data', + }; + + EnterpriseSearchAPI.mockReturn(sessionDataBody, { headers: JSON_HEADER }); + + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/' }); + await makeAPICall(requestHandler); + + expect(enterpriseSearchRequestHandler.headers).toEqual({ + ['set-cookie']: `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}; Path=/; Expires=${mockInAnHour}; SameSite=Lax; HttpOnly`, + ...mockExpectedResponseHeaders, + }); + }); + }); + it('isEmptyObj', async () => { expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true); expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index f47df58c4eca1..fb525740dd55b 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -16,7 +16,12 @@ import { Logger, } from 'src/core/server'; -import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; +import { + ENTERPRISE_SEARCH_KIBANA_COOKIE, + JSON_HEADER, + READ_ONLY_MODE_HEADER, +} from '../../common/constants'; + import { ConfigType } from '../index'; interface ConstructorDependencies { @@ -113,11 +118,17 @@ export class EnterpriseSearchRequestHandler { return this.handleInvalidDataError(response, url, json); } + // Intercept data that is meant for the server side session + const { _sessionData, ...responseJson } = json; + if (_sessionData) { + this.setSessionData(_sessionData); + } + // Pass successful responses back to the front-end return response.custom({ statusCode: status, headers: this.headers, - body: json, + body: _sessionData ? responseJson : json, }); } catch (e) { // Catch connection/auth errors @@ -270,6 +281,27 @@ export class EnterpriseSearchRequestHandler { this.headers[READ_ONLY_MODE_HEADER] = readOnlyMode as 'true' | 'false'; } + /** + * Extract Session Data + * + * In the future, this will set the keys passed back from Enterprise Search + * into the Kibana login session. + * For now we'll explicity look for the Workplace Search OAuth token package + * and stuff it into a cookie so it can be picked up later when we proxy the + * OAuth callback. + */ + setSessionData(sessionData: { [key: string]: string }) { + if (sessionData.wsOAuthTokenPackage) { + const anHourFromNow = new Date(Date.now()); + anHourFromNow.setHours(anHourFromNow.getHours() + 1); + + const cookiePayload = `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${sessionData.wsOAuthTokenPackage};`; + const cookieRestrictions = `Path=/; Expires=${anHourFromNow.toUTCString()}; SameSite=Lax; HttpOnly`; + + this.headers['set-cookie'] = `${cookiePayload} ${cookieRestrictions}`; + } + } + /** * Misc helpers */ diff --git a/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.test.ts b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.test.ts new file mode 100644 index 0000000000000..9b2df3ce1373a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../common/constants'; + +import { getOAuthTokenPackageParams } from './get_oauth_token_package_params'; + +describe('getOAuthTokenPackage', () => { + const tokenPackage = 'some_encrypted_secrets'; + const tokenPackageCookie = `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}`; + const tokenPackageParams = { token_package: tokenPackage }; + + describe('when there are no cookie headers', () => { + it('returns an empty parameter set', () => { + expect(getOAuthTokenPackageParams(undefined)).toEqual({}); + }); + }); + + describe('when there is a single cookie header', () => { + it('returns an empty parameter set when our cookie is not there', () => { + const cookieHeader = '_st_fruit=banana'; + + expect(getOAuthTokenPackageParams(cookieHeader)).toEqual({}); + }); + + it('returns the token package when our cookie is the only one', () => { + const cookieHeader = `${tokenPackageCookie}`; + + expect(getOAuthTokenPackageParams(cookieHeader)).toEqual(tokenPackageParams); + }); + + it('returns the token package when there are other cookies in the header', () => { + const cookieHeader = `_chocolate=chip; ${tokenPackageCookie}; _oatmeal=raisin`; + + expect(getOAuthTokenPackageParams(cookieHeader)).toEqual(tokenPackageParams); + }); + }); + + describe('when there are multiple cookie headers', () => { + it('returns an empty parameter set when none of them include our cookie', () => { + const cookieHeaders = ['_st_fruit=banana', '_sid=12345']; + + expect(getOAuthTokenPackageParams(cookieHeaders)).toEqual({}); + }); + + it('returns the token package when our cookie is present', () => { + const cookieHeaders = ['_st_fruit=banana', `_heat=spicy; ${tokenPackageCookie}`]; + + expect(getOAuthTokenPackageParams(cookieHeaders)).toEqual(tokenPackageParams); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.ts b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.ts new file mode 100644 index 0000000000000..d9db57717611d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/get_oauth_token_package_params.ts @@ -0,0 +1,39 @@ +/* + * 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 { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../common/constants'; + +export const getOAuthTokenPackageParams = (rawCookieHeader: string | string[] | undefined) => { + // In the future the token package will be stored in the login session. For now it's in a cookie. + + if (!rawCookieHeader) { + return {}; + } + + /** + * A request can have multiple cookie headers and each header can hold multiple cookies. + * Within a header, cookies are separated by '; '. Here we are splitting out the individual + * cookies from the header(s) and looking for the specific one that holds our token package. + */ + + const cookieHeaders = Array.isArray(rawCookieHeader) ? rawCookieHeader : [rawCookieHeader]; + + let tokenPackage: string | undefined; + + cookieHeaders + .flatMap((rawHeader) => rawHeader.split('; ')) + .forEach((rawCookie) => { + const [cookieName, cookieValue] = rawCookie.split('='); + if (cookieName === ENTERPRISE_SEARCH_KIBANA_COOKIE) tokenPackage = cookieValue; + }); + + if (tokenPackage) { + return { token_package: tokenPackage }; + } else { + return {}; + } +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index a2fbe759f1a11..1443a78854abf 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -7,6 +7,8 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; +import { ENTERPRISE_SEARCH_KIBANA_COOKIE } from '../../../common/constants'; + import { registerAccountSourcesRoute, registerAccountSourcesStatusRoute, @@ -1249,6 +1251,15 @@ describe('sources routes', () => { }); describe('GET /api/workplace_search/sources/create', () => { + const tokenPackage = 'some_encrypted_secrets'; + + const mockRequest = { + headers: { + authorization: 'BASIC 123', + cookie: `${ENTERPRISE_SEARCH_KIBANA_COOKIE}=${tokenPackage}`, + }, + }; + let mockRouter: MockRouter; beforeEach(() => { @@ -1265,8 +1276,11 @@ describe('sources routes', () => { }); it('creates a request handler', () => { + mockRouter.callRoute(mockRequest as any); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ path: '/ws/sources/create', + params: { token_package: tokenPackage }, }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index ab6eda64eace4..5c1ef8ed62982 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -7,6 +7,8 @@ import { schema } from '@kbn/config-schema'; +import { getOAuthTokenPackageParams } from '../../lib/get_oauth_token_package_params'; + import { RouteDependencies } from '../../plugin'; const schemaValuesSchema = schema.recordOf( @@ -862,9 +864,12 @@ export function registerOauthConnectorParamsRoute({ }), }, }, - enterpriseSearchRequestHandler.createRequest({ - path: '/ws/sources/create', - }) + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/sources/create', + params: getOAuthTokenPackageParams(request.headers.cookie), + })(context, request, response); + } ); }