From f1222e32acea584b2b9a2c711bdaf48152d3b5d3 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 23 Nov 2022 11:59:27 +0100 Subject: [PATCH 01/83] [Discover] Field stats should not ignore pinned filters (#145332) Closes https://github.com/elastic/kibana/issues/145242 ## Summary This PR fixes an issue with pinned filters being ignored when field stats are fetched (sidebar field popover and field stats table). ![Nov-16-2022 11-17-27](https://user-images.githubusercontent.com/1415710/202154489-bd90a5b5-c302-4e33-a85f-3fd338072a33.gif) --- .../field_stats_table/field_stats_tab.tsx | 28 +++++++++ .../components/field_stats_table/index.ts | 3 +- .../layout/discover_main_content.tsx | 8 +-- .../sidebar/discover_field.test.tsx | 4 ++ .../components/sidebar/discover_field.tsx | 33 +++-------- .../sidebar/discover_field_stats.tsx | 59 +++++++++++++++++++ .../discover_sidebar_responsive.test.tsx | 4 ++ .../components/field_stats/field_stats.tsx | 2 +- .../public/hooks/use_query_subscriber.ts | 54 +++++++++++++++++ .../unified_field_list/public/index.ts | 6 ++ .../apps/discover/group1/_sidebar.ts | 58 +++++++++++++++++- 11 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 src/plugins/discover/public/application/main/components/field_stats_table/field_stats_tab.tsx create mode 100644 src/plugins/discover/public/application/main/components/sidebar/discover_field_stats.tsx create mode 100644 src/plugins/unified_field_list/public/hooks/use_query_subscriber.ts diff --git a/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_tab.tsx b/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_tab.tsx new file mode 100644 index 0000000000000..87dcbb2297c4f --- /dev/null +++ b/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_tab.tsx @@ -0,0 +1,28 @@ +/* + * 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 React from 'react'; +import { useQuerySubscriber } from '@kbn/unified-field-list-plugin/public'; +import { FieldStatisticsTable, type FieldStatisticsTableProps } from './field_stats_table'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; + +export const FieldStatisticsTab: React.FC> = + React.memo((props) => { + const services = useDiscoverServices(); + const querySubscriberResult = useQuerySubscriber({ + data: services.data, + }); + + return ( + + ); + }); diff --git a/src/plugins/discover/public/application/main/components/field_stats_table/index.ts b/src/plugins/discover/public/application/main/components/field_stats_table/index.ts index b418366481fa0..937155f0e09d6 100644 --- a/src/plugins/discover/public/application/main/components/field_stats_table/index.ts +++ b/src/plugins/discover/public/application/main/components/field_stats_table/index.ts @@ -6,4 +6,5 @@ * Side Public License, v 1. */ -export { FieldStatisticsTable } from './field_stats_table'; +export { FieldStatisticsTable, type FieldStatisticsTableProps } from './field_stats_table'; +export { FieldStatisticsTab } from './field_stats_tab'; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx b/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx index 100412c8f7930..86428fff1ed91 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx @@ -20,13 +20,11 @@ import { DocumentViewModeToggle, VIEW_MODE } from '../../../../components/view_m import { DocViewFilterFn } from '../../../../services/doc_views/doc_views_types'; import { DataRefetch$, SavedSearchData } from '../../hooks/use_saved_search'; import { AppState, GetStateReturn } from '../../services/discover_state'; -import { FieldStatisticsTable } from '../field_stats_table'; +import { FieldStatisticsTab } from '../field_stats_table'; import { DiscoverDocuments } from './discover_documents'; import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stats_table/constants'; import { useDiscoverHistogram } from './use_discover_histogram'; -const FieldStatisticsTableMemoized = React.memo(FieldStatisticsTable); - export interface DiscoverMainContentProps { isPlainRecord: boolean; dataView: DataView; @@ -161,12 +159,10 @@ export const DiscoverMainContent = ({ onFieldEdited={!isPlainRecord ? onFieldEdited : undefined} /> ) : ( - ({ + query: { query: '', language: 'lucene' }, + filters: [], + }), }, }, dataViews: dataViewPluginMocks.createStartContract(), diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx index 03e742da3514f..5513483b2b03c 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx @@ -16,12 +16,12 @@ import classNames from 'classnames'; import { FieldButton, FieldIcon } from '@kbn/react-field'; import type { DataViewField, DataView } from '@kbn/data-views-plugin/public'; import { - FieldStats, FieldPopover, FieldPopoverHeader, FieldPopoverHeaderProps, FieldPopoverVisualize, } from '@kbn/unified-field-list-plugin/public'; +import { DiscoverFieldStats } from './discover_field_stats'; import { getTypeForFieldIcon } from '../../../../utils/get_type_for_field_icon'; import { DiscoverFieldDetails } from './discover_field_details'; import { FieldDetails } from './types'; @@ -29,7 +29,6 @@ import { getFieldTypeName } from '../../../../utils/get_field_type_name'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { SHOW_LEGACY_FIELD_TOP_VALUES, PLUGIN_ID } from '../../../../../common'; import { getUiActions } from '../../../../kibana_services'; -import { useAppStateSelector } from '../../services/discover_app_state_container'; function wrapOnDot(str?: string) { // u200B is a non-width white-space character, which allows @@ -284,11 +283,8 @@ function DiscoverFieldComponent({ contextualFields, }: DiscoverFieldProps) { const services = useDiscoverServices(); - const { data } = services; const [infoIsOpen, setOpen] = useState(false); const isDocumentRecord = !!onAddFilter; - const query = useAppStateSelector((state) => state.query); - const filters = useAppStateSelector((state) => state.filters); const addFilterAndClosePopover: typeof onAddFilter | undefined = useMemo( () => @@ -389,12 +385,6 @@ function DiscoverFieldComponent({ } const renderPopover = () => { - const dateRange = data?.query?.timefilter.timefilter.getAbsoluteTime(); - // prioritize an aggregatable multi field if available or take the parent field - const fieldForStats = - (multiFields?.length && - multiFields.find((multiField) => multiField.field.aggregatable)?.field) || - field; const showLegacyFieldStats = services.uiSettings.get(SHOW_LEGACY_FIELD_TOP_VALUES); return ( @@ -420,21 +410,12 @@ function DiscoverFieldComponent({ )} ) : ( - <> - {Boolean(dateRange) && ( - - )} - + )} {multiFields && ( diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_stats.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field_stats.tsx new file mode 100644 index 0000000000000..6ba6807cf9370 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field_stats.tsx @@ -0,0 +1,59 @@ +/* + * 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 React, { useMemo } from 'react'; +import { + FieldStats, + FieldStatsProps, + useQuerySubscriber, +} from '@kbn/unified-field-list-plugin/public'; +import type { DataViewField, DataView } from '@kbn/data-views-plugin/public'; +import { useDiscoverServices } from '../../../../hooks/use_discover_services'; + +export interface DiscoverFieldStatsProps { + field: DataViewField; + dataView: DataView; + multiFields?: Array<{ field: DataViewField; isSelected: boolean }>; + onAddFilter: FieldStatsProps['onAddFilter']; +} + +export const DiscoverFieldStats: React.FC = React.memo( + ({ field, dataView, multiFields, onAddFilter }) => { + const services = useDiscoverServices(); + const dateRange = services.data?.query?.timefilter.timefilter.getAbsoluteTime(); + const querySubscriberResult = useQuerySubscriber({ + data: services.data, + }); + // prioritize an aggregatable multi field if available or take the parent field + const fieldForStats = useMemo( + () => + (multiFields?.length && + multiFields.find((multiField) => multiField.field.aggregatable)?.field) || + field, + [field, multiFields] + ); + + if (!dateRange || !querySubscriberResult.query || !querySubscriberResult.filters) { + return null; + } + + return ( + + ); + } +); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx index 40cab06039f6e..7f2134a758018 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -110,6 +110,10 @@ const mockServices = { }), }, }, + getState: () => ({ + query: { query: '', language: 'lucene' }, + filters: [], + }), }, }, dataViews: dataViewPluginMocks.createStartContract(), diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx index f6f8063df8334..eafdcc0dab69a 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx @@ -186,7 +186,7 @@ const FieldStatsComponent: React.FC = ({ toDate, dslQuery: dslQuery ?? - buildEsQuery(loadedDataView, query ?? [], filters, getEsQueryConfig(uiSettings)), + buildEsQuery(loadedDataView, query ?? [], filters ?? [], getEsQueryConfig(uiSettings)), abortController: abortControllerRef.current, }); diff --git a/src/plugins/unified_field_list/public/hooks/use_query_subscriber.ts b/src/plugins/unified_field_list/public/hooks/use_query_subscriber.ts new file mode 100644 index 0000000000000..9b42db6301f8f --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_query_subscriber.ts @@ -0,0 +1,54 @@ +/* + * 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 { useEffect, useState } from 'react'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { AggregateQuery, Query, Filter } from '@kbn/es-query'; + +/** + * Hook params + */ +export interface QuerySubscriberParams { + data: DataPublicPluginStart; +} + +/** + * Result from the hook + */ +export interface QuerySubscriberResult { + query: Query | AggregateQuery | undefined; + filters: Filter[] | undefined; +} + +/** + * Memorizes current query and filters + * @param data + */ +export const useQuerySubscriber = ({ data }: QuerySubscriberParams) => { + const [result, setResult] = useState(() => { + const state = data.query.getState(); + return { + query: state?.query, + filters: state?.filters, + }; + }); + + useEffect(() => { + const subscription = data.query.state$.subscribe(({ state }) => { + setResult((prevState) => ({ + ...prevState, + query: state.query, + filters: state.filters, + })); + }); + + return () => subscription.unsubscribe(); + }, [setResult, data.query.state$]); + + return result; +}; diff --git a/src/plugins/unified_field_list/public/index.ts b/src/plugins/unified_field_list/public/index.ts index d90a5092576e6..e1a315401e0bc 100755 --- a/src/plugins/unified_field_list/public/index.ts +++ b/src/plugins/unified_field_list/public/index.ts @@ -73,3 +73,9 @@ export { type GroupedFieldsParams, type GroupedFieldsResult, } from './hooks/use_grouped_fields'; + +export { + useQuerySubscriber, + type QuerySubscriberResult, + type QuerySubscriberParams, +} from './hooks/use_query_subscriber'; diff --git a/test/functional/apps/discover/group1/_sidebar.ts b/test/functional/apps/discover/group1/_sidebar.ts index 23705444a085c..585aae36196e6 100644 --- a/test/functional/apps/discover/group1/_sidebar.ts +++ b/test/functional/apps/discover/group1/_sidebar.ts @@ -6,13 +6,22 @@ * Side Public License, v 1. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const PageObjects = getPageObjects([ + 'common', + 'discover', + 'timePicker', + 'header', + 'unifiedSearch', + ]); const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const filterBar = getService('filterBar'); describe('discover sidebar', function describeIndexTests() { before(async function () { @@ -36,6 +45,53 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); + describe('field stats', function () { + it('should work for regular and pinned filters', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + + const allTermsResult = 'jpg\n65.0%\ncss\n15.4%\npng\n9.8%\ngif\n6.6%\nphp\n3.2%'; + await PageObjects.discover.clickFieldListItem('extension'); + expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be(allTermsResult); + + await filterBar.addFilter('extension', 'is', 'jpg'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + const onlyJpgResult = 'jpg\n100%'; + await PageObjects.discover.clickFieldListItem('extension'); + expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be(onlyJpgResult); + + await filterBar.toggleFilterNegated('extension'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + const jpgExcludedResult = 'css\n44.1%\npng\n28.0%\ngif\n18.8%\nphp\n9.1%'; + await PageObjects.discover.clickFieldListItem('extension'); + expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be( + jpgExcludedResult + ); + + await filterBar.toggleFilterPinned('extension'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.clickFieldListItem('extension'); + expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be( + jpgExcludedResult + ); + + await browser.refresh(); + + await PageObjects.discover.clickFieldListItem('extension'); + expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be( + jpgExcludedResult + ); + + await filterBar.toggleFilterEnabled('extension'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.clickFieldListItem('extension'); + expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be(allTermsResult); + }); + }); + describe('collapse expand', function () { it('should initially be expanded', async function () { await testSubjects.existOrFail('discover-sidebar'); From b6dcb8b088cb110fd001f7aa27191fb47124307d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 23 Nov 2022 12:35:08 +0100 Subject: [PATCH 02/83] [Infra UI] Remove outdated docs and scripts (#146066) --- x-pack/plugins/infra/docs/arch.md | 106 -------------- x-pack/plugins/infra/docs/arch_client.md | 132 ------------------ x-pack/plugins/infra/docs/assets/arch.png | Bin 69550 -> 0 bytes .../plugins/infra/scripts/gql_gen_client.json | 11 -- .../plugins/infra/scripts/gql_gen_server.json | 14 -- x-pack/plugins/infra/tsconfig.json | 3 +- 6 files changed, 1 insertion(+), 265 deletions(-) delete mode 100644 x-pack/plugins/infra/docs/arch.md delete mode 100644 x-pack/plugins/infra/docs/arch_client.md delete mode 100644 x-pack/plugins/infra/docs/assets/arch.png delete mode 100644 x-pack/plugins/infra/scripts/gql_gen_client.json delete mode 100644 x-pack/plugins/infra/scripts/gql_gen_server.json diff --git a/x-pack/plugins/infra/docs/arch.md b/x-pack/plugins/infra/docs/arch.md deleted file mode 100644 index 89b00cd19d1d9..0000000000000 --- a/x-pack/plugins/infra/docs/arch.md +++ /dev/null @@ -1,106 +0,0 @@ -# Adapter Based Architecture - -## Terms - -In this arch, we use 3 main terms to describe the code: - -- **Libs / Domain Libs** - Business logic & data formatting (though complex formatting might call utils) -- **Adapters** - code that directly calls 3rd party APIs and data sources, exposing clean easy to stub APIs -- **Composition Files** - composes adapters into libs based on where the code is running -- **Implementation layer** - The API such as rest endpoints on the server, and the state management / UI on the client - -## Arch Visual Example - -![Arch Visual Overview](/docs/assets/arch.png) - -## Code Guidelines - -### Libs & Domain Libs: - -This term is used to describe the location of business logic. Each use-case in your app should maintain its own lib. - -Now there are 2 types of libs. A "regular lib" would be something like a lib for interacting with Kibana APIs, with something like a parent app adapter. The other is a "domain lib", and would be something like a hosts, or logging lib that might have composed into it an Elasicsearch adapter. - -For the cases on this application, we might have a Logs, Hosts, Containers, Services, ParentApp, and Settings libs, just as an example. Libs should only have 1 Lib per use-case. - -Libs have, composed into them, adapters, as well as access to other libs. The inter-dependencies on other libs and adapters are explicitly expressed in the types of the lib's constructor arguments to provide static type checking and improve testability. In the following example AdapterInterface would define the required interface of an adapter composed into this lib. Likewise LibInterface would declare the inter-dependency this lib has on other libs: - -```ts -new (adapter: AdapterInterface, otherLibs: { lib1: Lib1Interface; lib2: Lib2Interface }): LibInterface -``` - -Libs must not contain code that depends on APIs and behavior specific to the runtime environment. Any such code should be extracted into an adapter. Any code that does not meet this requirement should be inside an adapter. - -### Adapters - -Adapters are the location of any code to interact with any data sources, or 3rd party API / dependency. An example of code that belongs to an adapter would be anything that interacts with Kibana, or Elasticsearch. This would also include things like, for instance, the browser's local storage. - -**The interface exposed by an adapter should be as domain-specific as possible to reduce the risk of leaking abstraction from the "adapted" technology. Therefore a method like `getHosts()` would be preferable to a `runQuery(filterArgs)` method.** This way the output can be well typed and easily stubbed out in an alternate adapter. This will result in vast improvements in testing reliability and code quality. - -Even adapters though should have required dependencies injected into them for as much as is reasonable. Though this is something that is up to the specific adapter as to what is best on a case-by-case basis. - -An app will in most cases have multiple types of each adapter. As an example, a Lib might have an Elasticsearch-backed adapter as well as an adapter backed by an in-memory store, both of which expose the same interface. This way you can compose a lib to use an in-memory adapter to functional or unit tests in order to have isolated tests that are cleaner / faster / more accurate. - -Adapters can at times be composed into another adapter. This behavior though should be kept to a strict minimum. - -**Acceptable:** - -- An Elasticsearch adapter being passed into Hosts, K8, and logging adapters. The Elasticsearch adapter would then never be exposed directly to a lib. - -**Unacceptable:** - -- A K8 adapter being composed into a hosts adapter, but then k8 also being exposed to a lib. - -The former is acceptable only to abstract shared code between adapters. It is clear that this is acceptable because only other adapters use this code. - -The latter being a "code smell" that indicates there is ether too much logic in your adapter that should be in a lib, or the adapters API is insufficient and should be reconsidered. - -### Composition files - -These files will import all libs and their required adapters to instantiate them in the correct order while passing in the respective dependencies. For a contrived but realistic example, a dev_ui composition file that composes an Elasticsearch adapter into Logs, Hosts, and Services libs, and a dev-ui adapter into ParentApp, and a local-storage adapter into Settings. Then another composition file for Kibana might compose other compatible adapters for use with the Kibana APIs. - -composition files simply export a compose method that returns the composed and initialized libs. - -## File structure - -An example structure might be... - -``` -|-- infra-ui - |-- common - | |-- types.ts - | - |-- server - | |-- lib - | | |-- adapters - | | | |-- hosts - | | | | |-- elasticsearch.ts - | | | | |-- fake_data.ts - | | | | - | | | |-- logs - | | | | |-- elasticsearch.ts - | | | | |-- fake_data.ts - | | | | - | | | |-- parent_app - | | | | |-- kibana_angular // if an adapter has more than one file... - | | | | | |-- index.html - | | | | | |-- index.ts - | | | | | - | | | | |-- ui_harness.ts - | | | | - | | |-- domains - | | | |-- hosts.ts - | | | |-- logs.ts - | | | - | | |-- compose - | | | |-- dev.ts - | | | |-- kibana.ts - | | | - | | |-- parent_app.ts // a non-domain lib - | | |-- lib.ts // a file containing lib type defs - |-- public - | | ## SAME STRUCTURE AS SERVER -``` - -Note that in the above adapters have a folder for each adapter type, then inside the implementation of the adapters. The implementation can be a single file, or a directory where index.js is the class that exposes the adapter. -`libs/compose/` contains the composition files diff --git a/x-pack/plugins/infra/docs/arch_client.md b/x-pack/plugins/infra/docs/arch_client.md deleted file mode 100644 index b40c9aaf1ff58..0000000000000 --- a/x-pack/plugins/infra/docs/arch_client.md +++ /dev/null @@ -1,132 +0,0 @@ -# Client Architecture - -All rules described in the [server-side architecture documentation](docs/arch.md) apply to the client as well. As shown below, the directory structure additionally accommodates the front-end-specific concepts like components and containers. - -## Apps - -The `apps` folder contains the entry point for the UI code, such as for use in Kibana or testing. - -## Components - -- Components should be stateless wherever possible with pages and containers holding state. -- Small (less than ~30 lines of JSX and less than ~120 lines total) components should simply get their own file. -- If a component gets too large to reason about, and/or needs multiple child components that are only used in this one place, place them all in a folder. -- All components, please use Styled-Components. This also applies to small tweaks to EUI, just use `styled(Component)` and the `attrs` method for always used props. For example: - -```jsx -export const Toolbar = styled(EuiPanel).attrs(() => ({ - paddingSize: 'none', - grow: false, -}))` - margin: -2px; -`; -``` - -However, components that tweak EUI should go into `/public/components/eui/${componentName}`. - -If using an EUI component that has not yet been typed, types should be placed into `/types/eui.d.ts` - -## Containers - -- HOC's based on Apollo. -- One folder per data type e.g. `host`. Folder name should be singular. -- One file per query type. - -## Pages - -- Ideally one file per page, if more files are needed, move into folder containing the page and a layout file. -- Pages are class based components. -- Should hold most state, and any additional route logic. -- Are the only files where components are wrapped by containers. For example: - -```jsx -// Simple usage -const FancyLogPage = withSearchResults(class FancyLogPage extends React.Component { - render() { - return ( - <> - - - - <> - ); - } -}); -``` - -OR, for more complex scenarios: - -```jsx -// Advanced usage -const ConnectedToolbar = compose( - withTimeMutation, - withCurrentTime -)(Toolbar); - -const ConnectedLogView = compose( - withLogEntries, - withSearchResults, -)(LogView); - -const ConnectedSearchBar = compose( - withSearchMutation -)(SearchBar); - -interface FancyLogPageProps {} - -class FancyLogPage extends React.Component { - render() { - return ( - <> - - - - <> - ); - } -}; -``` - -## Transforms - -- If you need to do some complex data transforms, it is better to put them here than in a utility or lib. Simpler transforms are probably easier to keep in a container. -- One file per transform - -## File structure - -``` -|-- infra-ui - |-- common - | |-- types.ts - | - |-- public - | |-- components // - | | |-- eui // staging area for eui customizations before pushing upstream - | | |-- layout // any layout components should be placed in here - | | |-- button.tsx - | | |-- mega_table // Where mega table is used directly with a data prop, not a composable table - | | |-- index.ts - | | |-- row.tsx - | | |-- table.tsx - | | |-- cell.tsx - | | - | |-- containers - | | |-- host - | | | |-- index.ts - | | | |-- with_all_hosts.ts - | | | |-- transforms - | | | |-- hosts_to_waffel.ts - | | | - | | |-- pod - | | |-- index.ts - | | |-- with_all_pods.ts - | | - | |-- pages - | | |-- home.tsx // the initial page of a plugin is always the `home` page - | | |-- hosts.tsx - | | |-- logging.tsx - | | - | |-- utils // utils folder for what utils folders are for ;) - | | - | |-- lib // covered in [Our code and arch](docs/arch.md) -``` diff --git a/x-pack/plugins/infra/docs/assets/arch.png b/x-pack/plugins/infra/docs/assets/arch.png deleted file mode 100644 index 878c7d1aa16d44d169cf454269a3f650a99112df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69550 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

??CNcQ%B9@Mgs-3)JySwag`A5mCLGbF+woYq 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 diff --git a/x-pack/plugins/infra/scripts/gql_gen_client.json b/x-pack/plugins/infra/scripts/gql_gen_client.json deleted file mode 100644 index 87b8233dd1eeb..0000000000000 --- a/x-pack/plugins/infra/scripts/gql_gen_client.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "flattenTypes": true, - "generatorConfig": {}, - "primitives": { - "String": "string", - "Int": "number", - "Float": "number", - "Boolean": "boolean", - "ID": "string" - } -} diff --git a/x-pack/plugins/infra/scripts/gql_gen_server.json b/x-pack/plugins/infra/scripts/gql_gen_server.json deleted file mode 100644 index f8f916897c2d4..0000000000000 --- a/x-pack/plugins/infra/scripts/gql_gen_server.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "flattenTypes": true, - "generatorConfig": { - "contextType": "InfraContext", - "prepend": ["import { InfraContext } from '../lib/infra_types';"] - }, - "primitives": { - "String": "string", - "Int": "number", - "Float": "number", - "Boolean": "boolean", - "ID": "string" - } -} diff --git a/x-pack/plugins/infra/tsconfig.json b/x-pack/plugins/infra/tsconfig.json index c092210c7ba68..238681826ffc4 100644 --- a/x-pack/plugins/infra/tsconfig.json +++ b/x-pack/plugins/infra/tsconfig.json @@ -3,13 +3,12 @@ "compilerOptions": { "outDir": "./target/types", "emitDeclarationOnly": true, - "declaration": true, + "declaration": true }, "include": [ "../../../typings/**/*", "common/**/*", "public/**/*", - "scripts/**/*", "server/**/*", "types/**/*" ], From 6de90aab9b56a23518b46779da273bac76af132c Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 23 Nov 2022 12:37:05 +0100 Subject: [PATCH 03/83] [Files] Add default file kind (#144803) ## Summary Introduce a default file kind for images in Kibana. This file kind will be the download/upload target usable for all images across Kibana. Consider the following: A Kibana user wants to add a branding logo to their dashboard. They need to create a new image or select from a set of existing images (i.e., images already uploaded). This set of images is the "default image" set. The idea will be this set of images can be access across dashboards and solutions. For example, the same user can access the branding image they uploaded in Cases. ## How it works * We added a new default file kind specifically for images, this is registered from the files plugin * In order to access these files over HTTP users will need the `files:defaultImage` privilege * This is a distinct privilege from the file management privilege and allows users to access HTTP endpoints controlled by `access:files:defaultImage` as well as the underlying `file` saved object * Consider a dashboard user that wants to add an image embeddable: they will need access to `file` saved object as well as the endpoints for creating/reading/deleting the default file kind. In order to get this their role must grant the new "Shared images" privilege. Screenshot 2022-11-22 at 10 34 25 --- .../files/common/default_image_file_kind.ts | 35 ++++++ src/plugins/files/common/index.ts | 1 + .../common/register_default_file_kinds.ts | 15 +++ src/plugins/files/public/index.ts | 1 + src/plugins/files/public/plugin.ts | 2 + src/plugins/files/server/plugin.ts | 4 + .../__snapshots__/oss_features.test.ts.snap | 106 +++++++++++++++++- .../plugins/features/server/oss_features.ts | 41 ++++++- x-pack/plugins/features/server/plugin.test.ts | 1 + .../apis/features/features/features.ts | 1 + .../apis/security/privileges.ts | 1 + .../apis/security/privileges_basic.ts | 2 + 12 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 src/plugins/files/common/default_image_file_kind.ts create mode 100644 src/plugins/files/common/register_default_file_kinds.ts diff --git a/src/plugins/files/common/default_image_file_kind.ts b/src/plugins/files/common/default_image_file_kind.ts new file mode 100644 index 0000000000000..2fd2812e6f17b --- /dev/null +++ b/src/plugins/files/common/default_image_file_kind.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. + */ + +import { FileKind } from './types'; + +const id = 'defaultImage' as const; +const tag = 'files:defaultImage' as const; +const tags = [`access:${tag}`]; +const tenMebiBytes = 1024 * 1024 * 10; + +/** + * A file kind that is available to all plugins to use for uploading images + * intended to be reused across Kibana. + */ +export const defaultImageFileKind: FileKind = { + id, + maxSizeBytes: tenMebiBytes, + blobStoreSettings: {}, + // tried using "image/*" but it did not work with the HTTP endpoint (got 415 Unsupported Media Type) + allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/avif'], + http: { + create: { tags }, + delete: { tags }, + download: { tags }, + getById: { tags }, + list: { tags }, + share: { tags }, + update: { tags }, + }, +}; diff --git a/src/plugins/files/common/index.ts b/src/plugins/files/common/index.ts index ece05d00a8bd3..16a7d03775e86 100755 --- a/src/plugins/files/common/index.ts +++ b/src/plugins/files/common/index.ts @@ -7,6 +7,7 @@ */ export { FILE_SO_TYPE, PLUGIN_ID, PLUGIN_NAME, ES_FIXED_SIZE_INDEX_BLOB_STORE } from './constants'; +export { defaultImageFileKind } from './default_image_file_kind'; export type { File, diff --git a/src/plugins/files/common/register_default_file_kinds.ts b/src/plugins/files/common/register_default_file_kinds.ts new file mode 100644 index 0000000000000..37982c445cd16 --- /dev/null +++ b/src/plugins/files/common/register_default_file_kinds.ts @@ -0,0 +1,15 @@ +/* + * 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 { getFileKindsRegistry } from './file_kinds_registry'; +import { defaultImageFileKind } from '.'; + +export function registerDefaultFileKinds() { + const registry = getFileKindsRegistry(); + registry.register(defaultImageFileKind); +} diff --git a/src/plugins/files/public/index.ts b/src/plugins/files/public/index.ts index 5822efb655735..2d07fa06b8a4e 100644 --- a/src/plugins/files/public/index.ts +++ b/src/plugins/files/public/index.ts @@ -7,6 +7,7 @@ */ import { FilesPlugin } from './plugin'; +export { defaultImageFileKind } from '../common/default_image_file_kind'; export type { FilesSetup, FilesStart } from './plugin'; export type { FilesClient, diff --git a/src/plugins/files/public/plugin.ts b/src/plugins/files/public/plugin.ts index a91b565d2a540..57f17fd6a3753 100644 --- a/src/plugins/files/public/plugin.ts +++ b/src/plugins/files/public/plugin.ts @@ -15,6 +15,7 @@ import { import type { FilesClient, FilesClientFactory } from './types'; import { createFilesClient } from './files_client'; import { FileKind } from '../common'; +import { registerDefaultFileKinds } from '../common/register_default_file_kinds'; import { ScopedFilesClient } from '.'; /** @@ -59,6 +60,7 @@ export class FilesPlugin implements Plugin { return createFilesClient({ http: core.http }) as FilesClient; }, }; + registerDefaultFileKinds(); return { filesClientFactory: this.filesClientFactory, registerFileKind: (fileKind: FileKind) => { diff --git a/src/plugins/files/server/plugin.ts b/src/plugins/files/server/plugin.ts index b7cef3173302f..1680512ded3d2 100755 --- a/src/plugins/files/server/plugin.ts +++ b/src/plugins/files/server/plugin.ts @@ -21,6 +21,7 @@ import { getFileKindsRegistry, FileKindsRegistryImpl, } from '../common/file_kinds_registry'; +import { registerDefaultFileKinds } from '../common/register_default_file_kinds'; import { BlobStorageService } from './blob_storage_service'; import { FileServiceFactory } from './file_service'; @@ -91,6 +92,9 @@ export class FilesPlugin implements Plugin this.fileServiceFactory?.asInternal(), }); + // Now that everything is set up: + registerDefaultFileKinds(); + return { registerFileKind(fileKind) { getFileKindsRegistry().register(fileKind); diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 37b116f7611e8..5ab73d95c15de 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -169,6 +169,10 @@ Array [ "id": "filesManagement", "subFeatures": undefined, }, + Object { + "id": "filesSharedImage", + "subFeatures": undefined, + }, Object { "id": "savedObjectsManagement", "subFeatures": undefined, @@ -457,6 +461,10 @@ Array [ "id": "filesManagement", "subFeatures": undefined, }, + Object { + "id": "filesSharedImage", + "subFeatures": undefined, + }, Object { "id": "savedObjectsManagement", "subFeatures": undefined, @@ -773,6 +781,7 @@ Array [ "privilege": Object { "api": Array [ "files:manageFiles", + "files:defaultImage", ], "app": Array [ "kibana", @@ -784,7 +793,8 @@ Array [ }, "savedObject": Object { "all": Array [ - "files", + "file", + "fileShare", ], "read": Array [], }, @@ -796,6 +806,7 @@ Array [ "privilege": Object { "api": Array [ "files:manageFiles", + "files:defaultImage", ], "app": Array [ "kibana", @@ -808,7 +819,49 @@ Array [ "savedObject": Object { "all": Array [], "read": Array [ - "files", + "file", + "fileShare", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a basic license returns the filesSharedImage feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "files:defaultImage", + ], + "app": Array [ + "kibana", + ], + "savedObject": Object { + "all": Array [ + "file", + ], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "files:defaultImage", + ], + "app": Array [ + "kibana", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "file", ], }, "ui": Array [], @@ -1332,6 +1385,7 @@ Array [ "privilege": Object { "api": Array [ "files:manageFiles", + "files:defaultImage", ], "app": Array [ "kibana", @@ -1343,7 +1397,8 @@ Array [ }, "savedObject": Object { "all": Array [ - "files", + "file", + "fileShare", ], "read": Array [], }, @@ -1355,6 +1410,7 @@ Array [ "privilege": Object { "api": Array [ "files:manageFiles", + "files:defaultImage", ], "app": Array [ "kibana", @@ -1367,7 +1423,49 @@ Array [ "savedObject": Object { "all": Array [], "read": Array [ - "files", + "file", + "fileShare", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures with a enterprise license returns the filesSharedImage feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "files:defaultImage", + ], + "app": Array [ + "kibana", + ], + "savedObject": Object { + "all": Array [ + "file", + ], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "files:defaultImage", + ], + "app": Array [ + "kibana", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "file", ], }, "ui": Array [], diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 120c8a5652d09..076ef4941c530 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -441,11 +441,11 @@ export const buildOSSFeatures = ({ kibana: ['filesManagement'], }, savedObject: { - all: ['files'], + all: ['file', 'fileShare'], read: [], }, ui: [], - api: ['files:manageFiles'], + api: ['files:manageFiles', 'files:defaultImage'], }, read: { app: ['kibana'], @@ -454,10 +454,43 @@ export const buildOSSFeatures = ({ }, savedObject: { all: [], - read: ['files'], + read: ['file', 'fileShare'], }, ui: [], - api: ['files:manageFiles'], + api: ['files:manageFiles', 'files:defaultImage'], + }, + }, + }, + { + id: 'filesSharedImage', + name: i18n.translate('xpack.features.filesSharedImagesFeatureName', { + defaultMessage: 'Shared images', + }), + order: 1600, + category: DEFAULT_APP_CATEGORIES.management, + app: ['kibana'], + catalogue: [], + privilegesTooltip: i18n.translate('xpack.features.filesSharedImagesPrivilegesTooltip', { + defaultMessage: 'Required to access images stored in Kibana.', + }), + privileges: { + all: { + app: ['kibana'], + savedObject: { + all: ['file'], + read: [], + }, + ui: [], + api: ['files:defaultImage'], + }, + read: { + app: ['kibana'], + savedObject: { + all: [], + read: ['file'], + }, + ui: [], + api: ['files:defaultImage'], }, }, }, diff --git a/x-pack/plugins/features/server/plugin.test.ts b/x-pack/plugins/features/server/plugin.test.ts index cc366456b4d87..e1eac3ebbd481 100644 --- a/x-pack/plugins/features/server/plugin.test.ts +++ b/x-pack/plugins/features/server/plugin.test.ts @@ -67,6 +67,7 @@ describe('Features Plugin', () => { "advancedSettings", "indexPatterns", "filesManagement", + "filesSharedImage", "savedObjectsManagement", ] `); diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index d8af1a8d638b6..ce937a5e4618e 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -101,6 +101,7 @@ export default function ({ getService }: FtrProviderContext) { 'actions', 'enterpriseSearch', 'filesManagement', + 'filesSharedImage', 'advancedSettings', 'indexPatterns', 'graph', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index a16e850967bb3..c7661a978bcaf 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -79,6 +79,7 @@ export default function ({ getService }: FtrProviderContext) { 'packs_read', ], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], + filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], }, reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 2b110aa30df7a..ef6cd6ea9d845 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -46,6 +46,7 @@ export default function ({ getService }: FtrProviderContext) { stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'], actions: ['all', 'read', 'minimal_all', 'minimal_read'], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], + filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], }, global: ['all', 'read'], space: ['all', 'read'], @@ -133,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'], indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], + filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], osquery: [ 'all', From ba16f55c5e3357737ae7f4ffc5381988a1c31b8d Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 23 Nov 2022 06:40:12 -0500 Subject: [PATCH 04/83] Fixing delete action and empty message bugs (#145463) This PR addresses two issues: 1. This bug: https://github.com/elastic/kibana/issues/144128 when a user deletes an action at say index 0 within the rule form , if the action at index 1 was a different type (`createAlert` vs `closeAlert`) the user would lose the information in the action. - Now the information from the action at index 1 should persistent 2. A user could save the rule when the `message` field was a string of all spaces - Now the user should not be able to save the rule Fixes: https://github.com/elastic/kibana/issues/144128 ### Deleting the action https://user-images.githubusercontent.com/56361221/202298047-d0533639-6444-40c9-8bc8-6504551128dc.mov https://user-images.githubusercontent.com/56361221/202298074-34878a4c-5fa0-4d8d-8192-ae5f43439cd0.mov ### Whitespace message field https://user-images.githubusercontent.com/56361221/202298085-95f1fe3f-2652-4b92-86c7-b49b0b23d913.mov --- .../stack/opsgenie/close_alert_schema.test.ts | 33 +++ .../stack/opsgenie/close_alert_schema.ts | 33 +++ .../stack/opsgenie/create_alert/index.tsx | 2 + .../opsgenie/create_alert/json_editor.tsx | 3 +- .../opsgenie/create_alert/schema.test.ts | 197 ++++++++++-------- .../stack/opsgenie/create_alert/schema.ts | 150 ++++++------- .../opsgenie/create_alert/translations.ts | 7 - .../connector_types/stack/opsgenie/model.tsx | 13 +- .../stack/opsgenie/params.test.tsx | 99 +++++++-- .../connector_types/stack/opsgenie/params.tsx | 18 +- .../stack/opsgenie/schema_utils.test.ts | 37 ++++ .../stack/opsgenie/schema_utils.ts | 60 ++++++ .../stack/opsgenie/translations.ts | 5 + .../stack/opsgenie/schema.test.ts | 17 +- .../stack/opsgenie/test_schema.ts | 13 +- 15 files changed, 480 insertions(+), 207 deletions(-) create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.test.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.test.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.test.ts new file mode 100644 index 0000000000000..2af9eb85db3fe --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.test.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { OpsgenieCloseAlertExample } from '../../../../server/connector_types/stack/opsgenie/test_schema'; +import { isPartialCloseAlertSchema } from './close_alert_schema'; + +describe('close_alert_schema', () => { + describe('isPartialCloseAlertSchema', () => { + it('returns true with an empty object', () => { + expect(isPartialCloseAlertSchema({})).toBeTruthy(); + }); + + it('returns false with undefined', () => { + expect(isPartialCloseAlertSchema(undefined)).toBeFalsy(); + }); + + it('returns false with an invalid field', () => { + expect(isPartialCloseAlertSchema({ invalidField: 'a' })).toBeFalsy(); + }); + + it('returns true with only the note field', () => { + expect(isPartialCloseAlertSchema({ note: 'a' })).toBeTruthy(); + }); + + it('returns true with the Opsgenie close alert example', () => { + expect(isPartialCloseAlertSchema(OpsgenieCloseAlertExample)).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.ts new file mode 100644 index 0000000000000..8c8a78ca0d920 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert_schema.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { decodeSchema } from './schema_utils'; + +/** + * This schema must match the CloseAlertParamsSchema in x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts + * except that it makes all fields partial. + */ +const CloseAlertSchema = rt.exact( + rt.partial({ + alias: rt.string, + user: rt.string, + source: rt.string, + note: rt.string, + }) +); + +type CloseAlertSchemaType = rt.TypeOf; + +export const isPartialCloseAlertSchema = (data: unknown): data is CloseAlertSchemaType => { + try { + decodeSchema(CloseAlertSchema, data); + return true; + } catch (error) { + return false; + } +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx index 7b0a7de9722d7..856c7bfde3c55 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx @@ -207,3 +207,5 @@ const CreateAlertComponent: React.FC = ({ CreateAlertComponent.displayName = 'CreateAlert'; export const CreateAlert = React.memo(CreateAlertComponent); + +export { isPartialCreateAlertSchema } from './schema'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx index 91147204e46f4..f29736492d0e7 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx @@ -11,7 +11,8 @@ import { JsonEditorWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/ import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack'; import * as i18n from './translations'; import { CreateAlertProps } from '.'; -import { decodeCreateAlert, isDecodeError } from './schema'; +import { decodeCreateAlert } from './schema'; +import { isDecodeError } from '../schema_utils'; export type JsonEditorProps = Pick< CreateAlertProps, diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts index f2aec179e8676..9b96d70c65633 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts @@ -5,104 +5,137 @@ * 2.0. */ -import { decodeCreateAlert } from './schema'; +import { decodeCreateAlert, isPartialCreateAlertSchema } from './schema'; import { OpsgenieCreateAlertExample, ValidCreateAlertSchema, } from '../../../../../server/connector_types/stack/opsgenie/test_schema'; -describe('decodeCreateAlert', () => { - it('throws an error when the message field is not present', () => { - expect(() => decodeCreateAlert({ alias: '123' })).toThrowErrorMatchingInlineSnapshot( - `"[message]: expected value of type [string] but got [undefined]"` - ); - }); +describe('schema', () => { + describe('decodeCreateAlert', () => { + it('throws an error when the message field is not present', () => { + expect(() => decodeCreateAlert({ alias: '123' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: expected value of type [string] but got [undefined]"` + ); + }); - it('throws an error when the message field is only spaces', () => { - expect(() => decodeCreateAlert({ message: ' ' })).toThrowErrorMatchingInlineSnapshot( - `"[message]: must be populated with a value other than just whitespace"` - ); - }); + it('throws an error when the message field is only spaces', () => { + expect(() => decodeCreateAlert({ message: ' ' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: must be populated with a value other than just whitespace"` + ); + }); - it('throws an error when the message field is an empty string', () => { - expect(() => decodeCreateAlert({ message: '' })).toThrowErrorMatchingInlineSnapshot( - `"[message]: must be populated with a value other than just whitespace"` - ); - }); + it('throws an error when the message field is an empty string', () => { + expect(() => decodeCreateAlert({ message: '' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: must be populated with a value other than just whitespace"` + ); + }); - it('throws an error when additional fields are present in the data that are not defined in the schema', () => { - expect(() => - decodeCreateAlert({ invalidField: 'hi', message: 'hi' }) - ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); - }); + it('throws an error when additional fields are present in the data that are not defined in the schema', () => { + expect(() => + decodeCreateAlert({ invalidField: 'hi', message: 'hi' }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); - it('throws an error when additional fields are present in responders with name field than in the schema', () => { - expect(() => - decodeCreateAlert({ - message: 'hi', - responders: [{ name: 'sam', type: 'team', invalidField: 'scott' }], - }) - ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); - }); + it('throws an error when additional fields are present in responders with name field than in the schema', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + responders: [{ name: 'sam', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); - it('throws an error when additional fields are present in responders with id field than in the schema', () => { - expect(() => - decodeCreateAlert({ - message: 'hi', - responders: [{ id: 'id', type: 'team', invalidField: 'scott' }], - }) - ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); - }); + it('throws an error when additional fields are present in responders with id field than in the schema', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + responders: [{ id: 'id', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); - it('throws an error when additional fields are present in visibleTo with name and type=team', () => { - expect(() => - decodeCreateAlert({ - message: 'hi', - visibleTo: [{ name: 'sam', type: 'team', invalidField: 'scott' }], - }) - ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); - }); + it('throws an error when additional fields are present in visibleTo with name and type=team', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ name: 'sam', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); - it('throws an error when additional fields are present in visibleTo with id and type=team', () => { - expect(() => - decodeCreateAlert({ - message: 'hi', - visibleTo: [{ id: 'id', type: 'team', invalidField: 'scott' }], - }) - ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); - }); + it('throws an error when additional fields are present in visibleTo with id and type=team', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ id: 'id', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); - it('throws an error when additional fields are present in visibleTo with id and type=user', () => { - expect(() => - decodeCreateAlert({ - message: 'hi', - visibleTo: [{ id: 'id', type: 'user', invalidField: 'scott' }], - }) - ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); - }); + it('throws an error when additional fields are present in visibleTo with id and type=user', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ id: 'id', type: 'user', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); - it('throws an error when additional fields are present in visibleTo with username and type=user', () => { - expect(() => - decodeCreateAlert({ - message: 'hi', - visibleTo: [{ username: 'sam', type: 'user', invalidField: 'scott' }], - }) - ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); - }); + it('throws an error when additional fields are present in visibleTo with username and type=user', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ username: 'sam', type: 'user', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); - it('throws an error when details is a record of string to number', () => { - expect(() => - decodeCreateAlert({ - message: 'hi', - details: { id: 1 }, - }) - ).toThrowErrorMatchingInlineSnapshot(`"Invalid value \\"1\\" supplied to \\"details.id\\""`); + it('throws an error when details is a record of string to number', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + details: { id: 1 }, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid value \\"1\\" supplied to \\"details.id\\""`); + }); + + it.each([ + ['ValidCreateAlertSchema', ValidCreateAlertSchema], + ['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample], + ])('validates the test object [%s] correctly', (objectName, testObject) => { + expect(() => decodeCreateAlert(testObject)).not.toThrow(); + }); }); - it.each([ - ['ValidCreateAlertSchema', ValidCreateAlertSchema], - ['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample], - ])('validates the test object [%s] correctly', (objectName, testObject) => { - expect(() => decodeCreateAlert(testObject)).not.toThrow(); + describe('isPartialCreateAlertSchema', () => { + const { message, ...createAlertSchemaWithoutMessage } = ValidCreateAlertSchema; + const { message: ignoreMessage2, ...opsgenieCreateAlertExampleWithoutMessage } = + OpsgenieCreateAlertExample; + + it('returns true with an empty object', () => { + expect(isPartialCreateAlertSchema({})).toBeTruthy(); + }); + + it('returns false with undefined', () => { + expect(isPartialCreateAlertSchema(undefined)).toBeFalsy(); + }); + + it('returns true with only alias', () => { + expect(isPartialCreateAlertSchema({ alias: 'abc' })).toBeTruthy(); + }); + + it.each([ + ['ValidCreateAlertSchema', ValidCreateAlertSchema], + ['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample], + ['createAlertSchemaWithoutMessage', createAlertSchemaWithoutMessage], + ['opsgenieCreateAlertExampleWithoutMessage', opsgenieCreateAlertExampleWithoutMessage], + ])('returns true with the test object [%s]', (objectName, testObject) => { + expect(isPartialCreateAlertSchema(testObject)).toBeTruthy(); + }); + + it('returns false with an additional property', () => { + expect(isPartialCreateAlertSchema({ anInvalidField: 'a' })).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts index 2a76527c6c355..6f13886a32a62 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts @@ -5,12 +5,10 @@ * 2.0. */ -import { Either, fold } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; +import { Either } from 'fp-ts/lib/Either'; import * as rt from 'io-ts'; -import { exactCheck } from '@kbn/securitysolution-io-ts-utils'; -import { identity } from 'fp-ts/lib/function'; -import { isEmpty, isObject } from 'lodash'; +import { isEmpty } from 'lodash'; +import { decodeSchema } from '../schema_utils'; import * as i18n from './translations'; const MessageNonEmptyString = new rt.Type( @@ -37,6 +35,42 @@ const ResponderTypes = rt.union([ rt.literal('schedule'), ]); +const CreateAlertSchemaOptionalProps = rt.partial( + rt.type({ + alias: rt.string, + description: rt.string, + responders: rt.array( + rt.union([ + rt.strict({ name: rt.string, type: ResponderTypes }), + rt.strict({ id: rt.string, type: ResponderTypes }), + rt.strict({ username: rt.string, type: rt.literal('user') }), + ]) + ), + visibleTo: rt.array( + rt.union([ + rt.strict({ name: rt.string, type: rt.literal('team') }), + rt.strict({ id: rt.string, type: rt.literal('team') }), + rt.strict({ id: rt.string, type: rt.literal('user') }), + rt.strict({ username: rt.string, type: rt.literal('user') }), + ]) + ), + actions: rt.array(rt.string), + tags: rt.array(rt.string), + details: rt.record(rt.string, rt.string), + entity: rt.string, + source: rt.string, + priority: rt.union([ + rt.literal('P1'), + rt.literal('P2'), + rt.literal('P3'), + rt.literal('P4'), + rt.literal('P5'), + ]), + user: rt.string, + note: rt.string, + }).props +); + /** * This schema is duplicated from the server. The only difference is that it is using io-ts vs kbn-schema. * NOTE: This schema must be the same as defined here: x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts @@ -51,92 +85,38 @@ const ResponderTypes = rt.union([ */ const CreateAlertSchema = rt.intersection([ rt.strict({ message: MessageNonEmptyString }), - rt.exact( - rt.partial({ - alias: rt.string, - description: rt.string, - responders: rt.array( - rt.union([ - rt.strict({ name: rt.string, type: ResponderTypes }), - rt.strict({ id: rt.string, type: ResponderTypes }), - rt.strict({ username: rt.string, type: rt.literal('user') }), - ]) - ), - visibleTo: rt.array( - rt.union([ - rt.strict({ name: rt.string, type: rt.literal('team') }), - rt.strict({ id: rt.string, type: rt.literal('team') }), - rt.strict({ id: rt.string, type: rt.literal('user') }), - rt.strict({ username: rt.string, type: rt.literal('user') }), - ]) - ), - actions: rt.array(rt.string), - tags: rt.array(rt.string), - details: rt.record(rt.string, rt.string), - entity: rt.string, - source: rt.string, - priority: rt.union([ - rt.literal('P1'), - rt.literal('P2'), - rt.literal('P3'), - rt.literal('P4'), - rt.literal('P5'), - ]), - user: rt.string, - note: rt.string, - }) - ), + rt.exact(CreateAlertSchemaOptionalProps), ]); -export const formatErrors = (errors: rt.Errors): string[] => { - const err = errors.map((error) => { - if (error.message != null) { - return error.message; - } else { - const keyContext = error.context - .filter( - (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' - ) - .map((entry) => entry.key) - .join('.'); +type CreateAlertSchemaType = rt.TypeOf; - const nameContext = error.context.find( - (entry) => entry.type != null && entry.type.name != null && entry.type.name.length > 0 - ); +/** + * This schema should match CreateAlertSchema except that all fields are optional and message is only enforced as a string. + * Enforcing message as only a string accommodates the following scenario: + * + * If a user deletes an action in the rule form at index 0, and the + * action at index 1 had the message field specified with all spaces, the message field is technically invalid but + * we want to allow it to pass the partial check so that the form is still populated with the invalid value. Otherwise the + * forum will be reset and the user would lose the information (although it is invalid) they had entered + */ +const PartialCreateAlertSchema = rt.exact( + rt.intersection([ + rt.partial(rt.type({ message: rt.string }).props), + CreateAlertSchemaOptionalProps, + ]) +); - const suppliedValue = - keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; - const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; - return `Invalid value "${value}" supplied to "${suppliedValue}"`; - } - }); +type PartialCreateAlertSchemaType = rt.TypeOf; - return [...new Set(err)]; +export const isPartialCreateAlertSchema = (data: unknown): data is PartialCreateAlertSchemaType => { + try { + decodeSchema(PartialCreateAlertSchema, data); + return true; + } catch (error) { + return false; + } }; -type CreateAlertSchemaType = rt.TypeOf; - export const decodeCreateAlert = (data: unknown): CreateAlertSchemaType => { - const onLeft = (errors: rt.Errors) => { - throw new DecodeError(formatErrors(errors)); - }; - - const onRight = (a: CreateAlertSchemaType): CreateAlertSchemaType => identity(a); - - return pipe( - CreateAlertSchema.decode(data), - (decoded) => exactCheck(data, decoded), - fold(onLeft, onRight) - ); + return decodeSchema(CreateAlertSchema, data); }; - -export class DecodeError extends Error { - constructor(public readonly decodeErrors: string[]) { - super(decodeErrors.join()); - this.name = this.constructor.name; - } -} - -export function isDecodeError(error: unknown): error is DecodeError { - return error instanceof DecodeError; -} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts index d78e66cab0b14..64b5eb442f098 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts @@ -37,13 +37,6 @@ export const DESCRIPTION_FIELD_LABEL = i18n.translate( } ); -export const MESSAGE_FIELD_IS_REQUIRED = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.messageFieldRequired', - { - defaultMessage: '"message" field must be populated with a value other than just whitespace', - } -); - export const USE_JSON_EDITOR_LABEL = i18n.translate( 'xpack.stackConnectors.components.opsgenie.useJsonEditorLabel', { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx index 87dedc180f561..fa538fdfcfe96 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx @@ -11,6 +11,7 @@ import { ActionTypeModel as ConnectorTypeModel, GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public'; +import { isEmpty } from 'lodash'; import { RULE_TAGS_TEMPLATE } from '../../../../common/opsgenie'; import { OpsgenieSubActions } from '../../../../common'; import type { @@ -74,13 +75,13 @@ const validateParams = async ( errors, }; - if ( - actionParams.subAction === OpsgenieSubActions.CreateAlert && - !actionParams?.subActionParams?.message?.length - ) { - errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED); + if (actionParams.subAction === OpsgenieSubActions.CreateAlert) { + if (!actionParams?.subActionParams?.message?.length) { + errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED); + } else if (isEmpty(actionParams?.subActionParams?.message?.trim())) { + errors['subActionParams.message'].push(translations.MESSAGE_NON_WHITESPACE); + } } - if ( actionParams.subAction === OpsgenieSubActions.CloseAlert && !actionParams?.subActionParams?.alias?.length diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx index 7f20f15294878..e78d1af35e953 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx @@ -195,7 +195,7 @@ describe('OpsgenieParamFields', () => { expect(screen.queryByText('Message')).not.toBeInTheDocument(); }); - it('preserves the previous alias value when switching between the create and close alert event actions', async () => { + it('does not call edit action when a component rerenders with subActionParams that match the new subAction', async () => { const { rerender } = render(); expect(screen.getByDisplayValue('hello')).toBeInTheDocument(); @@ -220,17 +220,78 @@ describe('OpsgenieParamFields', () => { expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument(); - expect(editAction).toBeCalledTimes(2); + expect(editAction).toBeCalledTimes(1); + }); - expect(editAction.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - "subActionParams", - Object { - "alias": "a new alias", - }, - 0, - ] - `); + it('calls editAction with only the alias when the component is rerendered with mismatched closeAlert and params', async () => { + const { rerender } = render(); + + expect(screen.getByDisplayValue('hello')).toBeInTheDocument(); + expect(screen.getByDisplayValue('123')).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument(); + + expect(editAction).toBeCalledTimes(1); + expect(editAction.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "subActionParams", + Object { + "alias": "a new alias", + }, + 0, + ] + `); + }); + + it('calls editAction with only the alias when the component is rerendered with mismatched createAlert and params', async () => { + const { rerender } = render(); + + expect(screen.queryByText('Message')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('456')).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByDisplayValue('456')).not.toBeInTheDocument(); + + expect(editAction).toBeCalledTimes(1); + expect(editAction.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "subActionParams", + Object { + "alias": "a new alias", + }, + 0, + ] + `); }); it('only preserves the previous alias value when switching between the create and close alert event actions', async () => { @@ -262,14 +323,14 @@ describe('OpsgenieParamFields', () => { expect(editAction).toBeCalledTimes(2); expect(editAction.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - "subActionParams", - Object { - "alias": "a new alias", - }, - 0, - ] - `); + Array [ + "subActionParams", + Object { + "alias": "a new alias", + }, + 0, + ] + `); }); it('calls editAction when changing the subAction', async () => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx index 33dc1740c5ad8..269babe3ff4b4 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx @@ -19,8 +19,9 @@ import type { OpsgenieCreateAlertSubActionParams, } from '../../../../server/connector_types/stack'; import * as i18n from './translations'; -import { CreateAlert } from './create_alert'; +import { CreateAlert, isPartialCreateAlertSchema } from './create_alert'; import { CloseAlert } from './close_alert'; +import { isPartialCloseAlertSchema } from './close_alert_schema'; const actionOptions = [ { @@ -85,11 +86,20 @@ const OpsgenieParamFields: React.FC> = ( useEffect(() => { if (subAction != null && currentSubAction.current !== subAction) { currentSubAction.current = subAction; - const params = subActionParams?.alias ? { alias: subActionParams.alias } : undefined; - editAction('subActionParams', params, index); + + // check for a mismatch in the subAction and params, if the subAction does not match the params then we need to + // clear them by calling editAction. We can carry over the alias if it exists + if ( + (subAction === OpsgenieSubActions.CreateAlert && + !isPartialCreateAlertSchema(subActionParams)) || + (subAction === OpsgenieSubActions.CloseAlert && !isPartialCloseAlertSchema(subActionParams)) + ) { + const params = subActionParams?.alias ? { alias: subActionParams.alias } : undefined; + editAction('subActionParams', params, index); + } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subAction, currentSubAction, subActionParams?.alias, index]); + }, [subAction, currentSubAction, index, subActionParams]); return ( <> diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.test.ts new file mode 100644 index 0000000000000..de61514e61785 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { DecodeError, decodeSchema } from './schema_utils'; + +describe('schema_utils', () => { + describe('decodeSchema', () => { + const testSchema = rt.strict({ stringField: rt.string }); + + it('throws an error when stringField is not present', () => { + expect(() => decodeSchema(testSchema, { a: 1 })).toThrowErrorMatchingInlineSnapshot( + `"Invalid value \\"undefined\\" supplied to \\"stringField\\""` + ); + }); + + it('throws an error when stringField is present but excess properties are also present', () => { + expect(() => + decodeSchema(testSchema, { stringField: 'abc', a: 1 }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"a\\""`); + }); + + it('does not throw an error when the data matches the schema', () => { + expect(() => decodeSchema(testSchema, { stringField: 'abc' })).not.toThrow(); + }); + + it('throws a DecodeError instance', () => { + expect(() => decodeSchema(testSchema, { a: 1 })).toThrowError( + new DecodeError([`Invalid value \"undefined\" supplied to \"stringField\"`]) + ); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.ts new file mode 100644 index 0000000000000..e599df8361d39 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/schema_utils.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { exactCheck } from '@kbn/securitysolution-io-ts-utils'; +import { identity } from 'fp-ts/lib/function'; +import { isObject } from 'lodash'; + +const formatErrors = (errors: rt.Errors): string[] => { + const err = errors.map((error) => { + if (error.message != null) { + return error.message; + } else { + const keyContext = error.context + .filter( + (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' + ) + .map((entry) => entry.key) + .join('.'); + + const nameContext = error.context.find( + (entry) => entry.type != null && entry.type.name != null && entry.type.name.length > 0 + ); + + const suppliedValue = + keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; + const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; + return `Invalid value "${value}" supplied to "${suppliedValue}"`; + } + }); + + return [...new Set(err)]; +}; + +export const decodeSchema = (schema: rt.Type, data: unknown): T => { + const onLeft = (errors: rt.Errors) => { + throw new DecodeError(formatErrors(errors)); + }; + + const onRight = (schemaType: T): T => identity(schemaType); + + return pipe(schema.decode(data), (decoded) => exactCheck(data, decoded), fold(onLeft, onRight)); +}; + +export class DecodeError extends Error { + constructor(public readonly decodeErrors: string[]) { + super(decodeErrors.join()); + this.name = this.constructor.name; + } +} + +export function isDecodeError(error: unknown): error is DecodeError { + return error instanceof DecodeError; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts index a750e30ca8ef6..9b2615acd52aa 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts @@ -28,6 +28,11 @@ export const MESSAGE_IS_REQUIRED = i18n.translate( } ); +export const MESSAGE_NON_WHITESPACE = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageNotWhitespaceForm', + { defaultMessage: 'Message must be populated with a value other than just whitespace' } +); + export const ACTION_LABEL = i18n.translate( 'xpack.stackConnectors.components.opsgenie.actionLabel', { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts index b46ddb61be135..de8aedd58257d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts @@ -5,8 +5,12 @@ * 2.0. */ -import { CreateAlertParamsSchema } from './schema'; -import { OpsgenieCreateAlertExample, ValidCreateAlertSchema } from './test_schema'; +import { CloseAlertParamsSchema, CreateAlertParamsSchema } from './schema'; +import { + OpsgenieCloseAlertExample, + OpsgenieCreateAlertExample, + ValidCreateAlertSchema, +} from './test_schema'; describe('opsgenie schema', () => { describe('CreateAlertParamsSchema', () => { @@ -17,4 +21,13 @@ describe('opsgenie schema', () => { expect(() => CreateAlertParamsSchema.validate(testObject)).not.toThrow(); }); }); + + describe('CloseAlertParamsSchema', () => { + it.each([['OpsgenieCloseAlertExample', OpsgenieCloseAlertExample]])( + 'validates the test object [%s] correctly', + (objectName, testObject) => { + expect(() => CloseAlertParamsSchema.validate(testObject)).not.toThrow(); + } + ); + }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts index 748423ef22381..595bad18612c7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CreateAlertParams } from './types'; +import { CloseAlertParams, CreateAlertParams } from './types'; export const ValidCreateAlertSchema: CreateAlertParams = { message: 'a message', @@ -94,3 +94,14 @@ export const OpsgenieCreateAlertExample: CreateAlertParams = { entity: 'An example entity', priority: 'P1', }; + +/** + * This example is pulled from the sample curl request here: https://docs.opsgenie.com/docs/alert-api#close-alert + * with the addition of the alias field. + */ +export const OpsgenieCloseAlertExample: CloseAlertParams = { + alias: '123', + user: 'Monitoring Script', + source: 'AWS Lambda', + note: 'Action executed via Alert API', +}; From a2549b7c92c024389bd6690b84073b75608a6a34 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Wed, 23 Nov 2022 12:45:55 +0100 Subject: [PATCH 05/83] [Enterprise Search] Update on_demand enum value (#146127) This updates the TriggerMethod enum to match the connectors protocol. --- x-pack/plugins/enterprise_search/common/types/connectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/common/types/connectors.ts b/x-pack/plugins/enterprise_search/common/types/connectors.ts index 5340c6d9b8fd3..1cabde52b894e 100644 --- a/x-pack/plugins/enterprise_search/common/types/connectors.ts +++ b/x-pack/plugins/enterprise_search/common/types/connectors.ts @@ -99,7 +99,7 @@ export interface FilteringConfig { } export enum TriggerMethod { - ON_DEMAND = 'on-demand', + ON_DEMAND = 'on_demand', SCHEDULED = 'scheduled', } From 99340486af49ba542e1df3891a4ee20d7a7977d2 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Wed, 23 Nov 2022 12:31:47 +0000 Subject: [PATCH 06/83] [SecuritySolution] Inspect modal styling issue (#146015) ## Summary Original issue: https://github.com/elastic/kibana/issues/145800 The size of inspect modal was not aligned between request and response. Before: https://user-images.githubusercontent.com/59917825/202986017-fbce8d03-a9d8-46b6-a235-a7b1b6634f6c.mp4 After: https://user-images.githubusercontent.com/6295984/203363965-b80f89fe-5456-4e89-bd99-567637b80735.mov --- .../public/common/components/inspect/modal.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx index 5bf57218960b2..e46c2bca723f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx @@ -70,6 +70,8 @@ interface Response { } const MyEuiModal = styled(EuiModal)` + width: min(768px, calc(100vw - 16px)); + min-height: 41vh; .euiModal__flex { width: 60vw; } From 4c6a91555de4c0a808b45733d08b61d1ffafb4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Wed, 23 Nov 2022 13:50:22 +0100 Subject: [PATCH 07/83] [Security Solution] UI trusted applications RBAC (#145593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary RBAC UI features for Trusted Applications. To test, enable `endpointRbacEnabled` feature-flag, create a non-superuser user with _Security: ALL_ privilege and (All | Read | None) sub-privilege for _Trusted Applications_. image The modification should: - hide Trusted Apps from Manage navigation items if privilege is NONE, (note: it is still displayed for non-superusers, if the feature flag is disabled) - disable add/edit/delete for Trusted Applications if privilege is READ. ## ⚠️ Note This PR focuses on _Read_ and _None_. The sub-privilege _All_ does not work perfectly at the moment, because of unauthorised API calls. A follow-up PR will fix this, after this PR is merged: https://github.com/elastic/kibana/pull/145361 ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../public/management/links.test.ts | 84 ++++++++++++++----- .../public/management/links.ts | 30 ++++--- .../view/trusted_apps_list.test.tsx | 67 +++++++++++++++ .../trusted_apps/view/trusted_apps_list.tsx | 5 ++ 4 files changed, 152 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/links.test.ts b/x-pack/plugins/security_solution/public/management/links.test.ts index ea27d4fdd28e2..f17fa4bc46c23 100644 --- a/x-pack/plugins/security_solution/public/management/links.test.ts +++ b/x-pack/plugins/security_solution/public/management/links.test.ts @@ -32,6 +32,11 @@ describe('links', () => { let getPlugins: (roles: string[]) => StartPlugins; let fakeHttpServices: jest.Mocked; + const getLinksWithout = (...excludedLinks: SecurityPageName[]) => ({ + ...links, + links: links.links?.filter((link) => !excludedLinks.includes(link.id)), + }); + beforeAll(() => { ExperimentalFeaturesService.init({ experimentalFeatures: { ...allowedExperimentalValues }, @@ -103,10 +108,7 @@ describe('links', () => { coreMockStarted, getPlugins(['superuser']) ); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions), - }); + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); }); it('should return all but HIE when NO isolation permission due to license and NO host isolation exceptions entry', async () => { @@ -123,10 +125,7 @@ describe('links', () => { coreMockStarted, getPlugins(['superuser']) ); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions), - }); + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); }); it('should return all but HIE when HAS isolation permission AND has HIE entry but not superuser', async () => { @@ -143,10 +142,7 @@ describe('links', () => { coreMockStarted, getPlugins(['superuser']) ); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions), - }); + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); }); it('should return all when NO isolation permission due to license but HAS at least one host isolation exceptions entry', async () => { @@ -177,10 +173,7 @@ describe('links', () => { coreMockStarted, getPlugins(['superuser']) ); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter((link) => link.id !== SecurityPageName.hostIsolationExceptions), - }); + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.hostIsolationExceptions)); }); it('should not affect hiding Action Log if getting from HIE API throws error', async () => { @@ -196,15 +189,60 @@ describe('links', () => { coreMockStarted, getPlugins(['superuser']) ); - expect(filteredLinks).toEqual({ - ...links, - links: links.links?.filter( - (link) => - link.id !== SecurityPageName.hostIsolationExceptions && - link.id !== SecurityPageName.responseActionsHistory - ), + expect(filteredLinks).toEqual( + getLinksWithout( + SecurityPageName.hostIsolationExceptions, + SecurityPageName.responseActionsHistory + ) + ); + }); + }); + + // this can be removed after removing endpointRbacEnabled feature flag + describe('without endpointRbacEnabled', () => { + beforeAll(() => { + ExperimentalFeaturesService.init({ + experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: false }, + }); + }); + + it('shows Trusted Applications for non-superuser, too', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock()); + + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + + expect(filteredLinks).toEqual(links); + }); + }); + + // this can be the default after removing endpointRbacEnabled feature flag + describe('with endpointRbacEnabled', () => { + beforeAll(() => { + ExperimentalFeaturesService.init({ + experimentalFeatures: { ...allowedExperimentalValues, endpointRbacEnabled: true }, }); }); + + it('hides Trusted Applications for user without privilege', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue( + getEndpointAuthzInitialStateMock({ + canReadTrustedApplications: false, + canReadHostIsolationExceptions: true, + }) + ); + + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + + expect(filteredLinks).toEqual(getLinksWithout(SecurityPageName.trustedApps)); + }); + + it('shows Trusted Applications for user with privilege', async () => { + (calculateEndpointAuthz as jest.Mock).mockReturnValue(getEndpointAuthzInitialStateMock()); + + const filteredLinks = await getManagementFilteredLinks(coreMockStarted, getPlugins([])); + + expect(filteredLinks).toEqual(links); + }); }); describe('Endpoint List', () => { it('should return all but endpoints link when no Endpoint List READ access', async () => { diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 75e0c0bdc7383..fa564cc67a338 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -273,17 +273,21 @@ export const getManagementFilteredLinks = async ( ); } - const { canReadActionsLogManagement, canReadHostIsolationExceptions, canReadEndpointList } = - fleetAuthz - ? calculateEndpointAuthz( - licenseService, - fleetAuthz, - currentUser.roles, - isEndpointRbacEnabled, - endpointPermissions, - hasHostIsolationExceptions - ) - : getEndpointAuthzInitialState(); + const { + canReadActionsLogManagement, + canReadHostIsolationExceptions, + canReadEndpointList, + canReadTrustedApplications, + } = fleetAuthz + ? calculateEndpointAuthz( + licenseService, + fleetAuthz, + currentUser.roles, + isEndpointRbacEnabled, + endpointPermissions, + hasHostIsolationExceptions + ) + : getEndpointAuthzInitialState(); if (!canReadEndpointList) { linksToExclude.push(SecurityPageName.endpoints); @@ -297,5 +301,9 @@ export const getManagementFilteredLinks = async ( linksToExclude.push(SecurityPageName.hostIsolationExceptions); } + if (endpointRbacEnabled && !canReadTrustedApplications) { + linksToExclude.push(SecurityPageName.trustedApps); + } + return excludeLinks(linksToExclude); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx index 55992f5ffac03..6548dd8b0d590 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx @@ -15,8 +15,11 @@ import { TrustedAppsList } from './trusted_apps_list'; import { exceptionsListAllHttpMocks } from '../../../mocks/exceptions_list_http_mocks'; import { SEARCHABLE_FIELDS } from '../constants'; import { parseQueryFilterToKQL } from '../../../common/utils'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import type { EndpointPrivileges } from '../../../../../common/endpoint/types'; jest.mock('../../../../common/components/user_privileges'); +const mockUserPrivileges = useUserPrivileges as jest.Mock; describe('When on the trusted applications page', () => { let render: () => ReturnType; @@ -24,6 +27,7 @@ describe('When on the trusted applications page', () => { let history: AppContextTestRender['history']; let mockedContext: AppContextTestRender; let apiMocks: ReturnType; + let mockedEndpointPrivileges: Partial; beforeEach(() => { mockedContext = createAppRootMockRenderer(); @@ -35,6 +39,13 @@ describe('When on the trusted applications page', () => { act(() => { history.push(TRUSTED_APPS_PATH); }); + + mockedEndpointPrivileges = { canWriteTrustedApplications: true }; + mockUserPrivileges.mockReturnValue({ endpointPrivileges: mockedEndpointPrivileges }); + }); + + afterEach(() => { + mockUserPrivileges.mockReset(); }); it('should search using expected exception item fields', async () => { @@ -59,4 +70,60 @@ describe('When on the trusted applications page', () => { }) ); }); + + describe('RBAC Trusted Applications', () => { + describe('ALL privilege', () => { + beforeEach(() => { + mockedEndpointPrivileges.canWriteTrustedApplications = true; + }); + + it('should enable adding entries', async () => { + render(); + + await waitFor(() => + expect(renderResult.queryByTestId('trustedAppsListPage-pageAddButton')).toBeTruthy() + ); + }); + + it('should enable modifying/deleting entries', async () => { + render(); + + const actionsButton = await waitFor( + () => renderResult.getAllByTestId('trustedAppsListPage-card-header-actions-button')[0] + ); + userEvent.click(actionsButton); + + expect(renderResult.getByTestId('trustedAppsListPage-card-cardEditAction')).toBeTruthy(); + expect(renderResult.getByTestId('trustedAppsListPage-card-cardDeleteAction')).toBeTruthy(); + }); + }); + + describe('READ privilege', () => { + beforeEach(() => { + mockedEndpointPrivileges.canWriteTrustedApplications = false; + }); + + it('should disable adding entries', async () => { + render(); + + await waitFor(() => + expect(renderResult.queryByTestId('trustedAppsListPage-container')).toBeTruthy() + ); + + expect(renderResult.queryByTestId('trustedAppsListPage-pageAddButton')).toBeNull(); + }); + + it('should disable modifying/deleting entries', async () => { + render(); + + await waitFor(() => + expect(renderResult.queryByTestId('trustedAppsListPage-container')).toBeTruthy() + ); + + expect( + renderResult.queryByTestId('trustedAppsListPage-card-header-actions-button') + ).toBeNull(); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx index 33912a5b795c4..4695f938249e9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx @@ -11,6 +11,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { DocLinks } from '@kbn/doc-links'; import { EuiLink } from '@elastic/eui'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { useHttp } from '../../../../common/lib/kibana'; import type { ArtifactListPageProps } from '../../../components/artifact_list_page'; import { ArtifactListPage } from '../../../components/artifact_list_page'; @@ -108,6 +109,7 @@ const TRUSTED_APPS_PAGE_LABELS: ArtifactListPageProps['labels'] = { }; export const TrustedAppsList = memo(() => { + const { canWriteTrustedApplications } = useUserPrivileges().endpointPrivileges; const http = useHttp(); const trustedAppsApiClient = TrustedAppsApiClient.getInstance(http); @@ -119,6 +121,9 @@ export const TrustedAppsList = memo(() => { data-test-subj="trustedAppsListPage" searchableFields={SEARCHABLE_FIELDS} secondaryPageInfo={} + allowCardDeleteAction={canWriteTrustedApplications} + allowCardEditAction={canWriteTrustedApplications} + allowCardCreateAction={canWriteTrustedApplications} /> ); }); From 6d9811ec7dcd732bc6a5984c35cf628e8ca588f8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 23 Nov 2022 14:56:44 +0200 Subject: [PATCH 08/83] [Cases] Enhance labels in the bulk edit tags flyout (#145811) ## Summary This PR a) fixes the label when there are no tags, b) adds a label when there are no matches, c) removes a `useEffect` which is not necessary anymore, and d) shortens the help text for the tags field. Fixes: https://github.com/elastic/kibana/issues/145214 ## Screenshots Screenshot 2022-11-19 at 12 57 50 PM Screenshot 2022-11-19 at 12 45 55 PM ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: lcawl Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../cases/public/common/translations.ts | 19 ++++- .../tags/edit_tags_selectable.test.tsx | 45 +++++++++- .../actions/tags/edit_tags_selectable.tsx | 83 ++++++------------- .../components/actions/tags/translations.ts | 10 ++- .../components/assign_users.test.tsx | 6 +- .../case_view/components/edit_tags.tsx | 1 + .../components/case_view/translations.ts | 2 +- .../cases/public/components/create/tags.tsx | 2 + 8 files changed, 101 insertions(+), 67 deletions(-) diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 9748339878222..08f7c0aa4e8d0 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -143,8 +143,7 @@ export const COMMENTS = i18n.translate('xpack.cases.allCases.comments', { }); export const TAGS_HELP = i18n.translate('xpack.cases.createCase.fieldTagsHelpText', { - defaultMessage: - 'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.', + defaultMessage: 'Separate tags with a line break.', }); export const TAGS_EMPTY_ERROR = i18n.translate('xpack.cases.createCase.fieldTagsEmptyError', { @@ -152,7 +151,7 @@ export const TAGS_EMPTY_ERROR = i18n.translate('xpack.cases.createCase.fieldTags }); export const NO_TAGS = i18n.translate('xpack.cases.caseView.noTags', { - defaultMessage: 'No tags are currently assigned to this case.', + defaultMessage: 'No tags are added', }); export const TITLE_REQUIRED = i18n.translate('xpack.cases.createCase.titleFieldRequiredError', { @@ -302,3 +301,17 @@ export const DELETED_CASES = (totalCases: number) => values: { totalCases }, defaultMessage: 'Deleted {totalCases, plural, =1 {case} other {{totalCases} cases}}', }); + +export const ADD_TAG_CUSTOM_OPTION_LABEL = (searchValue: string) => + i18n.translate('xpack.cases.configure.addTagCustomOptionLabel', { + defaultMessage: 'Add {searchValue} as a tag', + values: { searchValue }, + }); + +/** + * EUI checkbox replace {searchValue} with the current + * search value. We need to put the template variable + * searchValue in the string but not replace it + * with i18n. + */ +export const ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX = ADD_TAG_CUSTOM_OPTION_LABEL('{searchValue}'); diff --git a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.test.tsx b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.test.tsx index 9eea326ba389b..c18642738b376 100644 --- a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.test.tsx +++ b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.test.tsx @@ -237,7 +237,7 @@ describe('EditTagsSelectable', () => { }); }); - it('adds a partial match correctly', async () => { + it('adds a partial match correctly and does not show the no match label', async () => { const result = appMock.render(); /** @@ -252,6 +252,10 @@ describe('EditTagsSelectable', () => { ).toBeInTheDocument(); }); + expect( + result.queryByTestId('cases-actions-tags-edit-selectable-no-match-label') + ).not.toBeInTheDocument(); + const addNewTagButton = result.getByTestId('cases-actions-tags-edit-selectable-add-new-tag'); userEvent.click(addNewTagButton); @@ -275,4 +279,43 @@ describe('EditTagsSelectable', () => { result.queryByTestId('cases-actions-tags-edit-selectable-add-new-tag') ).not.toBeInTheDocument(); }); + + it('does not show the no match label when the initial tags are empty', async () => { + const result = appMock.render(); + + await waitForComponentToUpdate(); + + expect( + result.queryByTestId('cases-actions-tags-edit-selectable-no-match-label') + ).not.toBeInTheDocument(); + }); + + it('shows the no match label when there is no match', async () => { + const result = appMock.render(); + + await userEvent.type(result.getByPlaceholderText('Search'), 'not-exist', { delay: 1 }); + await waitForComponentToUpdate(); + + expect( + result.getByTestId('cases-actions-tags-edit-selectable-no-match-label') + ).toBeInTheDocument(); + }); + + it('shows the no match label and the add new item when there is space in the search term', async () => { + const result = appMock.render(); + + await userEvent.type(result.getByPlaceholderText('Search'), 'test tag', { delay: 1 }); + + await waitFor(() => { + expect( + result.getByTestId('cases-actions-tags-edit-selectable-add-new-tag') + ).toBeInTheDocument(); + }); + + await waitForComponentToUpdate(); + + expect( + result.getByTestId('cases-actions-tags-edit-selectable-no-match-label') + ).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx index bb7b167814c6b..1bd1ba4b74e88 100644 --- a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx +++ b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useReducer, useState, useEffect } from 'react'; +import React, { useCallback, useMemo, useReducer, useState } from 'react'; import type { EuiSelectableOption, IconType } from '@elastic/eui'; import { EuiSelectable, @@ -17,11 +17,9 @@ import { EuiHorizontalRule, EuiIcon, EuiHighlight, - EuiSelectableListItem, useEuiTheme, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; import { assertNever } from '@kbn/std'; import { isEmpty } from 'lodash'; import type { Case } from '../../../../common'; @@ -237,29 +235,9 @@ const hasExactMatch = (searchValue: string, options: TagSelectableOption[]) => { return options.some((option) => option.key === searchValue); }; -const AddNewTagItem: React.FC<{ searchValue: string; onNewItem: (newTag: string) => void }> = - React.memo(({ searchValue, onNewItem }) => { - const onNewTagClick = useCallback(() => { - onNewItem(searchValue); - }, [onNewItem, searchValue]); - - return ( - - {searchValue} }} - /> - - ); - }); - -AddNewTagItem.displayName = 'AddNewTagItem'; +const hasPartialMatch = (searchValue: string, options: TagSelectableOption[]) => { + return options.some((option) => option.key?.includes(searchValue)); +}; const EditTagsSelectableComponent: React.FC = ({ selectedCases, @@ -328,16 +306,6 @@ const EditTagsSelectableComponent: React.FC = ({ [onChangeTags, state.tags] ); - const onNewItem = useCallback( - (newTag: string) => { - const { selectedTags, unSelectedTags } = getSelectedAndUnselectedTags(options, state.tags); - dispatch({ type: Actions.CHECK_TAG, payload: [newTag] }); - setSearchValue(''); - onChangeTags({ selectedTags: [...selectedTags, newTag], unSelectedTags }); - }, - [onChangeTags, options, state.tags] - ); - const onSelectAll = useCallback(() => { dispatch({ type: Actions.CHECK_TAG, payload: Object.keys(state.tags) }); onChangeTags({ selectedTags: Object.keys(state.tags), unSelectedTags: [] }); @@ -360,25 +328,6 @@ const EditTagsSelectableComponent: React.FC = ({ setSearchValue(value); }, []); - /** - * TODO: Remove hack when PR https://github.com/elastic/eui/pull/6317 - * is merged and the new fix is merged into Kibana. - * - * This is a hack to force a rerender when - * the user adds a new tag. There is a bug in - * the EuiSelectable where a race condition that's causing the search bar - * to not to match terms with the empty string to trigger the reload. - * This means that when a user press the button to add a tag the - * search bar clears but the options are not shown. - */ - const [_, setRerender] = useState(0); - - useEffect(() => { - if (isEmpty(searchValue)) { - setRerender((x) => x + 1); - } - }, [options, setRerender, searchValue]); - /** * While the user searches we need to add the ability * to add the search term as a new tag. The no matches message @@ -393,8 +342,7 @@ const EditTagsSelectableComponent: React.FC = ({ return [ { key: searchValue, - searchableLabel: searchValue, - label: `Add ${searchValue} as a tag`, + label: i18n.ADD_TAG_CUSTOM_OPTION_LABEL(searchValue), 'data-test-subj': 'cases-actions-tags-edit-selectable-add-new-tag', data: { tagIcon: 'empty', newItem: true }, }, @@ -409,6 +357,11 @@ const EditTagsSelectableComponent: React.FC = ({ (tag) => tag.tagState === TagState.CHECKED || tag.tagState === TagState.PARTIAL ).length; + const showNoMatchText = useMemo( + () => !hasPartialMatch(searchValue, options) && Object.keys(state.tags).length > 0, + [options, searchValue, state.tags] + ); + return ( = ({ renderOption={renderOption} listProps={{ showIcons: false }} onChange={onChange} - noMatchesMessage={} + noMatchesMessage={i18n.NO_SEARCH_MATCH} + emptyMessage={i18n.NO_TAGS_AVAILABLE} data-test-subj="cases-actions-tags-edit-selectable" height="full" > @@ -483,6 +437,19 @@ const EditTagsSelectableComponent: React.FC = ({ + {showNoMatchText ? ( + + {i18n.NO_SEARCH_MATCH} + + ) : null} {list} )} diff --git a/x-pack/plugins/cases/public/components/actions/tags/translations.ts b/x-pack/plugins/cases/public/components/actions/tags/translations.ts index 61aadf41129a2..a00292ff11cb4 100644 --- a/x-pack/plugins/cases/public/components/actions/tags/translations.ts +++ b/x-pack/plugins/cases/public/components/actions/tags/translations.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -export { CANCEL } from '../../../common/translations'; +export { CANCEL, ADD_TAG_CUSTOM_OPTION_LABEL } from '../../../common/translations'; export const EDIT_TAGS = i18n.translate('xpack.cases.actions.tags.edit', { defaultMessage: 'Edit tags', @@ -51,3 +51,11 @@ export const SELECTED_TAGS = (selectedTags: number) => defaultMessage: 'Selected: {selectedTags}', values: { selectedTags }, }); + +export const NO_TAGS_AVAILABLE = i18n.translate('xpack.cases.actions.tags.noTagsAvailable', { + defaultMessage: 'No tags available. To add a tag, enter it in the query bar', +}); + +export const NO_SEARCH_MATCH = i18n.translate('xpack.cases.actions.tags.noTagsMatch', { + defaultMessage: 'No tags match your search', +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx index b14e52c9e51dc..2f90aff69c60e 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/assign_users.test.tsx @@ -46,7 +46,7 @@ describe('AssignUsers', () => { it('does not show any assignees when there are none assigned', () => { appMockRender.render(); - expect(screen.getByText('No users have been assigned.')).toBeInTheDocument(); + expect(screen.getByText('No users are assigned')).toBeInTheDocument(); }); it('does not show the suggest users edit button when the user does not have update permissions', () => { @@ -95,7 +95,7 @@ describe('AssignUsers', () => { expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); expect(screen.queryByText('Wet Dingo')).not.toBeInTheDocument(); - expect(screen.queryByText('No users have been assigned.')).not.toBeInTheDocument(); + expect(screen.queryByText('No users are assigned')).not.toBeInTheDocument(); expect(screen.queryByTestId('case-view-assignees-loading')).not.toBeInTheDocument(); }); @@ -112,7 +112,7 @@ describe('AssignUsers', () => { expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument(); expect(screen.getByText('Physical Dinosaur')).toBeInTheDocument(); expect(screen.queryByText('Wet Dingo')).not.toBeInTheDocument(); - expect(screen.queryByText('No users have been assigned.')).not.toBeInTheDocument(); + expect(screen.queryByText('No users are assigned')).not.toBeInTheDocument(); expect(screen.queryByTestId('case-view-assignees-loading')).not.toBeInTheDocument(); }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx index 2f8567d14f08d..a221a43b92ded 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/edit_tags.tsx @@ -147,6 +147,7 @@ export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps placeholder: '', options, noSuggestions: false, + customOptionText: i18n.ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX, }, }} /> diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index c4d0020aa7107..d71c56fc97fca 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -186,7 +186,7 @@ export const EDIT_ASSIGNEES_ARIA_LABEL = i18n.translate( ); export const NO_ASSIGNEES = i18n.translate('xpack.cases.caseView.noAssignees', { - defaultMessage: 'No users have been assigned.', + defaultMessage: 'No users are assigned', }); export const ASSIGN_A_USER = i18n.translate('xpack.cases.caseView.assignUser', { diff --git a/x-pack/plugins/cases/public/components/create/tags.tsx b/x-pack/plugins/cases/public/components/create/tags.tsx index 6bae6015e769b..f3d4319dfea37 100644 --- a/x-pack/plugins/cases/public/components/create/tags.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.tsx @@ -10,6 +10,7 @@ import React, { memo, useMemo } from 'react'; import { getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { useGetTags } from '../../containers/use_get_tags'; +import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); @@ -39,6 +40,7 @@ const TagsComponent: React.FC = ({ isLoading }) => { disabled: isLoading || isLoadingTags, options, noSuggestions: false, + customOptionText: i18n.ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX, }, }} /> From a7df8d10334fdb1523ed2530fe8f34095fda5c58 Mon Sep 17 00:00:00 2001 From: Khristinin Nikita Date: Wed, 23 Nov 2022 14:28:24 +0100 Subject: [PATCH 09/83] Fix placeholder for search bar (#146110) ## Fix placeholder for search bar, which shows how to search for list_id FIX: https://github.com/elastic/kibana/issues/145674 --- .../public/exceptions/translations/shared_list.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts index bb3fbd5663dae..ecb95b373d279 100644 --- a/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts +++ b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts @@ -163,7 +163,7 @@ export const referenceErrorMessage = (referenceCount: number) => export const EXCEPTION_LIST_SEARCH_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.all.exceptions.searchPlaceholder', { - defaultMessage: 'Search by name or list id', + defaultMessage: 'Search by name or list_id:id', } ); From a722aad6a4f96c69a2588c3e12d9227ae242eaea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 23 Nov 2022 08:44:43 -0500 Subject: [PATCH 10/83] Update dependency elastic-apm-node to ^3.40.1 (main) (#146070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [elastic-apm-node](https://togithub.com/elastic/apm-agent-nodejs) | [`^3.40.0` -> `^3.40.1`](https://renovatebot.com/diffs/npm/elastic-apm-node/3.40.0/3.40.1) | [![age](https://badges.renovateapi.com/packages/npm/elastic-apm-node/3.40.1/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/elastic-apm-node/3.40.1/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/elastic-apm-node/3.40.1/compatibility-slim/3.40.0)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/elastic-apm-node/3.40.1/confidence-slim/3.40.0)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes

elastic/apm-agent-nodejs ### [`v3.40.1`](https://togithub.com/elastic/apm-agent-nodejs/releases/tag/v3.40.1) [Compare Source](https://togithub.com/elastic/apm-agent-nodejs/compare/v3.40.0...v3.40.1) For more information, please see the [changelog](https://www.elastic.co/guide/en/apm/agent/nodejs/current/release-notes-3.x.html#release-notes-3.40.1). ##### Elastic APM Node.js agent layer ARNs |Region|ARN| |------|---| |af-south-1|arn:aws:lambda:af-south-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-east-1|arn:aws:lambda:ap-east-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-northeast-1|arn:aws:lambda:ap-northeast-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-northeast-2|arn:aws:lambda:ap-northeast-2:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-northeast-3|arn:aws:lambda:ap-northeast-3:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-south-1|arn:aws:lambda:ap-south-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-southeast-1|arn:aws:lambda:ap-southeast-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-southeast-2|arn:aws:lambda:ap-southeast-2:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ap-southeast-3|arn:aws:lambda:ap-southeast-3:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |ca-central-1|arn:aws:lambda:ca-central-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |eu-central-1|arn:aws:lambda:eu-central-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |eu-north-1|arn:aws:lambda:eu-north-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |eu-south-1|arn:aws:lambda:eu-south-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |eu-west-1|arn:aws:lambda:eu-west-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |eu-west-2|arn:aws:lambda:eu-west-2:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |eu-west-3|arn:aws:lambda:eu-west-3:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |me-south-1|arn:aws:lambda:me-south-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |sa-east-1|arn:aws:lambda:sa-east-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |us-east-1|arn:aws:lambda:us-east-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |us-east-2|arn:aws:lambda:us-east-2:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |us-west-1|arn:aws:lambda:us-west-1:267093732750:layer:elastic-apm-node-ver-3-40-1:1| |us-west-2|arn:aws:lambda:us-west-2:267093732750:layer:elastic-apm-node-ver-3-40-1:1|
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://app.renovatebot.com/dashboard#github/elastic/kibana). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 502a562a39a5a..608d17d10d667 100644 --- a/package.json +++ b/package.json @@ -501,7 +501,7 @@ "deepmerge": "^4.2.2", "del": "^6.1.0", "elastic-apm-http-client": "^11.0.1", - "elastic-apm-node": "^3.40.0", + "elastic-apm-node": "^3.40.1", "email-addresses": "^5.0.0", "execa": "^4.0.2", "expiry-js": "0.1.7", diff --git a/yarn.lock b/yarn.lock index 7ad126c926fdb..c9b082c63b66d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12760,7 +12760,22 @@ elastic-apm-http-client@11.0.2, elastic-apm-http-client@^11.0.1: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.38.0, elastic-apm-node@^3.40.0: +elastic-apm-http-client@11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.3.tgz#1d357af449d66695ef10019c21efe6377ad8815e" + integrity sha512-y+P9ByvfxjZbnLejgGaCAnwEe+FWMVshoMmjeLEEEVlQTLiFUHy7vhYyCQVqgbZzQ6zpaGPqPU2woKglKW4RHw== + dependencies: + agentkeepalive "^4.2.1" + breadth-filter "^2.0.0" + end-of-stream "^1.4.4" + fast-safe-stringify "^2.0.7" + fast-stream-to-buffer "^1.0.0" + object-filter-sequence "^1.0.0" + readable-stream "^3.4.0" + semver "^6.3.0" + stream-chopper "^3.0.1" + +elastic-apm-node@^3.38.0: version "3.40.0" resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.40.0.tgz#ed805ec817db7687ba9a77bcc0db6131e8cbc8cf" integrity sha512-gs9Z7boZW2o3ZMVbdjoJKXv4F2AcfMh52DW1WxEE/FSFa6lymj6GmCEFywuP8SqdpRZbh6yohJoGOpl7sheNJg== @@ -12798,6 +12813,44 @@ elastic-apm-node@^3.38.0, elastic-apm-node@^3.40.0: traverse "^0.6.6" unicode-byte-truncate "^1.0.0" +elastic-apm-node@^3.40.1: + version "3.40.1" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.40.1.tgz#ae3669d480fdacf62ace40d12a6f1a3c46b37940" + integrity sha512-vdyEZ7BPKJP2a1PkCsg350XXGZj03bwOiGrZdqgflocYxns5QwFbhvMKaVq7hWWWS8/sACesrLLELyQgdOpFsw== + dependencies: + "@elastic/ecs-pino-format" "^1.2.0" + "@opentelemetry/api" "^1.1.0" + after-all-results "^2.0.0" + async-cache "^1.1.0" + async-value-promise "^1.1.1" + basic-auth "^2.0.1" + cookie "^0.5.0" + core-util-is "^1.0.2" + elastic-apm-http-client "11.0.3" + end-of-stream "^1.4.4" + error-callsites "^2.0.4" + error-stack-parser "^2.0.6" + escape-string-regexp "^4.0.0" + fast-safe-stringify "^2.0.7" + http-headers "^3.0.2" + is-native "^1.0.1" + lru-cache "^6.0.0" + measured-reporting "^1.51.1" + monitor-event-loop-delay "^1.0.0" + object-filter-sequence "^1.0.0" + object-identity-map "^1.0.2" + original-url "^1.2.3" + pino "^6.11.2" + relative-microtime "^2.0.0" + require-in-the-middle "^5.2.0" + semver "^6.3.0" + set-cookie-serde "^1.0.0" + shallow-clone-shim "^2.0.0" + source-map "^0.8.0-beta.0" + sql-summary "^1.0.1" + traverse "^0.6.6" + unicode-byte-truncate "^1.0.0" + elasticsearch@^16.4.0: version "16.7.0" resolved "https://registry.yarnpkg.com/elasticsearch/-/elasticsearch-16.7.0.tgz#9055e3f586934d8de5fd407b04050e9d54173333" From 0f061f0203737a11368c0eefa0a31529b99a2113 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Wed, 23 Nov 2022 14:59:26 +0100 Subject: [PATCH 11/83] [Lens] Log counter_rate column only once in the inspector (#146056) ## Summary Fixes #145746 The bug was in an expression utility which was appending a clone of an existing column within the `time_scale` evaluation. The fix was to ensure the column was actually overwritten rather than duplicated in that specific case. Now the `Counter rate` function is no longer duplicated in the inspector: Screenshot 2022-11-22 at 19 47 49 Created also some unit tests for the utility function. ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Stratoula Kalafateli --- .../series_calculation_helpers.test.ts | 66 +++++++++++++++++++ .../series_calculation_helpers.ts | 14 +++- 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/plugins/expressions/common/expression_functions/series_calculation_helpers.test.ts diff --git a/src/plugins/expressions/common/expression_functions/series_calculation_helpers.test.ts b/src/plugins/expressions/common/expression_functions/series_calculation_helpers.test.ts new file mode 100644 index 0000000000000..2a717ee1c7414 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/series_calculation_helpers.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { buildResultColumns, Datatable } from '..'; + +describe('buildResultColumns', () => { + function getDatatable(columns: Datatable['columns']): Datatable { + const row: Datatable['rows'][number] = {}; + for (const { id, meta } of columns) { + row[id] = meta.type === 'number' ? 5 : 'a'; + } + return { + type: 'datatable', + columns, + rows: Array(5).fill(row), + }; + } + it('should append the new output column', () => { + const newColumns = buildResultColumns( + getDatatable([{ id: 'inputId', name: 'value', meta: { type: 'number' } }]), + 'outputId', + 'inputId', + undefined + ); + expect(newColumns).not.toBeUndefined(); + expect(newColumns).toHaveLength(2); + expect(newColumns![1]).toEqual({ id: 'outputId', name: 'outputId', meta: { type: 'number' } }); + }); + + it('should create a new column with the passed name', () => { + const newColumns = buildResultColumns( + getDatatable([{ id: 'inputId', name: 'value', meta: { type: 'number' } }]), + 'outputId', + 'inputId', + 'newName' + ); + expect(newColumns![1]).toEqual({ id: 'outputId', name: 'newName', meta: { type: 'number' } }); + }); + + it('should throw if same id is passed for input and output', () => { + expect(() => + buildResultColumns( + getDatatable([{ id: 'inputId', name: 'value', meta: { type: 'number' } }]), + 'inputId', + 'inputId', + undefined + ) + ).toThrow(); + }); + + it('should overwrite column with the correct flag', () => { + const newColumns = buildResultColumns( + getDatatable([{ id: 'inputId', name: 'value', meta: { type: 'number' } }]), + 'inputId', + 'inputId', + undefined, + { allowColumnOverwrite: true } + ); + expect(newColumns).toHaveLength(1); + }); +}); diff --git a/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts b/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts index 11f844920a0df..db98a57a850e3 100644 --- a/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts +++ b/src/plugins/expressions/common/expression_functions/series_calculation_helpers.ts @@ -65,7 +65,17 @@ export function buildResultColumns( }; const resultColumns = [...input.columns]; - // add output column after input column in the table - resultColumns.splice(resultColumns.indexOf(inputColumnDefinition) + 1, 0, outputColumnDefinition); + + // If input and output are the same, replace the input column with the output one + // otherwise add output column after input column in the table + const offset = inputColumnId === outputColumnId ? 0 : 1; + // replace 1 item in case of same column, otherwise just append + const replacingItems = inputColumnId === outputColumnId ? 1 : 0; + resultColumns.splice( + resultColumns.indexOf(inputColumnDefinition) + offset, + replacingItems, + outputColumnDefinition + ); + return resultColumns; } From 584752f36693d2e9af56605f2e914285a1d4d62d Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Wed, 23 Nov 2022 09:11:05 -0500 Subject: [PATCH 12/83] [Security Solution] [Rules] Fixes bug where editing a rule with a data view throws an unhandled exception (#145658) ## Summary Ref: https://github.com/elastic/kibana/issues/145078 --- .../custom_query_rule_data_view.cy.ts | 26 +++++++++++++++++++ .../cypress/screens/create_new_rule.ts | 2 ++ .../cypress/tasks/create_new_rule.ts | 8 ++++++ .../rules/step_define_rule/index.tsx | 5 +++- .../rules/step_rule_actions/index.tsx | 1 + 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts index 727c7257b6682..04e08d5de572a 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts @@ -20,6 +20,11 @@ import { RULE_SWITCH, SEVERITY, } from '../../screens/alerts_detection_rules'; +import { + ABOUT_CONTINUE_BTN, + RULE_DESCRIPTION_INPUT, + RULE_NAME_INPUT, +} from '../../screens/create_new_rule'; import { ADDITIONAL_LOOK_BACK_DETAILS, @@ -44,6 +49,7 @@ import { TAGS_DETAILS, TIMELINE_TEMPLATE_DETAILS, DATA_VIEW_DETAILS, + EDIT_RULE_SETTINGS_LINK, } from '../../screens/rule_details'; import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; @@ -51,6 +57,7 @@ import { createTimeline } from '../../tasks/api_calls/timelines'; import { postDataView } from '../../tasks/common'; import { createAndEnableRule, + createRuleWithoutEnabling, fillAboutRuleAndContinue, fillDefineCustomRuleAndContinue, fillScheduleRuleAndContinue, @@ -158,5 +165,24 @@ describe('Custom query rules', () => { .should('match', /^[1-9].+$/); cy.get(ALERT_GRID_CELL).contains(this.rule.name); }); + it('Creates and edits a new rule with a data view', function () { + visit(RULE_CREATION); + fillDefineCustomRuleAndContinue(this.rule); + cy.get(RULE_NAME_INPUT).clear({ force: true }).type(this.rule.name, { force: true }); + cy.get(RULE_DESCRIPTION_INPUT) + .clear({ force: true }) + .type(this.rule.description, { force: true }); + + cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + + fillScheduleRuleAndContinue(this.rule); + createRuleWithoutEnabling(); + + goToRuleDetails(); + + cy.get(EDIT_RULE_SETTINGS_LINK).click({ force: true }); + + cy.get(RULE_NAME_HEADER).should('contain', 'Edit rule settings'); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index c57e2c603e461..1d59f2ce83ce1 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -32,6 +32,8 @@ export const COMBO_BOX_CLEAR_BTN = '[data-test-subj="comboBoxClearButton"]'; export const CREATE_AND_ENABLE_BTN = '[data-test-subj="create-enable"]'; +export const CREATE_WITHOUT_ENABLING_BTN = '[data-test-subj="create-enabled-false"]'; + export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const CUSTOM_QUERY_BAR = '[data-test-subj="detectionEngineStepDefineRuleQueryBar"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index fc1f3b389bdf5..fe3809f1d3cc7 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -104,6 +104,7 @@ import { NEW_TERMS_INPUT_AREA, ACTIONS_THROTTLE_INPUT, CONTINUE_BUTTON, + CREATE_WITHOUT_ENABLING_BTN, } from '../screens/create_new_rule'; import { INDEX_SELECTOR, @@ -125,6 +126,13 @@ export const createAndEnableRule = () => { cy.get(BACK_TO_ALL_RULES_LINK).should('not.exist'); }; +export const createRuleWithoutEnabling = () => { + cy.get(CREATE_WITHOUT_ENABLING_BTN).click({ force: true }); + cy.get(CREATE_WITHOUT_ENABLING_BTN).should('not.exist'); + cy.get(BACK_TO_ALL_RULES_LINK).click({ force: true }); + cy.get(BACK_TO_ALL_RULES_LINK).should('not.exist'); +}; + export const fillAboutRule = ( rule: CustomRule | MachineLearningRule | ThresholdRule | ThreatIndicatorRule ) => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 8201863171227..e8fec6d0e9186 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiLoadingSpinner, EuiSpacer, EuiButtonGroup, EuiText, @@ -502,7 +503,9 @@ const StepDefineRuleComponent: FC = ({ ); const DataViewSelectorMemo = useMemo(() => { - return ( + return kibanaDataViews == null || Object.keys(kibanaDataViews).length === 0 ? ( + + ) : ( = ({ isDisabled={isLoading} isLoading={isLoading} onClick={() => handleSubmit(false)} + data-test-subj="create-enabled-false" > {I18n.COMPLETE_WITHOUT_ENABLING} From a1314b48313850081d9e7a47bea944fec4c233fe Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 23 Nov 2022 09:13:13 -0500 Subject: [PATCH 13/83] [Security Solution][Endpoint] Update endpoint artifacts APIs (via Lists Plugin) to support RBAC (#145927) ## Summary - Adds new constant to `@kbn/securitysolution-list-constants` that holds all of the Endpoint artifact list definitions and also exports a new const with the IDs of all of the Artifact list IDs. - Updates the List create list internal API schema (in `@kbn-securitysolution-io-ts-list-types`) to use new list of endpoint artifact list IDs - Update was also made in `const` defined under Security Solution plugin - Updates the security solution kibana sub-feature privileges to include the needed entries for enabling the Lists plugin (which is used for artifact CRUD) - Relax the auths to the `/internal/api/exception_lists/_create` to only require `read`, since this API is needed to ensure lists are created prior to being able to query their data --- .../create_exception_list_schema/index.ts | 21 ++--- .../BUILD.bazel | 5 +- .../index.ts | 84 +++++++++++++++---- .../internal/create_exceptions_list_route.ts | 5 +- .../endpoint/service/artifacts/constants.ts | 15 +--- .../security_solution/server/features.ts | 48 ++++++++--- .../host_isolation_exceptions_validator.ts | 45 ++++------ 7 files changed, 134 insertions(+), 89 deletions(-) diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/request/internal/create_exception_list_schema/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/request/internal/create_exception_list_schema/index.ts index 78d61436306aa..b19662bcb4600 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/request/internal/create_exception_list_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/request/internal/create_exception_list_schema/index.ts @@ -6,12 +6,7 @@ * Side Public License, v 1. */ -import { - ENDPOINT_BLOCKLISTS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, - ENDPOINT_TRUSTED_APPS_LIST_ID, -} from '@kbn/securitysolution-list-constants'; +import { ENDPOINT_ARTIFACT_LIST_IDS } from '@kbn/securitysolution-list-constants'; import * as t from 'io-ts'; import { @@ -32,13 +27,13 @@ export const internalCreateExceptionListSchema = t.intersection([ ), t.exact( t.partial({ - // TODO: Move the ALL_ENDPOINT_ARTIFACT_LIST_IDS inside the package and use it here instead - list_id: t.keyof({ - [ENDPOINT_TRUSTED_APPS_LIST_ID]: null, - [ENDPOINT_EVENT_FILTERS_LIST_ID]: null, - [ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: null, - [ENDPOINT_BLOCKLISTS_LIST_ID]: null, - }), + list_id: t.keyof( + ENDPOINT_ARTIFACT_LIST_IDS.reduce>((mapOfListIds, listId) => { + mapOfListIds[listId] = null; + + return mapOfListIds; + }, {}) + ), }) ), createExceptionListSchema, diff --git a/packages/kbn-securitysolution-list-constants/BUILD.bazel b/packages/kbn-securitysolution-list-constants/BUILD.bazel index ba79dbeb420fb..ac40cb7889e8d 100644 --- a/packages/kbn-securitysolution-list-constants/BUILD.bazel +++ b/packages/kbn-securitysolution-list-constants/BUILD.bazel @@ -36,11 +36,14 @@ NPM_MODULE_EXTRA_FILES = [ "README.md", ] -RUNTIME_DEPS = [] +RUNTIME_DEPS = [ + "//packages/kbn-std", +] TYPES_DEPS = [ "@npm//@types/jest", "@npm//@types/node", + "//packages/kbn-std:npm_module_types", ] jsts_transpiler( diff --git a/packages/kbn-securitysolution-list-constants/index.ts b/packages/kbn-securitysolution-list-constants/index.ts index 6a1241564cf30..7bc44b534caf2 100644 --- a/packages/kbn-securitysolution-list-constants/index.ts +++ b/packages/kbn-securitysolution-list-constants/index.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { deepFreeze } from '@kbn/std'; + /** * Value list routes */ @@ -70,30 +72,76 @@ export const MAXIMUM_SMALL_VALUE_LIST_SIZE = 65536; export const MAXIMUM_SMALL_IP_RANGE_VALUE_LIST_DASH_SIZE = 200; -/** ID of trusted apps agnostic list */ -export const ENDPOINT_TRUSTED_APPS_LIST_ID = 'endpoint_trusted_apps'; +/** + * List definitions for Endpoint Artifact + */ +export const ENDPOINT_ARTIFACT_LISTS = deepFreeze({ + trustedApps: { + id: 'endpoint_trusted_apps', + name: 'Endpoint Security Trusted Apps List', + description: 'Endpoint Security Trusted Apps List', + }, + eventFilters: { + id: 'endpoint_event_filters', + name: 'Endpoint Security Event Filters List', + description: 'Endpoint Security Event Filters List', + }, + hostIsolationExceptions: { + id: 'endpoint_host_isolation_exceptions', + name: 'Endpoint Security Host isolation exceptions List', + description: 'Endpoint Security Host isolation exceptions List', + }, + blocklists: { + id: 'endpoint_blocklists', + name: 'Endpoint Security Blocklists List', + description: 'Endpoint Security Blocklists List', + }, +}); + +/** + * The IDs of all Endpoint artifact lists + */ +export const ENDPOINT_ARTIFACT_LIST_IDS = Object.freeze( + Object.values(ENDPOINT_ARTIFACT_LISTS).map(({ id }) => id) +); -/** Name of trusted apps agnostic list */ -export const ENDPOINT_TRUSTED_APPS_LIST_NAME = 'Endpoint Security Trusted Apps List'; +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_TRUSTED_APPS_LIST_ID = ENDPOINT_ARTIFACT_LISTS.trustedApps.id; -/** Description of trusted apps agnostic list */ -export const ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION = 'Endpoint Security Trusted Apps List'; +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_TRUSTED_APPS_LIST_NAME = ENDPOINT_ARTIFACT_LISTS.trustedApps.name; -/** ID of event filters agnostic list */ -export const ENDPOINT_EVENT_FILTERS_LIST_ID = 'endpoint_event_filters'; +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION = + ENDPOINT_ARTIFACT_LISTS.trustedApps.description; -/** Name of event filters agnostic list */ -export const ENDPOINT_EVENT_FILTERS_LIST_NAME = 'Endpoint Security Event Filters List'; +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_EVENT_FILTERS_LIST_ID = ENDPOINT_ARTIFACT_LISTS.eventFilters.id; -/** Description of event filters agnostic list */ -export const ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION = 'Endpoint Security Event Filters List'; +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_EVENT_FILTERS_LIST_NAME = ENDPOINT_ARTIFACT_LISTS.eventFilters.name; -export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID = 'endpoint_host_isolation_exceptions'; +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION = + ENDPOINT_ARTIFACT_LISTS.eventFilters.description; + +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID = + ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id; + +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME = - 'Endpoint Security Host isolation exceptions List'; + ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.name; + +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION = - 'Endpoint Security Host isolation exceptions List'; + ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.description; + +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_BLOCKLISTS_LIST_ID = ENDPOINT_ARTIFACT_LISTS.blocklists.id; + +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_BLOCKLISTS_LIST_NAME = ENDPOINT_ARTIFACT_LISTS.blocklists.name; -export const ENDPOINT_BLOCKLISTS_LIST_ID = 'endpoint_blocklists'; -export const ENDPOINT_BLOCKLISTS_LIST_NAME = 'Endpoint Security Blocklists List'; -export const ENDPOINT_BLOCKLISTS_LIST_DESCRIPTION = 'Endpoint Security Blocklists List'; +/** @deprecated Use `ENDPOINT_ARTIFACT_LISTS` instead */ +export const ENDPOINT_BLOCKLISTS_LIST_DESCRIPTION = ENDPOINT_ARTIFACT_LISTS.blocklists.description; diff --git a/x-pack/plugins/lists/server/routes/internal/create_exceptions_list_route.ts b/x-pack/plugins/lists/server/routes/internal/create_exceptions_list_route.ts index 198e86ebd1e06..41b684b120da2 100644 --- a/x-pack/plugins/lists/server/routes/internal/create_exceptions_list_route.ts +++ b/x-pack/plugins/lists/server/routes/internal/create_exceptions_list_route.ts @@ -20,7 +20,10 @@ export const internalCreateExceptionListRoute = (router: ListsPluginRouter): voi router.post( { options: { - tags: ['access:lists-all'], + // Access control is set to `read` on purpose, as this route is internal and meant to + // ensure we have lists created (if not already) for Endpoint artifacts in order to support + // the UI. The Schema ensures that only endpoint artifact list IDs are allowed. + tags: ['access:lists-read'], }, path: INTERNAL_EXCEPTIONS_LIST_ENSURE_CREATED_URL, validate: { diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts index a818e4d56d5b6..fd244d3d90c8e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts @@ -5,23 +5,14 @@ * 2.0. */ -import { - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, - ENDPOINT_BLOCKLISTS_LIST_ID, -} from '@kbn/securitysolution-list-constants'; +import { ENDPOINT_ARTIFACT_LIST_IDS } from '@kbn/securitysolution-list-constants'; export const BY_POLICY_ARTIFACT_TAG_PREFIX = 'policy:'; export const GLOBAL_ARTIFACT_TAG = `${BY_POLICY_ARTIFACT_TAG_PREFIX}all`; -export const ALL_ENDPOINT_ARTIFACT_LIST_IDS: readonly string[] = [ - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, - ENDPOINT_BLOCKLISTS_LIST_ID, -]; +// TODO: refact all uses of `ALL_ENDPOINT_ARTIFACTS_LIST_IDS to sue new const from shared package +export const ALL_ENDPOINT_ARTIFACT_LIST_IDS = ENDPOINT_ARTIFACT_LIST_IDS; export const DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS: Readonly = [ `name`, diff --git a/x-pack/plugins/security_solution/server/features.ts b/x-pack/plugins/security_solution/server/features.ts index 4711e978a25fe..08cd06890f298 100644 --- a/x-pack/plugins/security_solution/server/features.ts +++ b/x-pack/plugins/security_solution/server/features.ts @@ -12,6 +12,7 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; import { createUICapabilities } from '@kbn/cases-plugin/common'; +import { EXCEPTION_LIST_NAMESPACE_AGNOSTIC } from '@kbn/securitysolution-list-constants'; import { APP_ID, CASES_FEATURE_ID, SERVER_APP_ID } from '../common/constants'; import { savedObjectTypes } from './saved_objects'; import type { ConfigType } from './config'; @@ -296,18 +297,24 @@ const subFeatures: SubFeatureConfig[] = [ groupType: 'mutually_exclusive', privileges: [ { - api: [`${APP_ID}-writeTrustedApplications`, `${APP_ID}-readTrustedApplications`], + api: [ + 'lists-all', + 'lists-read', + 'lists-summary', + `${APP_ID}-writeTrustedApplications`, + `${APP_ID}-readTrustedApplications`, + ], id: 'trusted_applications_all', includeIn: 'none', name: 'All', savedObject: { - all: [], + all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC], read: [], }, ui: ['writeTrustedApplications', 'readTrustedApplications'], }, { - api: [`${APP_ID}-readTrustedApplications`], + api: ['lists-read', 'lists-summary', `${APP_ID}-readTrustedApplications`], id: 'trusted_applications_read', includeIn: 'none', name: 'Read', @@ -341,6 +348,9 @@ const subFeatures: SubFeatureConfig[] = [ privileges: [ { api: [ + 'lists-all', + 'lists-read', + 'lists-summary', `${APP_ID}-writeHostIsolationExceptions`, `${APP_ID}-readHostIsolationExceptions`, ], @@ -348,13 +358,13 @@ const subFeatures: SubFeatureConfig[] = [ includeIn: 'none', name: 'All', savedObject: { - all: [], + all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC], read: [], }, ui: ['writeHostIsolationExceptions', 'readHostIsolationExceptions'], }, { - api: [`${APP_ID}-readHostIsolationExceptions`], + api: ['lists-read', 'lists-summary', `${APP_ID}-readHostIsolationExceptions`], id: 'host_isolation_exceptions_read', includeIn: 'none', name: 'Read', @@ -384,18 +394,24 @@ const subFeatures: SubFeatureConfig[] = [ groupType: 'mutually_exclusive', privileges: [ { - api: [`${APP_ID}-writeBlocklist`, `${APP_ID}-readBlocklist`], + api: [ + 'lists-all', + 'lists-read', + 'lists-summary', + `${APP_ID}-writeBlocklist`, + `${APP_ID}-readBlocklist`, + ], id: 'blocklist_all', includeIn: 'none', name: 'All', savedObject: { - all: [], + all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC], read: [], }, ui: ['writeBlocklist', 'readBlocklist'], }, { - api: [`${APP_ID}-readBlocklist`], + api: ['lists-read', 'lists-summary', `${APP_ID}-readBlocklist`], id: 'blocklist_read', includeIn: 'none', name: 'Read', @@ -425,18 +441,24 @@ const subFeatures: SubFeatureConfig[] = [ groupType: 'mutually_exclusive', privileges: [ { - api: [`${APP_ID}-writeEventFilters`, `${APP_ID}-readEventFilters`], + api: [ + 'lists-all', + 'lists-read', + 'lists-summary', + `${APP_ID}-writeEventFilters`, + `${APP_ID}-readEventFilters`, + ], id: 'event_filters_all', includeIn: 'none', name: 'All', savedObject: { - all: [], + all: [EXCEPTION_LIST_NAMESPACE_AGNOSTIC], read: [], }, ui: ['writeEventFilters', 'readEventFilters'], }, { - api: [`${APP_ID}-readEventFilters`], + api: ['lists-read', 'lists-summary', `${APP_ID}-readEventFilters`], id: 'event_filters_read', includeIn: 'none', name: 'Read', @@ -545,7 +567,7 @@ export const getKibanaPrivilegesFeaturePrivileges = ( all: [ 'alert', 'exception-list', - 'exception-list-agnostic', + EXCEPTION_LIST_NAMESPACE_AGNOSTIC, DATA_VIEW_SAVED_OBJECT_TYPE, ...savedObjectTypes, CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE, @@ -573,7 +595,7 @@ export const getKibanaPrivilegesFeaturePrivileges = ( all: [], read: [ 'exception-list', - 'exception-list-agnostic', + EXCEPTION_LIST_NAMESPACE_AGNOSTIC, DATA_VIEW_SAVED_OBJECT_TYPE, ...savedObjectTypes, CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE, diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts index f2ff6e5074fd8..2b73297881871 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts @@ -60,21 +60,18 @@ export class HostIsolationExceptionsValidator extends BaseValidator { return item.listId === ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID; } - // TODO: 8.7 rbac - // protected async validateHasWritePrivilege(): Promise { - // return super.validateHasPrivilege('canWriteHostIsolationExceptions'); - // } + protected async validateHasWritePrivilege(): Promise { + return this.validateHasPrivilege('canWriteHostIsolationExceptions'); + } - // TODO: 8.7 rbac - // protected async validateHasReadPrivilege(): Promise { - // return super.validateHasPrivilege('canReadHostIsolationExceptions'); - // } + protected async validateHasReadPrivilege(): Promise { + return this.validateHasPrivilege('canReadHostIsolationExceptions'); + } async validatePreCreateItem( item: CreateExceptionListItemOptions ): Promise { - // TODO add this to 8.7 rbac await this.validateHasWritePrivilege(); - await this.validateCanIsolateHosts(); + await this.validateHasWritePrivilege(); await this.validateHostIsolationData(item); await this.validateByPolicyItem(item); @@ -86,9 +83,7 @@ export class HostIsolationExceptionsValidator extends BaseValidator { ): Promise { const updatedItem = _updatedItem as ExceptionItemLikeOptions; - // TODO add this to 8.7 rbac add - // await this.validateHasWritePrivilege(); - await this.validateCanIsolateHosts(); + await this.validateHasWritePrivilege(); await this.validateHostIsolationData(updatedItem); await this.validateByPolicyItem(updatedItem); @@ -96,39 +91,27 @@ export class HostIsolationExceptionsValidator extends BaseValidator { } async validatePreGetOneItem(): Promise { - // TODO: for 8.7 rbac replace with - // await this.validateHasReadPrivilege(); - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreSummary(): Promise { - // TODO: for 8.7 rbac replace with - // await this.validateHasReadPrivilege(); - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreDeleteItem(): Promise { - // TODO: for 8.7 rbac replace with - // await this.validateHasWritePrivilege(); - await this.validateCanManageEndpointArtifacts(); + await this.validateHasWritePrivilege(); } async validatePreExport(): Promise { - // TODO: for 8.7 rbac replace with - // await this.validateHasReadPrivilege(); - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreSingleListFind(): Promise { - // TODO: for 8.7 rbac replace with - // await this.validateHasReadPrivilege(); - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreMultiListFind(): Promise { - // TODO: for 8.7 rbac replace with - // await this.validateHasReadPrivilege(); - await this.validateCanManageEndpointArtifacts(); + await this.validateHasReadPrivilege(); } async validatePreImport(): Promise { From 78f22099436519dc8c069d12d21f9a90b3a6eb92 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Wed, 23 Nov 2022 09:32:30 -0500 Subject: [PATCH 14/83] [Synthetics] Hide URL field when value is falsey (#145938) ## Summary Resolves #144855. Resolves #144854. Hides the URL field when it has no value, like in the case of multistep monitors. ## Testing this PR Create a multistep monitor that will not have a URL field, and view the flyout for your monitor. See that there is no URL field displayed. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../overview/monitor_detail_flyout.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx index e481d8642569d..c7a3e6c9e6f78 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx @@ -30,6 +30,7 @@ import { EuiPanel, EuiPopover, EuiSpacer, + EuiText, EuiTitle, useIsWithinMaxBreakpoint, } from '@elastic/eui'; @@ -348,20 +349,24 @@ export function MonitorDetailFlyout(props: Props) { compressed listItems={ [ + monitorDetail.data?.url?.full + ? { + title: URL_HEADER_TEXT, + description: ( + + {monitorDetail.data.url.full} + + ), + } + : undefined, { - title: URL_HEADER_TEXT, - description: monitorDetail.data?.url?.full ? ( - - {monitorDetail.data.url.full} - + title: LAST_RUN_HEADER_TEXT, + description: monitorDetail.data?.timestamp ? ( +