From 1589547b6f143c45c782ed857902026af2e7e38a Mon Sep 17 00:00:00 2001 From: Jeramy Soucy Date: Wed, 24 May 2023 12:20:46 -0400 Subject: [PATCH 01/20] Handles non-existing objects in _copy_saved_objects API call (#158036) Closes #156791 ## Summary This PR implements catching the error thrown by the saved_objects_exporter when an object is not found, and responding with a detailed 404 ("Not Found") rather than a generic 500 ("Internal Server Error") response message. ### Example Response: ``` { "statusCode": 404, "error": "Not Found", "message": "Saved objects not found", "attributes": { "objects": [ { "id": "7adfa750-4c81-11e8-b3d7-01146121b73d", "type": "dashboard" }, { "id": "571aaf70-4c88-11e8-b3d7-01146121b73d", "type": "search" }, { "id": "d3d7af60-4c81-11e8-b3d7-01146121b73d", "type": "index-pattern" } ] } } ``` ### Testing 1. Install sample flight data, find the id of the flights dashboard SO 2. Create an additional space 'b' 3. Issue a request to copy saved objects to space b ``` POST kbn:/api/spaces/_copy_saved_objects { "spaces": [ "b" ], "objects": [ { "type": "dashboard", "id": "7adfa750-4c81-11e8-b3d7-01146121b73e" }, { "type": "dashboard", "id": "7adfa750-4c81-11e8-b3d7-01146121b73f" }, { "type": "dashboard", "id": "7adfa750-4c81-11e8-b3d7-01146121b73g" } ] } ``` 4. Verify response ``` { "statusCode": 404, "error": "Not Found", "message": "Saved objects not found", "attributes": { "objects": [ { "type": "dashboard", "id": "7adfa750-4c81-11e8-b3d7-01146121b73e" }, { "type": "dashboard", "id": "7adfa750-4c81-11e8-b3d7-01146121b73f" }, { "type": "dashboard", "id": "7adfa750-4c81-11e8-b3d7-01146121b73g" } ] } } ``` 5. Issue a request to copy the flights dashboard SO 6. Verify the usual response (200, missing references) 7. Issue a malformed request to copy an SO 8. Verify status 400 response with details --- .../update_objects_spaces.asciidoc | 9 ++++ .../routes/api/external/copy_to_space.ts | 42 +++++++++++++------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/api/spaces-management/update_objects_spaces.asciidoc b/docs/api/spaces-management/update_objects_spaces.asciidoc index dec846fd6fee0..5938ddb4e4315 100644 --- a/docs/api/spaces-management/update_objects_spaces.asciidoc +++ b/docs/api/spaces-management/update_objects_spaces.asciidoc @@ -36,6 +36,15 @@ Updates one or more saved objects to add and/or remove them from specified space `spacesToRemove`:: (Required, string array) The IDs of the spaces the specified objects should be removed from. +[[spaces-api-update-objects-spaces-response-codes]] +==== Response codes + +`200`:: + Indicates a successful call. + +`404`:: + Indicates that the request failed because one or more of the objects specified could not be found. A list of the unresolved objects are included in the 404 response attributes. + [role="child_attributes"] [[spaces-api-update-objects-spaces-response-body]] ==== {api-response-body-title} diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index 954ea932d6a3b..7faf03ea60b57 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -105,19 +105,35 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { }) ); - const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( - startServices.savedObjects, - request - ); - const sourceSpaceId = getSpacesService().getSpaceId(request); - const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { - objects, - includeReferences, - overwrite, - createNewCopies, - compatibilityMode, - }); - return response.ok({ body: copyResponse }); + try { + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + startServices.savedObjects, + request + ); + const sourceSpaceId = getSpacesService().getSpaceId(request); + const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { + objects, + includeReferences, + overwrite, + createNewCopies, + compatibilityMode, + }); + return response.ok({ body: copyResponse }); + } catch (e) { + if (e.type === 'object-fetch-error' && e.attributes?.objects) { + return response.notFound({ + body: { + message: 'Saved objects not found', + attributes: { + objects: e.attributes?.objects.map((obj: SavedObjectIdentifier) => ({ + id: obj.id, + type: obj.type, + })), + }, + }, + }); + } else throw e; + } }) ); From b1d9e9aca6c9f830081a5a35aadbe3ff4e00fd82 Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Wed, 24 May 2023 18:28:44 +0200 Subject: [PATCH 02/20] [Security Solution] Add a feature flag for Protections/Detections Coverage Overview dashboard (#158298) **Resolves:** https://github.com/elastic/kibana/issues/158200 ## Summary This PR adds a feature flag for Protections/Detections Coverage Overview dashboard to facilitate the development process until the MVP is ready. If it's not the case before the next release it will be safe to continue working on the feature in the next cycle. --- .../security_solution/common/experimental_features.ts | 9 +++++++++ .../rule_management/api/register_routes.ts | 6 ++++++ .../rule_management/api/rules/dashboard/route.ts | 8 ++++++++ 3 files changed, 23 insertions(+) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/dashboard/route.ts diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 331f1d89b941f..0e44190bfab78 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -134,6 +134,15 @@ export const allowedExperimentalValues = Object.freeze({ * **/ newUserDetailsFlyout: false, + + /** + * Enables Protections/Detections Coverage Overview page (Epic link https://github.com/elastic/security-team/issues/2905) + * + * This flag aims to facilitate the development process as the feature may not make it to 8.9 release. + * + * The flag doesn't have to be documented and has to be removed after the feature is ready to release. + */ + detectionsCoverageOverview: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/register_routes.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/register_routes.ts index 53c4e54484e18..9f80623cd7cc7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/register_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/register_routes.ts @@ -25,6 +25,7 @@ import { patchRuleRoute } from './rules/patch_rule/route'; import { readRuleRoute } from './rules/read_rule/route'; import { updateRuleRoute } from './rules/update_rule/route'; import { readTagsRoute } from './tags/read_tags/route'; +import { getRulesDashboardDataRoute } from './rules/dashboard/route'; export const registerRuleManagementRoutes = ( router: SecuritySolutionPluginRouter, @@ -60,4 +61,9 @@ export const registerRuleManagementRoutes = ( // Rules filters getRuleManagementFilters(router); + + // Rules dashboard + if (config.experimentalFeatures.detectionsCoverageOverview) { + getRulesDashboardDataRoute(); + } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/dashboard/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/dashboard/route.ts new file mode 100644 index 0000000000000..33821fcd90d26 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/dashboard/route.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function getRulesDashboardDataRoute(): void {} From 295ccb5a9df8256869f94b00358101dfac168a71 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 24 May 2023 12:41:05 -0400 Subject: [PATCH 03/20] skip failing test suite (#158394) --- x-pack/test/api_integration/apis/synthetics/get_monitor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/synthetics/get_monitor.ts b/x-pack/test/api_integration/apis/synthetics/get_monitor.ts index 503872c02a731..e07cd0c50f7f3 100644 --- a/x-pack/test/api_integration/apis/synthetics/get_monitor.ts +++ b/x-pack/test/api_integration/apis/synthetics/get_monitor.ts @@ -13,7 +13,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { getFixtureJson } from './helper/get_fixture_json'; export default function ({ getService }: FtrProviderContext) { - describe('getSyntheticsMonitors', function () { + // Failing: See https://github.com/elastic/kibana/issues/158394 + describe.skip('getSyntheticsMonitors', function () { this.tags('skipCloud'); const supertest = getService('supertest'); From 28efce79557a89f81139c2e71f4c68416201bcf4 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 24 May 2023 11:42:16 -0500 Subject: [PATCH 04/20] Revert "[Synthetics] Remove fields from API query interface (#158363)" This reverts commit 3a4f5ec504618e73d72992e08e61aaeaca90c7fc. --- .../server/legacy_uptime/routes/types.ts | 4 +- .../synthetics/server/routes/common.ts | 37 +++++++-------- .../plugins/synthetics/server/routes/index.ts | 2 +- .../monitor_cruds/delete_monitor_project.ts | 15 +++--- .../routes/monitor_cruds/get_monitor.ts | 47 ++++++++++++++++++- .../monitor_cruds/get_monitor_project.ts | 30 +++++------- .../routes/monitor_cruds/get_monitors_list.ts | 47 ------------------- 7 files changed, 85 insertions(+), 97 deletions(-) delete mode 100644 x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitors_list.ts diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/routes/types.ts b/x-pack/plugins/synthetics/server/legacy_uptime/routes/types.ts index 7d93077c7ef16..f586a434acdb5 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/routes/types.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/routes/types.ts @@ -111,10 +111,10 @@ export type UMRouteHandler = ({ subject, }: UptimeRouteContext) => IKibanaResponse | Promise>; -export interface RouteContext> { +export interface RouteContext { uptimeEsClient: UptimeEsClient; context: UptimeRequestHandlerContext; - request: KibanaRequest, Query, Record>; + request: SyntheticsRequest; response: KibanaResponseFactory; savedObjectsClient: SavedObjectsClientContract; server: UptimeServerSetup; diff --git a/x-pack/plugins/synthetics/server/routes/common.ts b/x-pack/plugins/synthetics/server/routes/common.ts index 6cfac7e22a36a..c17b4a133fd2e 100644 --- a/x-pack/plugins/synthetics/server/routes/common.ts +++ b/x-pack/plugins/synthetics/server/routes/common.ts @@ -12,10 +12,6 @@ import { EncryptedSyntheticsMonitor, ServiceLocations } from '../../common/runti import { monitorAttributes, syntheticsMonitorType } from '../../common/types/saved_objects'; import { RouteContext } from '../legacy_uptime/routes'; -const StringOrArraySchema = schema.maybe( - schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) -); - export const QuerySchema = schema.object({ page: schema.maybe(schema.number()), perPage: schema.maybe(schema.number()), @@ -23,12 +19,13 @@ export const QuerySchema = schema.object({ sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), query: schema.maybe(schema.string()), filter: schema.maybe(schema.string()), - tags: StringOrArraySchema, - monitorTypes: StringOrArraySchema, - locations: StringOrArraySchema, - projects: StringOrArraySchema, - schedules: StringOrArraySchema, - status: StringOrArraySchema, + tags: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + monitorTypes: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + locations: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + projects: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + schedules: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + status: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + fields: schema.maybe(schema.arrayOf(schema.string())), searchAfter: schema.maybe(schema.arrayOf(schema.string())), }); @@ -37,12 +34,12 @@ export type MonitorsQuery = TypeOf; export const OverviewStatusSchema = schema.object({ query: schema.maybe(schema.string()), filter: schema.maybe(schema.string()), - tags: StringOrArraySchema, - monitorTypes: StringOrArraySchema, - locations: StringOrArraySchema, - projects: StringOrArraySchema, - schedules: StringOrArraySchema, - status: StringOrArraySchema, + tags: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + monitorTypes: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + locations: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + projects: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + schedules: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + status: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), scopeStatusByLocation: schema.maybe(schema.boolean()), }); @@ -59,8 +56,7 @@ export const SEARCH_FIELDS = [ ]; export const getMonitors = async ( - context: RouteContext, - { fields }: { fields?: string[] } = {} + context: RouteContext ): Promise> => { const { perPage = 50, @@ -72,10 +68,11 @@ export const getMonitors = async ( monitorTypes, locations, filter = '', + fields, searchAfter, projects, schedules, - } = context.request.query; + } = context.request.query as MonitorsQuery; const filterStr = await getMonitorFilters({ filter, @@ -96,8 +93,8 @@ export const getMonitors = async ( searchFields: SEARCH_FIELDS, search: query ? `${query}*` : undefined, filter: filterStr, - searchAfter, fields, + searchAfter, }); }; diff --git a/x-pack/plugins/synthetics/server/routes/index.ts b/x-pack/plugins/synthetics/server/routes/index.ts index 5077c5d166149..03d8493fbf5a7 100644 --- a/x-pack/plugins/synthetics/server/routes/index.ts +++ b/x-pack/plugins/synthetics/server/routes/index.ts @@ -23,6 +23,7 @@ import { getSyntheticsEnablementRoute, } from './synthetics_service/enablement'; import { + getAllSyntheticsMonitorRoute, getSyntheticsMonitorOverviewRoute, getSyntheticsMonitorRoute, } from './monitor_cruds/get_monitor'; @@ -52,7 +53,6 @@ import { addPrivateLocationRoute } from './settings/private_locations/add_privat import { deletePrivateLocationRoute } from './settings/private_locations/delete_private_location'; import { getPrivateLocationsRoute } from './settings/private_locations/get_private_locations'; import { getSyntheticsFilters } from './filters/filters'; -import { getAllSyntheticsMonitorRoute } from './monitor_cruds/get_monitors_list'; export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ addSyntheticsMonitorRoute, diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts index 83c6bd26ee275..49cfda80011d5 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts @@ -45,16 +45,13 @@ export const deleteSyntheticsMonitorProjectRoute: SyntheticsRestApiRouteFactory values: monitorsToDelete.map((id: string) => `${id}`), })}`; - const { saved_objects: monitors } = await getMonitors( - { - ...routeContext, - request: { - ...request, - query: { ...request.query, filter: deleteFilter, perPage: 500 }, - }, + const { saved_objects: monitors } = await getMonitors({ + ...routeContext, + request: { + ...request, + query: { ...request.query, filter: deleteFilter, fields: [], perPage: 500 }, }, - { fields: [] } - ); + }); const { integrations: { writeIntegrationPolicies }, diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts index fbaf0b798b15d..2ba8dc4380c5c 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import { syntheticsMonitorType } from '../../../common/types/saved_objects'; import { getAllMonitors } from '../../saved_objects/synthetics_monitor/get_all_monitors'; import { isStatusEnabled } from '../../../common/runtime_types/monitor_management/alert_config'; import { @@ -17,7 +18,14 @@ import { UMServerLibs } from '../../legacy_uptime/lib/lib'; import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types'; import { API_URLS, SYNTHETICS_API_URLS } from '../../../common/constants'; import { getMonitorNotFoundResponse } from '../synthetics_service/service_errors'; -import { getMonitorFilters, MonitorsQuery, QuerySchema, SEARCH_FIELDS } from '../common'; +import { + getMonitorFilters, + getMonitors, + isMonitorsQueryFiltered, + MonitorsQuery, + QuerySchema, + SEARCH_FIELDS, +} from '../common'; export const getSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', @@ -70,6 +78,43 @@ export const getSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = (libs: U }, }); +export const getAllSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ + method: 'GET', + path: API_URLS.SYNTHETICS_MONITORS, + validate: { + query: QuerySchema, + }, + handler: async (routeContext): Promise => { + const { request, savedObjectsClient, syntheticsMonitorClient } = routeContext; + const totalCountQuery = async () => { + if (isMonitorsQueryFiltered(request.query)) { + return savedObjectsClient.find({ + type: syntheticsMonitorType, + perPage: 0, + page: 1, + }); + } + }; + + const [queryResult, totalCount] = await Promise.all([ + getMonitors(routeContext), + totalCountQuery(), + ]); + + const absoluteTotal = totalCount?.total ?? queryResult.total; + + const { saved_objects: monitors, per_page: perPageT, ...rest } = queryResult; + + return { + ...rest, + monitors, + absoluteTotal, + perPage: perPageT, + syncErrors: syntheticsMonitorClient.syntheticsService.syncErrors, + }; + }, +}); + export const getSyntheticsMonitorOverviewRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'GET', path: SYNTHETICS_API_URLS.SYNTHETICS_OVERVIEW, diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor_project.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor_project.ts index eff7e40c86b20..66ee960add4f8 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor_project.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor_project.ts @@ -37,25 +37,21 @@ export const getSyntheticsProjectMonitorsRoute: SyntheticsRestApiRouteFactory = const decodedSearchAfter = searchAfter ? decodeURI(searchAfter) : undefined; try { - const { saved_objects: monitors, total } = await getMonitors( - { - ...routeContext, - request: { - ...request, - query: { - ...request.query, - filter: `${syntheticsMonitorType}.attributes.${ConfigKey.PROJECT_ID}: "${decodedProjectName}"`, - perPage, - sortField: ConfigKey.JOURNEY_ID, - sortOrder: 'asc', - searchAfter: decodedSearchAfter ? [...decodedSearchAfter.split(',')] : undefined, - }, + const { saved_objects: monitors, total } = await getMonitors({ + ...routeContext, + request: { + ...request, + query: { + ...request.query, + filter: `${syntheticsMonitorType}.attributes.${ConfigKey.PROJECT_ID}: "${decodedProjectName}"`, + fields: [ConfigKey.JOURNEY_ID, ConfigKey.CONFIG_HASH], + perPage, + sortField: ConfigKey.JOURNEY_ID, + sortOrder: 'asc', + searchAfter: decodedSearchAfter ? [...decodedSearchAfter.split(',')] : undefined, }, }, - { - fields: [ConfigKey.JOURNEY_ID, ConfigKey.CONFIG_HASH], - } - ); + }); const projectMonitors = monitors.map((monitor) => ({ journey_id: monitor.attributes[ConfigKey.JOURNEY_ID], hash: monitor.attributes[ConfigKey.CONFIG_HASH] || '', diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitors_list.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitors_list.ts deleted file mode 100644 index d407134a8f024..0000000000000 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitors_list.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes'; -import { API_URLS } from '../../../common/constants'; -import { getMonitors, isMonitorsQueryFiltered, QuerySchema } from '../common'; -import { syntheticsMonitorType } from '../../../common/types/saved_objects'; - -export const getAllSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ - method: 'GET', - path: API_URLS.SYNTHETICS_MONITORS, - validate: { - query: QuerySchema, - }, - handler: async (routeContext): Promise => { - const { request, savedObjectsClient, syntheticsMonitorClient } = routeContext; - const totalCountQuery = async () => { - if (isMonitorsQueryFiltered(request.query)) { - return savedObjectsClient.find({ - type: syntheticsMonitorType, - perPage: 0, - page: 1, - }); - } - }; - - const [queryResult, totalCount] = await Promise.all([ - getMonitors(routeContext), - totalCountQuery(), - ]); - - const absoluteTotal = totalCount?.total ?? queryResult.total; - - const { saved_objects: monitors, per_page: perPageT, ...rest } = queryResult; - - return { - ...rest, - monitors, - absoluteTotal, - perPage: perPageT, - syncErrors: syntheticsMonitorClient.syntheticsService.syncErrors, - }; - }, -}); From b388d704c2d2f55f5d468aedcd438425df6763da Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Wed, 24 May 2023 19:49:33 +0300 Subject: [PATCH 05/20] [Cloud Security] Fixing evaluations colors mismatching (#158368) --- .../cloud_security_posture/public/common/constants.ts | 2 +- .../public/components/csp_evaluation_badge.tsx | 5 +++-- .../configurations/layout/findings_distribution_bar.tsx | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index afc8bf8b1d6b9..16128d1308945 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -29,7 +29,7 @@ import aksLogo from '../assets/icons/cis_aks_logo.svg'; import gkeLogo from '../assets/icons/cis_gke_logo.svg'; export const statusColors = { - passed: euiThemeVars.euiColorVis0, + passed: euiThemeVars.euiColorSuccess, failed: euiThemeVars.euiColorVis9, }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx index c1e1b52963805..009ab56ac27b9 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { EuiBadge, type EuiBadgeProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; +import { statusColors } from '../common/constants'; interface Props { type: 'passed' | 'failed'; @@ -19,8 +20,8 @@ interface Props { const BADGE_WIDTH = '46px'; const getColor = (type: Props['type']): EuiBadgeProps['color'] => { - if (type === 'passed') return 'success'; - if (type === 'failed') return 'danger'; + if (type === 'passed') return statusColors.passed; + if (type === 'failed') return statusColors.failed; return 'default'; }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx index 947a5d40e4f83..b16c697f1590c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_distribution_bar.tsx @@ -72,7 +72,7 @@ const PassedFailedCounters = ({ passed, failed }: Pick > = ({ > { distributionOnClick(RULE_PASSED); }} From e77b45f9f90d0619b3445afd9ae5e3c0cc9a56cb Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 24 May 2023 18:17:57 +0100 Subject: [PATCH 06/20] [ML] Versioning file upload APIs (#158265) Adds versioning to all of the file upload APIs. Versions are added to the server side routes and to the client side functions which call the routes. Updates API tests to add the API version to the request headers. All of the APIs are internal and have been given the version '1'. Also renames `/internal/file_data_visualizer/analyze_file` to `/internal/file_upload/analyze_file` It appears this was a mistake from when the route was moved from the data visualiser plugin midway through development on [this PR](https://github.com/elastic/kibana/pull/96408). **Internal APIs** `/internal/file_upload/analyze_file` `/internal/file_upload/has_import_permission` `/internal/file_upload/index_exists` `/internal/file_upload/time_field_range` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/file_upload/public/api/index.ts | 6 +- .../file_upload/public/importer/importer.ts | 1 + x-pack/plugins/file_upload/server/routes.ts | 279 ++++++++++-------- .../apis/file_upload/has_import_permission.ts | 5 + .../apis/file_upload/index_exists.ts | 3 + x-pack/test/tsconfig.json | 3 +- 6 files changed, 173 insertions(+), 124 deletions(-) diff --git a/x-pack/plugins/file_upload/public/api/index.ts b/x-pack/plugins/file_upload/public/api/index.ts index 4284102b26674..4d6483f2b2a50 100644 --- a/x-pack/plugins/file_upload/public/api/index.ts +++ b/x-pack/plugins/file_upload/public/api/index.ts @@ -54,8 +54,9 @@ export async function analyzeFile( const { getHttp } = await lazyLoadModules(); const body = JSON.stringify(file); return await getHttp().fetch({ - path: `/internal/file_data_visualizer/analyze_file`, + path: `/internal/file_upload/analyze_file`, method: 'POST', + version: '1', body, query: params, }); @@ -67,6 +68,7 @@ export async function hasImportPermission(params: HasImportPermissionParams): Pr const resp = await fileUploadModules.getHttp().fetch({ path: `/internal/file_upload/has_import_permission`, method: 'GET', + version: '1', query: { ...params }, }); return resp.hasImportPermission; @@ -85,6 +87,7 @@ export async function checkIndexExists( const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ path: `/internal/file_upload/index_exists`, method: 'POST', + version: '1', body, query: params, }); @@ -101,6 +104,7 @@ export async function getTimeFieldRange(index: string, query: unknown, timeField return await fileUploadModules.getHttp().fetch({ path: `/internal/file_upload/time_field_range`, method: 'POST', + version: '1', body, }); } diff --git a/x-pack/plugins/file_upload/public/importer/importer.ts b/x-pack/plugins/file_upload/public/importer/importer.ts index 8928c4849435f..630c35aa794c7 100644 --- a/x-pack/plugins/file_upload/public/importer/importer.ts +++ b/x-pack/plugins/file_upload/public/importer/importer.ts @@ -298,6 +298,7 @@ export function callImportRoute({ return getHttp().fetch({ path: `/internal/file_upload/import`, method: 'POST', + version: '1', query, body, }); diff --git a/x-pack/plugins/file_upload/server/routes.ts b/x-pack/plugins/file_upload/server/routes.ts index 76d6443f47f54..0fbcbd413c238 100644 --- a/x-pack/plugins/file_upload/server/routes.ts +++ b/x-pack/plugins/file_upload/server/routes.ts @@ -44,37 +44,44 @@ function importData( export function fileUploadRoutes(coreSetup: CoreSetup, logger: Logger) { const router = coreSetup.http.createRouter(); - router.get( - { + router.versioned + .get({ path: '/internal/file_upload/has_import_permission', - validate: { - query: schema.object({ - indexName: schema.maybe(schema.string()), - checkCreateDataView: schema.boolean(), - checkHasManagePipeline: schema.boolean(), - }), + access: 'internal', + }) + .addVersion( + { + version: '1', + validate: { + request: { + query: schema.object({ + indexName: schema.maybe(schema.string()), + checkCreateDataView: schema.boolean(), + checkHasManagePipeline: schema.boolean(), + }), + }, + }, }, - }, - async (context, request, response) => { - try { - const [, pluginsStart] = await coreSetup.getStartServices(); - const { indexName, checkCreateDataView, checkHasManagePipeline } = request.query; + async (context, request, response) => { + try { + const [, pluginsStart] = await coreSetup.getStartServices(); + const { indexName, checkCreateDataView, checkHasManagePipeline } = request.query; - const { hasImportPermission } = await checkFileUploadPrivileges({ - authorization: pluginsStart.security?.authz, - request, - indexName, - checkCreateDataView, - checkHasManagePipeline, - }); + const { hasImportPermission } = await checkFileUploadPrivileges({ + authorization: pluginsStart.security?.authz, + request, + indexName, + checkCreateDataView, + checkHasManagePipeline, + }); - return response.ok({ body: { hasImportPermission } }); - } catch (e) { - logger.warn(`Unable to check import permission, error: ${e.message}`); - return response.ok({ body: { hasImportPermission: false } }); + return response.ok({ body: { hasImportPermission } }); + } catch (e) { + logger.warn(`Unable to check import permission, error: ${e.message}`); + return response.ok({ body: { hasImportPermission: false } }); + } } - } - ); + ); /** * @apiGroup FileDataVisualizer @@ -85,13 +92,10 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge * * @apiSchema (query) analyzeFileQuerySchema */ - router.post( - { - path: '/internal/file_data_visualizer/analyze_file', - validate: { - body: schema.any(), - query: analyzeFileQuerySchema, - }, + router.versioned + .post({ + path: '/internal/file_upload/analyze_file', + access: 'internal', options: { body: { accepts: ['text/*', 'application/json'], @@ -99,17 +103,27 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge }, tags: ['access:fileUpload:analyzeFile'], }, - }, - async (context, request, response) => { - try { - const esClient = (await context.core).elasticsearch.client; - const result = await analyzeFile(esClient, request.body, request.query); - return response.ok({ body: result }); - } catch (e) { - return response.customError(wrapError(e)); + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: schema.any(), + query: analyzeFileQuerySchema, + }, + }, + }, + async (context, request, response) => { + try { + const esClient = (await context.core).elasticsearch.client; + const result = await analyzeFile(esClient, request.body, request.query); + return response.ok({ body: result }); + } catch (e) { + return response.customError(wrapError(e)); + } } - } - ); + ); /** * @apiGroup FileDataVisualizer @@ -121,49 +135,56 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge * @apiSchema (query) importFileQuerySchema * @apiSchema (body) importFileBodySchema */ - router.post( - { + router.versioned + .post({ path: '/internal/file_upload/import', - validate: { - query: importFileQuerySchema, - body: importFileBodySchema, - }, + access: 'internal', options: { body: { accepts: ['application/json'], maxBytes: MAX_FILE_SIZE_BYTES, }, }, - }, - async (context, request, response) => { - try { - const { id } = request.query; - const { index, data, settings, mappings, ingestPipeline } = request.body; - const esClient = (await context.core).elasticsearch.client; + }) + .addVersion( + { + version: '1', + validate: { + request: { + query: importFileQuerySchema, + body: importFileBodySchema, + }, + }, + }, + async (context, request, response) => { + try { + const { id } = request.query; + const { index, data, settings, mappings, ingestPipeline } = request.body; + const esClient = (await context.core).elasticsearch.client; - // `id` being `undefined` tells us that this is a new import due to create a new index. - // follow-up import calls to just add additional data will include the `id` of the created - // index, we'll ignore those and don't increment the counter. - if (id === undefined) { - await updateTelemetry(); - } + // `id` being `undefined` tells us that this is a new import due to create a new index. + // follow-up import calls to just add additional data will include the `id` of the created + // index, we'll ignore those and don't increment the counter. + if (id === undefined) { + await updateTelemetry(); + } - const result = await importData( - esClient, - id, - index, - settings, - mappings, - // @ts-expect-error - ingestPipeline, - data - ); - return response.ok({ body: result }); - } catch (e) { - return response.customError(wrapError(e)); + const result = await importData( + esClient, + id, + index, + settings, + mappings, + // @ts-expect-error + ingestPipeline, + data + ); + return response.ok({ body: result }); + } catch (e) { + return response.customError(wrapError(e)); + } } - } - ); + ); /** * @apiGroup FileDataVisualizer @@ -171,23 +192,30 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge * @api {post} /internal/file_upload/index_exists ES indices exists wrapper checks if index exists * @apiName IndexExists */ - router.post( - { + router.versioned + .post({ path: '/internal/file_upload/index_exists', - validate: { - body: schema.object({ index: schema.string() }), + access: 'internal', + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: schema.object({ index: schema.string() }), + }, + }, }, - }, - async (context, request, response) => { - try { - const esClient = (await context.core).elasticsearch.client; - const indexExists = await esClient.asCurrentUser.indices.exists(request.body); - return response.ok({ body: { exists: indexExists } }); - } catch (e) { - return response.customError(wrapError(e)); + async (context, request, response) => { + try { + const esClient = (await context.core).elasticsearch.client; + const indexExists = await esClient.asCurrentUser.indices.exists(request.body); + return response.ok({ body: { exists: indexExists } }); + } catch (e) { + return response.customError(wrapError(e)); + } } - } - ); + ); /** * @apiGroup FileDataVisualizer @@ -201,42 +229,49 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge * @apiSuccess {Object} start start of time range with epoch and string properties. * @apiSuccess {Object} end end of time range with epoch and string properties. */ - router.post( - { + router.versioned + .post({ path: '/internal/file_upload/time_field_range', - validate: { - body: schema.object({ - /** Index or indexes for which to return the time range. */ - index: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), - /** Name of the time field in the index. */ - timeFieldName: schema.string(), - /** Query to match documents in the index(es). */ - query: schema.maybe(schema.any()), - runtimeMappings: schema.maybe(runtimeMappingsSchema), - }), - }, + access: 'internal', options: { tags: ['access:fileUpload:analyzeFile'], }, - }, - async (context, request, response) => { - try { - const { index, timeFieldName, query, runtimeMappings } = request.body; - const esClient = (await context.core).elasticsearch.client; - const resp = await getTimeFieldRange( - esClient, - index, - timeFieldName, - query, - runtimeMappings - ); + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: schema.object({ + /** Index or indexes for which to return the time range. */ + index: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + /** Name of the time field in the index. */ + timeFieldName: schema.string(), + /** Query to match documents in the index(es). */ + query: schema.maybe(schema.any()), + runtimeMappings: schema.maybe(runtimeMappingsSchema), + }), + }, + }, + }, + async (context, request, response) => { + try { + const { index, timeFieldName, query, runtimeMappings } = request.body; + const esClient = (await context.core).elasticsearch.client; + const resp = await getTimeFieldRange( + esClient, + index, + timeFieldName, + query, + runtimeMappings + ); - return response.ok({ - body: resp, - }); - } catch (e) { - return response.customError(wrapError(e)); + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } } - } - ); + ); } diff --git a/x-pack/test/api_integration/apis/file_upload/has_import_permission.ts b/x-pack/test/api_integration/apis/file_upload/has_import_permission.ts index 6d73ebb8626c7..6b43093cd7053 100644 --- a/x-pack/test/api_integration/apis/file_upload/has_import_permission.ts +++ b/x-pack/test/api_integration/apis/file_upload/has_import_permission.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -54,6 +55,7 @@ export default ({ getService }: FtrProviderContext) => { ) .auth(IMPORTER_USER_NAME, IMPORT_USER_PASSWORD) .set('kbn-xsrf', 'kibana') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .expect(200); expect(resp.body.hasImportPermission).to.be(true); @@ -80,6 +82,7 @@ export default ({ getService }: FtrProviderContext) => { ) .auth(IMPORTER_USER_NAME, IMPORT_USER_PASSWORD) .set('kbn-xsrf', 'kibana') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send() .expect(200); @@ -107,6 +110,7 @@ export default ({ getService }: FtrProviderContext) => { ) .auth(IMPORTER_USER_NAME, IMPORT_USER_PASSWORD) .set('kbn-xsrf', 'kibana') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .expect(200); expect(resp.body.hasImportPermission).to.be(false); @@ -134,6 +138,7 @@ export default ({ getService }: FtrProviderContext) => { ) .auth(IMPORTER_USER_NAME, IMPORT_USER_PASSWORD) .set('kbn-xsrf', 'kibana') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .expect(200); expect(resp.body.hasImportPermission).to.be(false); diff --git a/x-pack/test/api_integration/apis/file_upload/index_exists.ts b/x-pack/test/api_integration/apis/file_upload/index_exists.ts index a9688a542dc1e..ee281942b82e7 100644 --- a/x-pack/test/api_integration/apis/file_upload/index_exists.ts +++ b/x-pack/test/api_integration/apis/file_upload/index_exists.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -25,6 +26,7 @@ export default ({ getService }: FtrProviderContext) => { const resp = await supertest .post(`/internal/file_upload/index_exists`) .set('kbn-xsrf', 'kibana') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ index: 'logstash-2015.09.22', }) @@ -37,6 +39,7 @@ export default ({ getService }: FtrProviderContext) => { const resp = await supertest .post(`/internal/file_upload/index_exists`) .set('kbn-xsrf', 'kibana') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') .send({ index: 'myNewIndex', }) diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 3147a1d625372..81df1cf3e317a 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -123,6 +123,7 @@ "@kbn/infra-forge", "@kbn/observability-shared-plugin", "@kbn/maps-vector-tile-utils", - "@kbn/server-route-repository" + "@kbn/server-route-repository", + "@kbn/core-http-common" ] } From 282305fd65b2a09644a511844475494fde2afdb1 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Wed, 24 May 2023 13:19:09 -0400 Subject: [PATCH 07/20] [Response Ops][Alerting] Initial implementation of FAAD `AlertsClient` for writing generic AAD documents (#156946) Resolves https://github.com/elastic/kibana/issues/156442 ## Summary 1. Adds `shouldWriteAlerts` flag to rule type registration which defaults to `false` if not set. This prevents duplicate AAD documents from being written for the rule registry rule types that had to register with the framework in order to get their resources installed on startup. 2. Initial implementation of `AlertsClient` which primarily functions as a proxy to the `LegacyAlertsClient`. It does 2 additional thing: a. When initialized with the active & recovered alerts from the previous execution (de-serialized from the task manager state), it queries the AAD index for the corresponding alert document. b. When returning the alerts to serialize into the task manager state, it builds the alert document and bulk upserts into the AAD index. This PR does not opt any rule types into writing these generic docs but adds an example functional test that does. To test it out with the ES query rule type, add the following ``` diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts index 214d2ee4b76..0439a576b03 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts @@ -187,5 +187,12 @@ export function getRuleType( }, producer: STACK_ALERTS_FEATURE_ID, doesSetRecoveryContext: true, + alerts: { + context: 'stack', + shouldWrite: true, + mappings: { + fieldMap: {}, + }, + }, }; } ``` ## To Verify - Verify that rule registry rule types still work as expected - Verify that non rule-registry rule types still work as expected - Modify a rule type to register with FAAD and write alerts and verify that the alert documents look as expected. --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/alerting/common/rule.ts | 3 + .../server/alert/create_alert_factory.test.ts | 19 - .../server/alert/create_alert_factory.ts | 5 +- .../alerts_client/alerts_client.mock.ts | 23 + .../alerts_client/alerts_client.test.ts | 897 ++++++++++++++++++ .../server/alerts_client/alerts_client.ts | 296 ++++++ .../alerting/server/alerts_client/index.ts | 10 + .../legacy_alerts_client.mock.ts | 24 + .../legacy_alerts_client.test.ts | 95 +- .../alerts_client/legacy_alerts_client.ts | 146 +-- .../alerts_client/lib/build_new_alert.test.ts | 131 +++ .../alerts_client/lib/build_new_alert.ts | 65 ++ .../lib/build_ongoing_alert.test.ts | 240 +++++ .../alerts_client/lib/build_ongoing_alert.ts | 84 ++ .../lib/build_recovered_alert.test.ts | 216 +++++ .../lib/build_recovered_alert.ts | 97 ++ .../alerts_client/lib/format_rule.test.ts | 76 ++ .../server/alerts_client/lib/format_rule.ts | 37 + .../server/alerts_client/lib/index.ts | 11 + .../alerting/server/alerts_client/types.ts | 82 ++ .../alerts_service/alerts_service.mock.ts | 1 + .../alerts_service/alerts_service.test.ts | 192 +++- .../server/alerts_service/alerts_service.ts | 75 +- x-pack/plugins/alerting/server/config.ts | 3 +- .../alerting/server/lib/license_state.test.ts | 4 +- .../alerting/server/lib/license_state.ts | 7 +- x-pack/plugins/alerting/server/plugin.test.ts | 10 +- x-pack/plugins/alerting/server/plugin.ts | 14 +- .../server/rule_type_registry.test.ts | 124 ++- .../alerting/server/rule_type_registry.ts | 43 +- .../task_runner/execution_handler.test.ts | 3 +- .../server/task_runner/execution_handler.ts | 10 +- .../server/task_runner/task_runner.test.ts | 18 +- .../server/task_runner/task_runner.ts | 111 ++- .../server/task_runner/task_runner_factory.ts | 10 +- .../alerting/server/task_runner/types.ts | 7 +- x-pack/plugins/alerting/server/types.ts | 36 +- .../plugins/alerts/server/alert_types.ts | 101 +- .../group4/alerts_as_data/alerts_as_data.ts | 378 ++++++++ .../alerts_as_data/alerts_as_data_flapping.ts | 493 ++++++++++ .../alerting/group4/alerts_as_data/index.ts | 17 + .../install_resources.ts} | 36 +- .../tests/alerting/group4/index.ts | 2 +- 43 files changed, 3932 insertions(+), 320 deletions(-) create mode 100644 x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/alerts_client.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/index.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/format_rule.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/index.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/types.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts rename x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/{alerts_as_data.ts => alerts_as_data/install_resources.ts} (88%) diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 2f8c50cf27f84..63342b9340306 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -18,6 +18,9 @@ import { RuleSnooze } from './rule_snooze_type'; export type RuleTypeState = Record; export type RuleTypeParams = Record; +// rule type defined alert fields to persist in alerts index +export type RuleAlertData = Record; + export interface IntervalSchedule extends SavedObjectAttributes { interval: string; } diff --git a/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts b/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts index fda036dabad32..fbd693fa4fed3 100644 --- a/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts +++ b/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts @@ -31,7 +31,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -59,7 +58,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -84,7 +82,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); alertFactory.create('1'); expect(alerts).toMatchObject({ @@ -106,7 +103,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 3, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); expect(alertFactory.hasReachedAlertLimit()).toBe(false); @@ -127,7 +123,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -171,7 +166,6 @@ describe('createAlertFactory()', () => { canSetRecoveryContext: true, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: ['test-id-1'], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -190,11 +184,6 @@ describe('createAlertFactory()', () => { const recoveredAlerts = getRecoveredAlertsFn!(); expect(Array.isArray(recoveredAlerts)).toBe(true); expect(recoveredAlerts.length).toEqual(2); - expect(processAlerts).toHaveBeenLastCalledWith( - expect.objectContaining({ - maintenanceWindowIds: ['test-id-1'], - }) - ); }); test('returns empty array if no recovered alerts', () => { @@ -205,7 +194,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: true, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -233,7 +221,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: true, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -260,7 +247,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: false, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -289,7 +275,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const limit = alertFactory.alertLimit.getValue(); @@ -308,7 +293,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const limit = alertFactory.alertLimit.getValue(); @@ -324,7 +308,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const limit = alertFactory.alertLimit.getValue(); @@ -341,7 +324,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: true, autoRecoverAlerts: false, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toEqual({ @@ -373,7 +355,6 @@ describe('getPublicAlertFactory', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); expect(alertFactory.create).toBeDefined(); diff --git a/x-pack/plugins/alerting/server/alert/create_alert_factory.ts b/x-pack/plugins/alerting/server/alert/create_alert_factory.ts index 0ac2c207ed103..87598c0a9a0fd 100644 --- a/x-pack/plugins/alerting/server/alert/create_alert_factory.ts +++ b/x-pack/plugins/alerting/server/alert/create_alert_factory.ts @@ -54,7 +54,6 @@ export interface CreateAlertFactoryOpts< logger: Logger; maxAlerts: number; autoRecoverAlerts: boolean; - maintenanceWindowIds: string[]; canSetRecoveryContext?: boolean; } @@ -67,7 +66,6 @@ export function createAlertFactory< logger, maxAlerts, autoRecoverAlerts, - maintenanceWindowIds, canSetRecoveryContext = false, }: CreateAlertFactoryOpts): AlertFactory { // Keep track of which alerts we started with so we can determine which have recovered @@ -154,7 +152,8 @@ export function createAlertFactory< autoRecoverAlerts, // flappingSettings.enabled is false, as we only want to use this function to get the recovered alerts flappingSettings: DISABLE_FLAPPING_SETTINGS, - maintenanceWindowIds, + // no maintenance window IDs are passed as we only want to use this function to get recovered alerts + maintenanceWindowIds: [], }); return Object.keys(currentRecoveredAlerts ?? {}).map( (alertId: string) => currentRecoveredAlerts[alertId] diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts new file mode 100644 index 0000000000000..20bf955359d3b --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ +const createAlertsClientMock = () => { + return jest.fn().mockImplementation(() => { + return { + processAndLogAlerts: jest.fn(), + getTrackedAlerts: jest.fn(), + getProcessedAlerts: jest.fn(), + getAlertsToSerialize: jest.fn(), + hasReachedAlertLimit: jest.fn(), + checkLimitUsage: jest.fn(), + getExecutorServices: jest.fn(), + }; + }); +}; + +export const alertsClientMock = { + create: createAlertsClientMock(), +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts new file mode 100644 index 0000000000000..970cccbaf5e7e --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts @@ -0,0 +1,897 @@ +/* + * 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 { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { DEFAULT_FLAPPING_SETTINGS, RecoveredActionGroup, RuleNotifyWhen } from '../types'; +import * as LegacyAlertsClientModule from './legacy_alerts_client'; +import { Alert } from '../alert/alert'; +import { AlertsClient } from './alerts_client'; +import { AlertRuleData } from './types'; +import { legacyAlertsClientMock } from './legacy_alerts_client.mock'; +import { range } from 'lodash'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { ruleRunMetricsStoreMock } from '../lib/rule_run_metrics_store.mock'; + +const date = '2023-03-28T22:27:28.159Z'; +const maxAlerts = 1000; +let logger: ReturnType; +const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; +const alertingEventLogger = alertingEventLoggerMock.create(); +const ruleRunMetricsStore = ruleRunMetricsStoreMock.create(); + +const ruleType: jest.Mocked = { + id: 'test.rule-type', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, + }, +}; + +const mockLegacyAlertsClient = legacyAlertsClientMock.create(); + +const alertRuleData: AlertRuleData = { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], +}; + +describe('Alerts Client', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(date)); + }); + + beforeEach(() => { + jest.resetAllMocks(); + logger = loggingSystemMock.createLogger(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('initializeExecution()', () => { + test('should initialize LegacyAlertsClient', async () => { + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: {}, + recovered: {}, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + // no alerts to query for + expect(clusterClient.search).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + test('should query for alert UUIDs if they exist', async () => { + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: { + '1': new Alert('1', { + state: { foo: true }, + meta: { + flapping: false, + flappingHistory: [true, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }), + '2': new Alert('2', { + state: { foo: false }, + meta: { + flapping: false, + flappingHistory: [true, false, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'def', + }, + }), + }, + recovered: { + '3': new Alert('3', { + state: { foo: false }, + meta: { + flapping: false, + flappingHistory: [true, false, false], + uuid: 'xyz', + }, + }), + }, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + expect(clusterClient.search).toHaveBeenCalledWith({ + body: { + query: { + bool: { + filter: [ + { term: { 'kibana.alert.rule.uuid': '1' } }, + { terms: { 'kibana.alert.uuid': ['abc', 'def', 'xyz'] } }, + ], + }, + }, + size: 3, + }, + index: '.internal.alerts-test.alerts-default-*', + }); + + spy.mockRestore(); + }); + + test('should split queries into chunks when there are greater than 10,000 alert UUIDs', async () => { + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: range(15000).reduce((acc: Record>, value: number) => { + const id: string = `${value}`; + acc[id] = new Alert(id, { + state: { foo: true }, + meta: { + flapping: false, + flappingHistory: [true, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: id, + }, + }); + return acc; + }, {}), + recovered: {}, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + expect(clusterClient.search).toHaveBeenCalledTimes(2); + + spy.mockRestore(); + }); + + test('should log but not throw if query returns error', async () => { + clusterClient.search.mockImplementation(() => { + throw new Error('search failed!'); + }); + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: { + '1': new Alert('1', { + state: { foo: true }, + meta: { + flapping: false, + flappingHistory: [true, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }), + }, + recovered: {}, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + expect(clusterClient.search).toHaveBeenCalledWith({ + body: { + query: { + bool: { + filter: [ + { term: { 'kibana.alert.rule.uuid': '1' } }, + { terms: { 'kibana.alert.uuid': ['abc'] } }, + ], + }, + }, + size: 1, + }, + index: '.internal.alerts-test.alerts-default-*', + }); + + expect(logger.error).toHaveBeenCalledWith( + `Error searching for tracked alerts by UUID - search failed!` + ); + + spy.mockRestore(); + }); + }); + + describe('test getAlertsToSerialize()', () => { + test('should index new alerts', async () => { + const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + await alertsClient.initializeExecution({ + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }); + + // Report 2 new alerts + const alertExecutorService = alertsClient.getExecutorServices(); + alertExecutorService.create('1').scheduleActions('default'); + alertExecutorService.create('2').scheduleActions('default'); + + alertsClient.processAndLogAlerts({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: false, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyWhen: RuleNotifyWhen.CHANGE, + maintenanceWindowIds: [], + }); + + const { alertsToReturn } = await alertsClient.getAlertsToSerialize(); + + const uuid1 = alertsToReturn['1'].meta?.uuid; + const uuid2 = alertsToReturn['2'].meta?.uuid; + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: 'wait_for', + require_alias: true, + body: [ + { index: { _id: uuid1 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '1', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid1, + }, + space_ids: ['default'], + }, + }, + { index: { _id: uuid2 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '2', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid2, + }, + space_ids: ['default'], + }, + }, + ], + }); + }); + + test('should update ongoing alerts in existing index', async () => { + clusterClient.search.mockResolvedValue({ + took: 10, + timed_out: false, + _shards: { failed: 0, successful: 1, total: 1, skipped: 0 }, + hits: { + total: { + relation: 'eq', + value: 1, + }, + hits: [ + { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + _source: { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '1', + }, + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'active', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + }, + ], + }, + }); + const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + await alertsClient.initializeExecution({ + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { + '1': { + state: { foo: true, start: '2023-03-28T12:27:28.159Z', duration: '0' }, + meta: { + flapping: false, + flappingHistory: [true], + maintenanceWindowIds: [], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }, + }, + recoveredAlertsFromState: {}, + }); + + // Report 1 new alert and 1 active alert + const alertExecutorService = alertsClient.getExecutorServices(); + alertExecutorService.create('1').scheduleActions('default'); + alertExecutorService.create('2').scheduleActions('default'); + + alertsClient.processAndLogAlerts({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: false, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyWhen: RuleNotifyWhen.CHANGE, + maintenanceWindowIds: [], + }); + + const { alertsToReturn } = await alertsClient.getAlertsToSerialize(); + + const uuid2 = alertsToReturn['2'].meta?.uuid; + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: 'wait_for', + require_alias: true, + body: [ + { + index: { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + require_alias: false, + }, + }, + // ongoing alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '36000000000000', + }, + flapping: false, + flapping_history: [true, false], + instance: { + id: '1', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'active', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + { index: { _id: uuid2 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '2', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid2, + }, + space_ids: ['default'], + }, + }, + ], + }); + }); + + test('should recover recovered alerts in existing index', async () => { + clusterClient.search.mockResolvedValue({ + took: 10, + timed_out: false, + _shards: { failed: 0, successful: 1, total: 1, skipped: 0 }, + hits: { + total: { + relation: 'eq', + value: 1, + }, + hits: [ + { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + _source: { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '1', + }, + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'active', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + }, + { + _id: 'def', + _index: '.internal.alerts-test.alerts-default-000002', + _source: { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '36000000000000', + }, + flapping: false, + flapping_history: [true, false], + instance: { + id: '2', + }, + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T02:27:28.159Z', + status: 'active', + uuid: 'def', + }, + space_ids: ['default'], + }, + }, + }, + ], + }, + }); + const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + await alertsClient.initializeExecution({ + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { + '1': { + state: { foo: true, start: '2023-03-28T12:27:28.159Z', duration: '0' }, + meta: { + flapping: false, + flappingHistory: [true], + maintenanceWindowIds: [], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }, + '2': { + state: { foo: true, start: '2023-03-28T02:27:28.159Z', duration: '36000000000000' }, + meta: { + flapping: false, + flappingHistory: [true, false], + maintenanceWindowIds: [], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'def', + }, + }, + }, + recoveredAlertsFromState: {}, + }); + + // Report 1 new alert and 1 active alert, recover 1 alert + const alertExecutorService = alertsClient.getExecutorServices(); + alertExecutorService.create('2').scheduleActions('default'); + alertExecutorService.create('3').scheduleActions('default'); + + alertsClient.processAndLogAlerts({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: false, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyWhen: RuleNotifyWhen.CHANGE, + maintenanceWindowIds: [], + }); + + const { alertsToReturn } = await alertsClient.getAlertsToSerialize(); + + const uuid3 = alertsToReturn['3'].meta?.uuid; + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: 'wait_for', + require_alias: true, + body: [ + { + index: { + _id: 'def', + _index: '.internal.alerts-test.alerts-default-000002', + require_alias: false, + }, + }, + // ongoing alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '72000000000000', + }, + flapping: false, + flapping_history: [true, false, false], + instance: { + id: '2', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T02:27:28.159Z', + status: 'active', + uuid: 'def', + }, + space_ids: ['default'], + }, + }, + { index: { _id: uuid3 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '3', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid3, + }, + space_ids: ['default'], + }, + }, + { + index: { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + require_alias: false, + }, + }, + // recovered alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'recovered', + duration: { + us: '36000000000000', + }, + end: date, + flapping: false, + flapping_history: [true, true], + instance: { + id: '1', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'recovered', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts new file mode 100644 index 0000000000000..b5d6e08d0857b --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -0,0 +1,296 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core/server'; +import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils'; +import { chunk, flatMap, keys } from 'lodash'; +import { SearchRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../types'; +import { LegacyAlertsClient } from './legacy_alerts_client'; +import { getIndexTemplateAndPattern } from '../alerts_service/resource_installer_utils'; +import { CreateAlertsClientParams } from '../alerts_service/alerts_service'; +import { + type AlertRule, + IAlertsClient, + InitializeExecutionOpts, + ProcessAndLogAlertsOpts, + TrackedAlerts, +} from './types'; +import { buildNewAlert, buildOngoingAlert, buildRecoveredAlert, formatRule } from './lib'; + +// Term queries can take up to 10,000 terms +const CHUNK_SIZE = 10000; + +export interface AlertsClientParams extends CreateAlertsClientParams { + elasticsearchClientPromise: Promise; +} + +export class AlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> implements IAlertsClient +{ + private legacyAlertsClient: LegacyAlertsClient< + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >; + + // Query for alerts from the previous execution in order to identify the + // correct index to use if and when we need to make updates to existing active or + // recovered alerts + private fetchedAlerts: { + indices: Record; + data: Record; + }; + + private rule: AlertRule = {}; + + constructor(private readonly options: AlertsClientParams) { + this.legacyAlertsClient = new LegacyAlertsClient< + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ logger: this.options.logger, ruleType: this.options.ruleType }); + this.fetchedAlerts = { indices: {}, data: {} }; + this.rule = formatRule({ rule: this.options.rule, ruleType: this.options.ruleType }); + } + + public async initializeExecution(opts: InitializeExecutionOpts) { + await this.legacyAlertsClient.initializeExecution(opts); + + // Get tracked alert UUIDs to query for + // TODO - we can consider refactoring to store the previous execution UUID and query + // for active and recovered alerts from the previous execution using that UUID + const trackedAlerts = this.legacyAlertsClient.getTrackedAlerts(); + + const uuidsToFetch: string[] = []; + keys(trackedAlerts).forEach((key) => { + const tkey = key as keyof TrackedAlerts; + keys(trackedAlerts[tkey]).forEach((alertId: string) => { + uuidsToFetch.push(trackedAlerts[tkey][alertId].getUuid()); + }); + }); + + if (!uuidsToFetch.length) { + return; + } + + const queryByUuid = async (uuids: string[]) => { + return await this.search({ + size: uuids.length, + query: { + bool: { + filter: [ + { + term: { + [ALERT_RULE_UUID]: this.options.rule.id, + }, + }, + { + terms: { + [ALERT_UUID]: uuids, + }, + }, + ], + }, + }, + }); + }; + + try { + const results = await Promise.all( + chunk(uuidsToFetch, CHUNK_SIZE).map((uuidChunk: string[]) => queryByUuid(uuidChunk)) + ); + + for (const hit of results.flat()) { + const alertHit: Alert & AlertData = hit._source as Alert & AlertData; + const alertUuid = alertHit.kibana.alert.uuid; + const alertId = alertHit.kibana.alert.instance.id; + + // Keep track of existing alert document so we can copy over data if alert is ongoing + this.fetchedAlerts.data[alertId] = alertHit; + + // Keep track of index so we can update the correct document + this.fetchedAlerts.indices[alertUuid] = hit._index; + } + } catch (err) { + this.options.logger.error(`Error searching for tracked alerts by UUID - ${err.message}`); + } + } + + public async search(queryBody: SearchRequest['body']) { + const context = this.options.ruleType.alerts?.context; + const esClient = await this.options.elasticsearchClientPromise; + + const indexTemplateAndPattern = getIndexTemplateAndPattern({ + context: context!, + namespace: this.options.ruleType.alerts?.isSpaceAware + ? this.options.namespace + : DEFAULT_NAMESPACE_STRING, + }); + + const { + hits: { hits }, + } = await esClient.search({ + index: indexTemplateAndPattern.pattern, + body: queryBody, + }); + + return hits; + } + + public hasReachedAlertLimit(): boolean { + return this.legacyAlertsClient.hasReachedAlertLimit(); + } + + public checkLimitUsage() { + return this.legacyAlertsClient.checkLimitUsage(); + } + + public processAndLogAlerts(opts: ProcessAndLogAlertsOpts) { + this.legacyAlertsClient.processAndLogAlerts(opts); + } + + public getProcessedAlerts( + type: 'new' | 'active' | 'activeCurrent' | 'recovered' | 'recoveredCurrent' + ) { + return this.legacyAlertsClient.getProcessedAlerts(type); + } + + public async getAlertsToSerialize() { + const currentTime = new Date().toISOString(); + const context = this.options.ruleType.alerts?.context; + const esClient = await this.options.elasticsearchClientPromise; + + const indexTemplateAndPattern = getIndexTemplateAndPattern({ + context: context!, + namespace: this.options.ruleType.alerts?.isSpaceAware + ? this.options.namespace + : DEFAULT_NAMESPACE_STRING, + }); + + const { alertsToReturn, recoveredAlertsToReturn } = + await this.legacyAlertsClient.getAlertsToSerialize(false); + + const activeAlerts = this.legacyAlertsClient.getProcessedAlerts('active'); + const recoveredAlerts = this.legacyAlertsClient.getProcessedAlerts('recovered'); + + // TODO - Lifecycle alerts set some other fields based on alert status + // Example: workflow status - default to 'open' if not set + // event action: new alert = 'new', active alert: 'active', otherwise 'close' + + const activeAlertsToIndex: Array = []; + for (const id of keys(alertsToReturn)) { + // See if there's an existing active alert document + if ( + this.fetchedAlerts.data.hasOwnProperty(id) && + this.fetchedAlerts.data[id].kibana.alert.status === 'active' + ) { + activeAlertsToIndex.push( + buildOngoingAlert< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ + alert: this.fetchedAlerts.data[id], + legacyAlert: activeAlerts[id], + rule: this.rule, + timestamp: currentTime, + }) + ); + } else { + activeAlertsToIndex.push( + buildNewAlert< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ legacyAlert: activeAlerts[id], rule: this.rule, timestamp: currentTime }) + ); + } + } + + const recoveredAlertsToIndex: Array = []; + for (const id of keys(recoveredAlertsToReturn)) { + // See if there's an existing alert document + // If there is not, log an error because there should be + if (this.fetchedAlerts.data.hasOwnProperty(id)) { + recoveredAlertsToIndex.push( + buildRecoveredAlert< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ + alert: this.fetchedAlerts.data[id], + legacyAlert: recoveredAlerts[id], + rule: this.rule, + timestamp: currentTime, + recoveryActionGroup: this.options.ruleType.recoveryActionGroup.id, + }) + ); + } else { + this.options.logger.warn( + `Could not find alert document to update for recovered alert with id ${id} and uuid ${recoveredAlerts[ + id + ].getUuid()}` + ); + } + } + + const alertsToIndex = [...activeAlertsToIndex, ...recoveredAlertsToIndex]; + if (alertsToIndex.length > 0) { + await esClient.bulk({ + refresh: 'wait_for', + index: indexTemplateAndPattern.alias, + require_alias: true, + body: flatMap( + [...activeAlertsToIndex, ...recoveredAlertsToIndex].map((alert: Alert & AlertData) => [ + { + index: { + _id: alert.kibana.alert.uuid, + // If we know the concrete index for this alert, specify it + ...(this.fetchedAlerts.indices[alert.kibana.alert.uuid] + ? { + _index: this.fetchedAlerts.indices[alert.kibana.alert.uuid], + require_alias: false, + } + : {}), + }, + }, + alert, + ]) + ), + }); + } + + // The flapping value that is persisted inside the task manager state (and used in the next execution) + // is different than the value that should be written to the alert document. For this reason, we call + // getAlertsToSerialize() twice, once before building and bulk indexing alert docs and once after to return + // the value for task state serialization + + // This will be a blocker if ever we want to stop serializing alert data inside the task state and just use + // the fetched alert document. + return await this.legacyAlertsClient.getAlertsToSerialize(); + } + + public getExecutorServices() { + return this.legacyAlertsClient.getExecutorServices(); + } +} diff --git a/x-pack/plugins/alerting/server/alerts_client/index.ts b/x-pack/plugins/alerting/server/alerts_client/index.ts new file mode 100644 index 0000000000000..442f8935650f5 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { type LegacyAlertsClientParams, LegacyAlertsClient } from './legacy_alerts_client'; +export { AlertsClient } from './alerts_client'; +export type { AlertRuleData } from './types'; diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts new file mode 100644 index 0000000000000..5154761a716a2 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +const createLegacyAlertsClientMock = () => { + return jest.fn().mockImplementation(() => { + return { + initializeExecution: jest.fn(), + processAndLogAlerts: jest.fn(), + getTrackedAlerts: jest.fn(), + getProcessedAlerts: jest.fn(), + getAlertsToSerialize: jest.fn(), + hasReachedAlertLimit: jest.fn(), + checkLimitUsage: jest.fn(), + getExecutorServices: jest.fn(), + }; + }); +}; + +export const legacyAlertsClientMock = { + create: createLegacyAlertsClientMock(), +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts index 4df56107bda94..b510a06f2817a 100644 --- a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts @@ -13,6 +13,7 @@ import { Alert } from '../alert/alert'; import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; import { ruleRunMetricsStoreMock } from '../lib/rule_run_metrics_store.mock'; import { getAlertsForNotification, processAlerts } from '../lib'; +import { trimRecoveredAlerts } from '../lib/trim_recovered_alerts'; import { logAlerts } from '../task_runner/log_alerts'; import { DEFAULT_FLAPPING_SETTINGS } from '../../common/rules_settings'; import { schema } from '@kbn/config-schema'; @@ -63,6 +64,12 @@ jest.mock('../lib', () => { }; }); +jest.mock('../lib/trim_recovered_alerts', () => { + return { + trimRecoveredAlerts: jest.fn(), + }; +}); + jest.mock('../lib/get_alerts_for_notification', () => { return { getAlertsForNotification: jest.fn(), @@ -94,7 +101,7 @@ const ruleType: jest.Mocked = { const testAlert1 = { state: { foo: 'bar' }, - meta: { flapping: false, flappingHistory: [false, false] }, + meta: { flapping: false, flappingHistory: [false, false], uuid: 'abc' }, }; const testAlert2 = { state: { any: 'value' }, @@ -103,6 +110,7 @@ const testAlert2 = { group: 'default', date: new Date(), }, + uuid: 'def', }, }; @@ -112,21 +120,22 @@ describe('Legacy Alerts Client', () => { logger = loggingSystemMock.createLogger(); }); - test('initialize() should create alert factory with given alerts', () => { + test('initializeExecution() should create alert factory with given alerts', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - ['test-id-1'] - ); + recoveredAlertsFromState: {}, + }); expect(createAlertFactory).toHaveBeenCalledWith({ alerts: { @@ -137,71 +146,73 @@ describe('Legacy Alerts Client', () => { maxAlerts: 1000, canSetRecoveryContext: false, autoRecoverAlerts: true, - maintenanceWindowIds: ['test-id-1'], }); }); - test('getExecutorServices() should call getPublicAlertFactory on alert factory', () => { + test('getExecutorServices() should call getPublicAlertFactory on alert factory', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.getExecutorServices(); expect(getPublicAlertFactory).toHaveBeenCalledWith(mockCreateAlertFactory); }); - test('checkLimitUsage() should pass through to alert factory function', () => { + test('checkLimitUsage() should pass through to alert factory function', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.checkLimitUsage(); expect(mockCreateAlertFactory.alertLimit.checkLimitUsage).toHaveBeenCalled(); }); - test('hasReachedAlertLimit() should pass through to alert factory function', () => { + test('hasReachedAlertLimit() should pass through to alert factory function', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.hasReachedAlertLimit(); expect(mockCreateAlertFactory.hasReachedAlertLimit).toHaveBeenCalled(); }); - test('processAndLogAlerts() should call processAlerts, setFlapping and logAlerts and store results', () => { + test('processAndLogAlerts() should call processAlerts, trimRecoveredAlerts, getAlertsForNotification and logAlerts and store results', async () => { (processAlerts as jest.Mock).mockReturnValue({ newAlerts: {}, activeAlerts: { @@ -211,6 +222,10 @@ describe('Legacy Alerts Client', () => { currentRecoveredAlerts: {}, recoveredAlerts: {}, }); + (trimRecoveredAlerts as jest.Mock).mockReturnValue({ + trimmedAlertsRecovered: {}, + earlyRecoveredAlerts: {}, + }); (getAlertsForNotification as jest.Mock).mockReturnValue({ newAlerts: {}, activeAlerts: { @@ -226,24 +241,24 @@ describe('Legacy Alerts Client', () => { }); const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `ruleLogPrefix`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.processAndLogAlerts({ eventLogger: alertingEventLogger, - ruleLabel: `ruleLogPrefix`, ruleRunMetricsStore, - shouldLogAndScheduleActionsForAlerts: true, + shouldLogAlerts: true, flappingSettings: DEFAULT_FLAPPING_SETTINGS, notifyWhen: RuleNotifyWhen.CHANGE, maintenanceWindowIds: ['window-id1', 'window-id2'], @@ -266,6 +281,8 @@ describe('Legacy Alerts Client', () => { maintenanceWindowIds: ['window-id1', 'window-id2'], }); + expect(trimRecoveredAlerts).toHaveBeenCalledWith(logger, {}, 1000); + expect(getAlertsForNotification).toHaveBeenCalledWith( { enabled: true, diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts index c432748da59c1..7542785dd3a6e 100644 --- a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts @@ -5,7 +5,7 @@ * 2.0. */ import { Logger } from '@kbn/core/server'; -import { cloneDeep, merge } from 'lodash'; +import { cloneDeep, keys, merge } from 'lodash'; import { Alert } from '../alert/alert'; import { AlertFactory, @@ -18,23 +18,24 @@ import { setFlapping, getAlertsForNotification, } from '../lib'; -import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { trimRecoveredAlerts } from '../lib/trim_recovered_alerts'; -import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { logAlerts } from '../task_runner/log_alerts'; +import { AlertInstanceContext, AlertInstanceState, WithoutReservedActionGroups } from '../types'; +import { + DEFAULT_FLAPPING_SETTINGS, + RulesSettingsFlappingProperties, +} from '../../common/rules_settings'; import { - AlertInstanceContext, - AlertInstanceState, - RawAlertInstance, - WithoutReservedActionGroups, - RuleNotifyWhenType, -} from '../types'; -import { RulesSettingsFlappingProperties } from '../../common/rules_settings'; - -interface ConstructorOpts { + IAlertsClient, + InitializeExecutionOpts, + ProcessAndLogAlertsOpts, + TrackedAlerts, +} from './types'; +import { DEFAULT_MAX_ALERTS } from '../config'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; + +export interface LegacyAlertsClientParams { logger: Logger; - maxAlerts: number; ruleType: UntypedNormalizedRuleType; } @@ -43,10 +44,21 @@ export class LegacyAlertsClient< Context extends AlertInstanceContext, ActionGroupIds extends string, RecoveryActionGroupId extends string -> { - private activeAlertsFromPreviousExecution: Record>; - private recoveredAlertsFromPreviousExecution: Record>; - private alerts: Record>; +> implements IAlertsClient +{ + private maxAlerts: number = DEFAULT_MAX_ALERTS; + private flappingSettings: RulesSettingsFlappingProperties = DEFAULT_FLAPPING_SETTINGS; + private ruleLogPrefix: string = ''; + + // Alerts from the previous execution that are deserialized from the task state + private trackedAlerts: TrackedAlerts = { + active: {}, + recovered: {}, + }; + + // Alerts reported from the rule executor using the alert factory + private reportedAlerts: Record> = {}; + private processedAlerts: { new: Record>; active: Record>; @@ -60,10 +72,8 @@ export class LegacyAlertsClient< Context, WithoutReservedActionGroups >; - constructor(private readonly options: ConstructorOpts) { - this.alerts = {}; - this.activeAlertsFromPreviousExecution = {}; - this.recoveredAlertsFromPreviousExecution = {}; + + constructor(private readonly options: LegacyAlertsClientParams) { this.processedAlerts = { new: {}, active: {}, @@ -73,77 +83,70 @@ export class LegacyAlertsClient< }; } - public initialize( - activeAlertsFromState: Record, - recoveredAlertsFromState: Record, - maintenanceWindowIds: string[] - ) { - for (const id in activeAlertsFromState) { - if (activeAlertsFromState.hasOwnProperty(id)) { - this.activeAlertsFromPreviousExecution[id] = new Alert( - id, - activeAlertsFromState[id] - ); - } + public async initializeExecution({ + maxAlerts, + ruleLabel, + flappingSettings, + activeAlertsFromState, + recoveredAlertsFromState, + }: InitializeExecutionOpts) { + this.maxAlerts = maxAlerts; + this.flappingSettings = flappingSettings; + this.ruleLogPrefix = ruleLabel; + + for (const id of keys(activeAlertsFromState)) { + this.trackedAlerts.active[id] = new Alert(id, activeAlertsFromState[id]); } - for (const id in recoveredAlertsFromState) { - if (recoveredAlertsFromState.hasOwnProperty(id)) { - this.recoveredAlertsFromPreviousExecution[id] = new Alert( - id, - recoveredAlertsFromState[id] - ); - } + for (const id of keys(recoveredAlertsFromState)) { + this.trackedAlerts.recovered[id] = new Alert( + id, + recoveredAlertsFromState[id] + ); } - this.alerts = cloneDeep(this.activeAlertsFromPreviousExecution); + // Legacy alerts client creates a copy of the active tracked alerts + // This copy is updated when rule executors report alerts back to the framework + // while the original alert is preserved + this.reportedAlerts = cloneDeep(this.trackedAlerts.active); this.alertFactory = createAlertFactory< State, Context, WithoutReservedActionGroups >({ - alerts: this.alerts, + alerts: this.reportedAlerts, logger: this.options.logger, - maxAlerts: this.options.maxAlerts, + maxAlerts: this.maxAlerts, autoRecoverAlerts: this.options.ruleType.autoRecoverAlerts ?? true, canSetRecoveryContext: this.options.ruleType.doesSetRecoveryContext ?? false, - maintenanceWindowIds, }); } + public getTrackedAlerts() { + return this.trackedAlerts; + } + public processAndLogAlerts({ eventLogger, - ruleLabel, ruleRunMetricsStore, - shouldLogAndScheduleActionsForAlerts, + shouldLogAlerts, flappingSettings, notifyWhen, maintenanceWindowIds, - }: { - eventLogger: AlertingEventLogger; - ruleLabel: string; - shouldLogAndScheduleActionsForAlerts: boolean; - ruleRunMetricsStore: RuleRunMetricsStore; - flappingSettings: RulesSettingsFlappingProperties; - notifyWhen: RuleNotifyWhenType | null; - maintenanceWindowIds: string[]; - }) { + }: ProcessAndLogAlertsOpts) { const { newAlerts: processedAlertsNew, activeAlerts: processedAlertsActive, currentRecoveredAlerts: processedAlertsRecoveredCurrent, recoveredAlerts: processedAlertsRecovered, } = processAlerts({ - alerts: this.alerts, - existingAlerts: this.activeAlertsFromPreviousExecution, - previouslyRecoveredAlerts: this.recoveredAlertsFromPreviousExecution, + alerts: this.reportedAlerts, + existingAlerts: this.trackedAlerts.active, + previouslyRecoveredAlerts: this.trackedAlerts.recovered, hasReachedAlertLimit: this.alertFactory!.hasReachedAlertLimit(), - alertLimit: this.options.maxAlerts, - autoRecoverAlerts: - this.options.ruleType.autoRecoverAlerts !== undefined - ? this.options.ruleType.autoRecoverAlerts - : true, + alertLimit: this.maxAlerts, + autoRecoverAlerts: this.options.ruleType.autoRecoverAlerts ?? true, flappingSettings, maintenanceWindowIds, }); @@ -151,7 +154,7 @@ export class LegacyAlertsClient< const { trimmedAlertsRecovered, earlyRecoveredAlerts } = trimRecoveredAlerts( this.options.logger, processedAlertsRecovered, - this.options.maxAlerts + this.maxAlerts ); const alerts = getAlertsForNotification( @@ -177,10 +180,10 @@ export class LegacyAlertsClient< newAlerts: alerts.newAlerts, activeAlerts: alerts.currentActiveAlerts, recoveredAlerts: alerts.currentRecoveredAlerts, - ruleLogPrefix: ruleLabel, + ruleLogPrefix: this.ruleLogPrefix, ruleRunMetricsStore, canSetRecoveryContext: this.options.ruleType.doesSetRecoveryContext ?? false, - shouldPersistAlerts: shouldLogAndScheduleActionsForAlerts, + shouldPersistAlerts: shouldLogAlerts, }); } @@ -194,7 +197,10 @@ export class LegacyAlertsClient< return {}; } - public getAlertsToSerialize() { + public async getAlertsToSerialize(shouldSetFlapping: boolean = true) { + if (shouldSetFlapping) { + this.setFlapping(); + } return determineAlertsToReturn( this.processedAlerts.active, this.processedAlerts.recovered @@ -213,9 +219,9 @@ export class LegacyAlertsClient< return getPublicAlertFactory(this.alertFactory!); } - public setFlapping(flappingSettings: RulesSettingsFlappingProperties) { + public setFlapping() { setFlapping( - flappingSettings, + this.flappingSettings, this.processedAlerts.active, this.processedAlerts.recovered ); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts new file mode 100644 index 0000000000000..f479c821e098f --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts @@ -0,0 +1,131 @@ +/* + * 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 { Alert as LegacyAlert } from '../../alert/alert'; +import { buildNewAlert } from './build_new_alert'; +import type { AlertRule } from '../types'; + +const rule = { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', +}; +const alertRule: AlertRule = { + kibana: { + alert: { + rule, + }, + space_ids: ['default'], + }, +}; + +describe('buildNewAlert', () => { + test('should build alert document with info from legacy alert', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + + expect( + buildNewAlert<{}, {}, {}, 'default', 'recovered'>({ + legacyAlert, + rule: alertRule, + timestamp: '2023-03-28T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + rule, + status: 'active', + uuid: legacyAlert.getUuid(), + }, + space_ids: ['default'], + }, + }); + }); + + test('should include start and duration if set', () => { + const now = Date.now(); + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default').replaceState({ start: now, duration: '0' }); + + expect( + buildNewAlert<{}, {}, {}, 'default', 'recovered'>({ + legacyAlert, + rule: alertRule, + timestamp: '2023-03-28T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: now, + rule, + status: 'active', + uuid: legacyAlert.getUuid(), + }, + space_ids: ['default'], + }, + }); + }); + + test('should include flapping history and maintenance window ids if set', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + legacyAlert.setFlappingHistory([true, false, false, false, true, true]); + legacyAlert.setMaintenanceWindowIds(['maint-1', 'maint-321']); + + expect( + buildNewAlert<{}, {}, {}, 'default', 'recovered'>({ + legacyAlert, + rule: alertRule, + timestamp: '2023-03-28T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + flapping: false, + flapping_history: [true, false, false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-1', 'maint-321'], + rule, + status: 'active', + uuid: legacyAlert.getUuid(), + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts new file mode 100644 index 0000000000000..d54f241db91ab --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isEmpty } from 'lodash'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../../alert/alert'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../../types'; +import type { AlertRule } from '../types'; + +interface BuildNewAlertOpts< + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + legacyAlert: LegacyAlert; + rule: AlertRule; + timestamp: string; +} + +/** + * Builds a new alert document from the LegacyAlert class + * Currently only populates framework fields and not any rule type specific fields + */ + +export const buildNewAlert = < + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +>({ + legacyAlert, + rule, + timestamp, +}: BuildNewAlertOpts): Alert & + AlertData => { + return { + '@timestamp': timestamp, + kibana: { + alert: { + action_group: legacyAlert.getScheduledActionOptions()?.actionGroup, + flapping: legacyAlert.getFlapping(), + instance: { + id: legacyAlert.getId(), + }, + maintenance_window_ids: legacyAlert.getMaintenanceWindowIds(), + rule: rule.kibana?.alert.rule, + status: 'active', + uuid: legacyAlert.getUuid(), + ...(legacyAlert.getState().duration + ? { duration: { us: legacyAlert.getState().duration } } + : {}), + ...(!isEmpty(legacyAlert.getFlappingHistory()) + ? { flapping_history: legacyAlert.getFlappingHistory() } + : {}), + ...(legacyAlert.getState().start ? { start: legacyAlert.getState().start } : {}), + }, + space_ids: rule.kibana?.space_ids, + }, + } as Alert & AlertData; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts new file mode 100644 index 0000000000000..c7ae9eb092ff5 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts @@ -0,0 +1,240 @@ +/* + * 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 { Alert as LegacyAlert } from '../../alert/alert'; +import { buildOngoingAlert } from './build_ongoing_alert'; +import type { AlertRule } from '../types'; + +const rule = { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', +}; +const alertRule: AlertRule = { + kibana: { + alert: { + rule, + }, + space_ids: ['default'], + }, +}; +const existingAlert = { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'error', + duration: { + us: '0', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, +}; + +describe('buildOngoingAlert', () => { + test('should update alert document with updated info from legacy alert', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A'); + legacyAlert + .scheduleActions('warning') + .replaceState({ start: '0000-00-00T00:00:00.000Z', duration: '36000000' }); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: existingAlert, + legacyAlert, + rule: alertRule, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'warning', + duration: { + us: '36000000', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update alert document with updated rule data if rule definition has changed', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A'); + legacyAlert + .scheduleActions('warning') + .replaceState({ start: '0000-00-00T00:00:00.000Z', duration: '36000000' }); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: existingAlert, + legacyAlert, + rule: { + kibana: { + alert: { + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + }, + space_ids: ['default'], + }, + }, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'warning', + duration: { + us: '36000000', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update alert document with updated flapping history and maintenance window ids if set', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('1'); + legacyAlert.scheduleActions('error'); + legacyAlert.setFlappingHistory([false, false, true, true]); + legacyAlert.setMaintenanceWindowIds(['maint-xyz']); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: { + ...existingAlert, + kibana: { + ...existingAlert.kibana, + alert: { + ...existingAlert.kibana.alert, + flapping_history: [true, false, false, false, true, true], + maintenance_window_ids: ['maint-1', 'maint-321'], + }, + }, + }, + legacyAlert, + rule: alertRule, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'error', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-xyz'], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update alert document with latest maintenance window ids', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('1'); + legacyAlert.scheduleActions('error'); + legacyAlert.setFlappingHistory([false, false, true, true]); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: { + ...existingAlert, + kibana: { + ...existingAlert.kibana, + alert: { + ...existingAlert.kibana.alert, + flapping_history: [true, false, false, false, true, true], + maintenance_window_ids: ['maint-1', 'maint-321'], + }, + }, + }, + legacyAlert, + rule: alertRule, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'error', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts new file mode 100644 index 0000000000000..65d18834d3755 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts @@ -0,0 +1,84 @@ +/* + * 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 { isEmpty } from 'lodash'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../../alert/alert'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../../types'; +import type { AlertRule } from '../types'; + +interface BuildOngoingAlertOpts< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + alert: Alert & AlertData; + legacyAlert: LegacyAlert; + rule: AlertRule; + timestamp: string; +} + +/** + * Updates an existing alert document with data from the LegacyAlert class + * Currently only populates framework fields and not any rule type specific fields + */ + +export const buildOngoingAlert = < + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +>({ + alert, + legacyAlert, + rule, + timestamp, +}: BuildOngoingAlertOpts< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId +>): Alert & AlertData => { + return { + ...alert, + // Update the timestamp to reflect latest update time + '@timestamp': timestamp, + kibana: { + ...alert.kibana, + alert: { + ...alert.kibana.alert, + // Set latest action group as this may have changed during execution (ex: error -> warning) + action_group: legacyAlert.getScheduledActionOptions()?.actionGroup, + // Set latest flapping state + flapping: legacyAlert.getFlapping(), + // Set latest rule configuration + rule: rule.kibana?.alert.rule, + // Set latest maintenance window IDs + maintenance_window_ids: legacyAlert.getMaintenanceWindowIds(), + // Set latest duration as ongoing alerts should have updated duration + ...(legacyAlert.getState().duration + ? { duration: { us: legacyAlert.getState().duration } } + : {}), + // Set latest flapping history + ...(!isEmpty(legacyAlert.getFlappingHistory()) + ? { flapping_history: legacyAlert.getFlappingHistory() } + : {}), + + // Fields that are explicitly not updated: + // instance.id + // status - ongoing alerts should maintain 'active' status + // uuid - ongoing alerts should carry over previous UUID + // start - ongoing alerts should keep the initial start time + }, + space_ids: rule.kibana?.space_ids, + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts new file mode 100644 index 0000000000000..22ac7cfd38136 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts @@ -0,0 +1,216 @@ +/* + * 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 { Alert as LegacyAlert } from '../../alert/alert'; +import { buildRecoveredAlert } from './build_recovered_alert'; +import type { AlertRule } from '../types'; + +const rule = { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', +}; +const alertRule: AlertRule = { + kibana: { + alert: { + rule, + }, + space_ids: ['default'], + }, +}; +const existingActiveAlert = { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-x'], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, +}; + +const existingRecoveredAlert = { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + end: '2023-03-28T12:27:28.159Z', + flapping: false, + flapping_history: [true, false, false], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-x'], + start: '2023-03-27T12:27:28.159Z', + rule, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, +}; + +describe('buildRecoveredAlert', () => { + test('should update active alert document with recovered status and info from legacy alert', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert + .scheduleActions('default') + .replaceState({ end: '2023-03-30T12:27:28.159Z', duration: '36000000' }); + + expect( + buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({ + alert: existingActiveAlert, + legacyAlert, + rule: alertRule, + recoveryActionGroup: 'recovered', + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'recovered', + duration: { + us: '36000000', + }, + end: '2023-03-30T12:27:28.159Z', + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update active alert document with recovery status and updated rule data if rule definition has changed', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert + .scheduleActions('default') + .replaceState({ end: '2023-03-30T12:27:28.159Z', duration: '36000000' }); + legacyAlert.setMaintenanceWindowIds(['maint-1', 'maint-321']); + + expect( + buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({ + alert: existingActiveAlert, + legacyAlert, + recoveryActionGroup: 'NoLongerActive', + rule: { + kibana: { + alert: { + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + }, + space_ids: ['default'], + }, + }, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'NoLongerActive', + duration: { + us: '36000000', + }, + end: '2023-03-30T12:27:28.159Z', + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-1', 'maint-321'], + start: '2023-03-28T12:27:28.159Z', + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update already recovered alert document with updated flapping history but not maintenance window ids', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + legacyAlert.setFlappingHistory([false, false, true, true]); + legacyAlert.setMaintenanceWindowIds(['maint-1', 'maint-321']); + + expect( + buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({ + alert: existingRecoveredAlert, + legacyAlert, + rule: alertRule, + recoveryActionGroup: 'recovered', + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'recovered', + duration: { + us: '0', + }, + end: '2023-03-28T12:27:28.159Z', + flapping: false, + flapping_history: [false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-x'], + start: '2023-03-27T12:27:28.159Z', + rule, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts new file mode 100644 index 0000000000000..07533e2fe763c --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts @@ -0,0 +1,97 @@ +/* + * 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 { isEmpty } from 'lodash'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../../alert/alert'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../../types'; +import type { AlertRule } from '../types'; + +interface BuildRecoveredAlertOpts< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + alert: Alert & AlertData; + legacyAlert: LegacyAlert; + rule: AlertRule; + recoveryActionGroup: string; + timestamp: string; +} + +/** + * Updates an existing alert document with data from the LegacyAlert class + * This could be a currently active alert that is now recovered or a previously + * recovered alert that has updates to its flapping history + * Currently only populates framework fields and not any rule type specific fields + */ + +export const buildRecoveredAlert = < + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +>({ + alert, + legacyAlert, + rule, + timestamp, + recoveryActionGroup, +}: BuildRecoveredAlertOpts< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId +>): Alert & AlertData => { + // If we're updating an active alert to be recovered, + // persist any maintenance window IDs on the alert, otherwise + // we should only be changing fields related to flapping + const maintenanceWindowIds = + alert.kibana.alert.status === 'active' ? legacyAlert.getMaintenanceWindowIds() : null; + return { + ...alert, + // Update the timestamp to reflect latest update time + '@timestamp': timestamp, + kibana: { + ...alert.kibana, + alert: { + ...alert.kibana.alert, + // Set the recovery action group + action_group: recoveryActionGroup, + // Set latest flapping state + flapping: legacyAlert.getFlapping(), + // Set latest rule configuration + rule: rule.kibana?.alert.rule, + // Set status to 'recovered' + status: 'recovered', + // Set latest duration as recovered alerts should have updated duration + ...(legacyAlert.getState().duration + ? { duration: { us: legacyAlert.getState().duration } } + : {}), + // Set end time + ...(legacyAlert.getState().end ? { end: legacyAlert.getState().end } : {}), + // Set latest flapping history + ...(!isEmpty(legacyAlert.getFlappingHistory()) + ? { flapping_history: legacyAlert.getFlappingHistory() } + : {}), + // Set maintenance window IDs if defined + ...(maintenanceWindowIds ? { maintenance_window_ids: maintenanceWindowIds } : {}), + + // Fields that are explicitly not updated: + // instance.id + // action_group + // uuid - recovered alerts should carry over previous UUID + // start - recovered alerts should keep the initial start time + }, + space_ids: rule.kibana?.space_ids, + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts new file mode 100644 index 0000000000000..cb90b75d2c16d --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { formatRule } from './format_rule'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { RecoveredActionGroup } from '../../types'; + +const ruleType: jest.Mocked = { + id: 'test.rule-type', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, + }, +}; + +describe('formatRule', () => { + test('should format rule data', () => { + expect( + formatRule({ + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + ruleType, + }) + ).toEqual({ + kibana: { + alert: { + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.ts b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.ts new file mode 100644 index 0000000000000..70588fc4cb665 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.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 type { AlertRule, AlertRuleData } from '../types'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; + +interface FormatRuleOpts { + rule: AlertRuleData; + ruleType: UntypedNormalizedRuleType; +} + +export const formatRule = ({ rule, ruleType }: FormatRuleOpts): AlertRule => { + return { + kibana: { + alert: { + rule: { + category: ruleType.name, + consumer: rule.consumer, + execution: { + uuid: rule.executionId, + }, + name: rule.name, + parameters: rule.parameters, + producer: ruleType.producer, + revision: rule.revision, + rule_type_id: ruleType.id, + tags: rule.tags, + uuid: rule.id, + }, + }, + space_ids: [rule.spaceId], + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/index.ts b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts new file mode 100644 index 0000000000000..9c1fd2d665ae9 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { buildNewAlert } from './build_new_alert'; +export { buildOngoingAlert } from './build_ongoing_alert'; +export { buildRecoveredAlert } from './build_recovered_alert'; +export { formatRule } from './format_rule'; diff --git a/x-pack/plugins/alerting/server/alerts_client/types.ts b/x-pack/plugins/alerting/server/alerts_client/types.ts new file mode 100644 index 0000000000000..672e1eccd2118 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/types.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../alert/alert'; +import { + AlertInstanceContext, + AlertInstanceState, + RawAlertInstance, + RuleNotifyWhenType, +} from '../types'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { RulesSettingsFlappingProperties } from '../../common/rules_settings'; + +export interface AlertRuleData { + consumer: string; + executionId: string; + id: string; + name: string; + parameters: unknown; + revision: number; + spaceId: string; + tags: string[]; +} + +export interface AlertRule { + kibana?: { + alert: { + rule: Alert['kibana']['alert']['rule']; + }; + space_ids: Alert['kibana']['space_ids']; + }; +} + +export interface IAlertsClient< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + initializeExecution(opts: InitializeExecutionOpts): Promise; + hasReachedAlertLimit(): boolean; + checkLimitUsage(): void; + processAndLogAlerts(opts: ProcessAndLogAlertsOpts): void; + getProcessedAlerts( + type: 'new' | 'active' | 'activeCurrent' | 'recovered' | 'recoveredCurrent' + ): Record>; + getAlertsToSerialize(): Promise<{ + alertsToReturn: Record; + recoveredAlertsToReturn: Record; + }>; +} + +export interface ProcessAndLogAlertsOpts { + eventLogger: AlertingEventLogger; + shouldLogAlerts: boolean; + ruleRunMetricsStore: RuleRunMetricsStore; + flappingSettings: RulesSettingsFlappingProperties; + notifyWhen: RuleNotifyWhenType | null; + maintenanceWindowIds: string[]; +} + +export interface InitializeExecutionOpts { + maxAlerts: number; + ruleLabel: string; + flappingSettings: RulesSettingsFlappingProperties; + activeAlertsFromState: Record; + recoveredAlertsFromState: Record; +} + +export interface TrackedAlerts< + State extends AlertInstanceState, + Context extends AlertInstanceContext +> { + active: Record>; + recovered: Record>; +} diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts index a3754d66e1cad..1df9e8b2ff67a 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts @@ -11,6 +11,7 @@ const creatAlertsServiceMock = () => { register: jest.fn(), isInitialized: jest.fn(), getContextInitializationPromise: jest.fn(), + createAlertsClient: jest.fn(), }; }); }; diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts index 2bc7245df4e6d..b6e102350c32d 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts @@ -10,9 +10,14 @@ import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-m import { errors as EsErrors } from '@elastic/elasticsearch'; import { ReplaySubject, Subject } from 'rxjs'; import { AlertsService } from './alerts_service'; -import { IRuleTypeAlerts } from '../types'; +import { IRuleTypeAlerts, RecoveredActionGroup } from '../types'; import { retryUntil } from './test_utils'; import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { AlertsClient } from '../alerts_client'; +import { alertsClientMock } from '../alerts_client/alerts_client.mock'; + +jest.mock('../alerts_client'); let logger: ReturnType; const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -141,6 +146,7 @@ const getIndexTemplatePutBody = (opts?: GetIndexTemplatePutBodyOpts) => { const TestRegistrationContext: IRuleTypeAlerts = { context: 'test', mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, }; const getContextInitialized = async ( @@ -152,6 +158,30 @@ const getContextInitialized = async ( return result; }; +const alertsClient = alertsClientMock.create(); +const ruleType: jest.Mocked = { + id: 'test.rule-type', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, +}; + +const ruleTypeWithAlertDefinition: jest.Mocked = { + ...ruleType, + alerts: TestRegistrationContext, +}; + describe('Alerts Service', () => { let pluginStop$: Subject; @@ -1100,6 +1130,166 @@ describe('Alerts Service', () => { }); }); + describe('createAlertsClient()', () => { + let alertsService: AlertsService; + beforeEach(async () => { + (AlertsClient as jest.Mock).mockImplementation(() => alertsClient); + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + }); + + test('should create new AlertsClient', async () => { + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(AlertsClient).toHaveBeenCalledWith({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + }); + + test('should return null if rule type has no alert definition', async () => { + const result = await alertsService.createAlertsClient({ + logger, + ruleType, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(result).toBe(null); + expect(AlertsClient).not.toHaveBeenCalled(); + }); + + test('should return null if context initialization has errored', async () => { + clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ + ...SimulateTemplateResponse, + template: { + ...SimulateTemplateResponse.template, + mappings: {}, + }, + })); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + const result = await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(result).toBe(null); + expect(logger.warn).toHaveBeenCalledWith( + `There was an error in the framework installing namespace-level resources and creating concrete indices for - Failure during installation. No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping` + ); + expect(AlertsClient).not.toHaveBeenCalled(); + }); + + test('should return null if shouldWrite is false', async () => { + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + const result = await alertsService.createAlertsClient({ + logger, + ruleType: { + ...ruleType, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: false, + }, + }, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(result).toBe(null); + expect(logger.debug).toHaveBeenCalledWith( + `Resources registered and installed for test context but "shouldWrite" is set to false.` + ); + expect(AlertsClient).not.toHaveBeenCalled(); + }); + }); + describe('retries', () => { test('should retry adding ILM policy for transient ES errors', async () => { clusterClient.ilm.putLifecycle diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts index 3142b67c82d4d..3c9bc36353406 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts @@ -19,7 +19,7 @@ import { getComponentTemplateName, getIndexTemplateAndPattern, } from './resource_installer_utils'; -import { IRuleTypeAlerts } from '../types'; +import { AlertInstanceContext, AlertInstanceState, IRuleTypeAlerts, RuleAlertData } from '../types'; import { createResourceInstallationHelper, errorResult, @@ -35,6 +35,7 @@ import { createConcreteWriteIndex, installWithTimeout, } from './lib'; +import { type LegacyAlertsClientParams, type AlertRuleData, AlertsClient } from '../alerts_client'; export const TOTAL_FIELDS_LIMIT = 2500; const LEGACY_ALERT_CONTEXT = 'legacy-alert'; @@ -48,6 +49,10 @@ interface AlertsServiceParams { timeoutMs?: number; } +export interface CreateAlertsClientParams extends LegacyAlertsClientParams { + namespace: string; + rule: AlertRuleData; +} interface IAlertsService { /** * Register solution specific resources. If common resource initialization is @@ -73,6 +78,27 @@ interface IAlertsService { context: string, namespace: string ): Promise; + + /** + * If the rule type has registered an alert context, initialize and return an AlertsClient, + * otherwise return null. Currently registering an alert context is optional but in the future + * we will make it a requirement for all rule types and this function should not return null. + */ + createAlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string + >( + opts: CreateAlertsClientParams + ): Promise | null>; } export type PublicAlertsService = Pick; @@ -104,6 +130,53 @@ export class AlertsService implements IAlertsService { return this.initialized; } + public async createAlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string + >(opts: CreateAlertsClientParams) { + if (!opts.ruleType.alerts) { + return null; + } + + // Check if context specific installation has succeeded + const { result: initialized, error } = await this.getContextInitializationPromise( + opts.ruleType.alerts.context, + opts.namespace + ); + + if (!initialized) { + // TODO - retry initialization here + this.options.logger.warn( + `There was an error in the framework installing namespace-level resources and creating concrete indices for - ${error}` + ); + return null; + } + + if (!opts.ruleType.alerts.shouldWrite) { + this.options.logger.debug( + `Resources registered and installed for ${opts.ruleType.alerts.context} context but "shouldWrite" is set to false.` + ); + return null; + } + + return new AlertsClient< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ + logger: this.options.logger, + elasticsearchClientPromise: this.options.elasticsearchClientPromise, + ruleType: opts.ruleType, + namespace: opts.namespace, + rule: opts.rule, + }); + } + public async getContextInitializationPromise( context: string, namespace: string diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index 383143f527623..b1b9817ce1b9f 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -8,6 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { validateDurationSchema, parseDuration } from './lib'; +export const DEFAULT_MAX_ALERTS = 1000; const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; const ruleTypeSchema = schema.object({ id: schema.string(), @@ -44,7 +45,7 @@ const rulesSchema = schema.object({ connectorTypeOverrides: schema.maybe(schema.arrayOf(connectorTypeSchema)), }), alerts: schema.object({ - max: schema.number({ defaultValue: 1000 }), + max: schema.number({ defaultValue: DEFAULT_MAX_ALERTS }), }), ruleTypeOverrides: schema.maybe(schema.arrayOf(ruleTypeSchema)), }), diff --git a/x-pack/plugins/alerting/server/lib/license_state.test.ts b/x-pack/plugins/alerting/server/lib/license_state.test.ts index 27e89108b77e7..a0e2c31b07903 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.test.ts @@ -57,7 +57,7 @@ describe('getLicenseCheckForRuleType', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -195,7 +195,7 @@ describe('ensureLicenseForRuleType()', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ diff --git a/x-pack/plugins/alerting/server/lib/license_state.ts b/x-pack/plugins/alerting/server/lib/license_state.ts index efeed9cc6409f..c774673934ebf 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.ts @@ -21,6 +21,7 @@ import { RuleTypeState, AlertInstanceState, AlertInstanceContext, + RuleAlertData, } from '../types'; import { RuleTypeDisabledError } from './errors/rule_type_disabled'; @@ -179,7 +180,8 @@ export class LicenseState { InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: RuleType< Params, @@ -188,7 +190,8 @@ export class LicenseState { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ) { this.notifyUsage(ruleType.name, ruleType.minimumLicenseRequired); diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 5101ec8609108..42a2f2074a6f5 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -60,7 +60,7 @@ const generateAlertingConfig = (): AlertingConfig => ({ }, }); -const sampleRuleType: RuleType = { +const sampleRuleType: RuleType = { id: 'test', name: 'test', minimumLicenseRequired: 'basic', @@ -196,7 +196,7 @@ describe('Alerting Plugin', () => { const ruleType = { ...sampleRuleType, minimumLicenseRequired: 'basic', - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.ruleTaskTimeout).toBe('5m'); }); @@ -206,7 +206,7 @@ describe('Alerting Plugin', () => { ...sampleRuleType, minimumLicenseRequired: 'basic', ruleTaskTimeout: '20h', - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.ruleTaskTimeout).toBe('20h'); }); @@ -215,7 +215,7 @@ describe('Alerting Plugin', () => { const ruleType = { ...sampleRuleType, minimumLicenseRequired: 'basic', - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.cancelAlertsOnRuleTimeout).toBe(true); }); @@ -225,7 +225,7 @@ describe('Alerting Plugin', () => { ...sampleRuleType, minimumLicenseRequired: 'basic', cancelAlertsOnRuleTimeout: false, - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.cancelAlertsOnRuleTimeout).toBe(false); }); diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index f73b7070ce55b..aa779f2e4e7f5 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -61,7 +61,7 @@ import { RulesClientFactory } from './rules_client_factory'; import { RulesSettingsClientFactory } from './rules_settings_client_factory'; import { MaintenanceWindowClientFactory } from './maintenance_window_client_factory'; import { ILicenseState, LicenseState } from './lib/license_state'; -import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID } from './types'; +import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID, RuleAlertData } from './types'; import { defineRoutes } from './routes'; import { AlertInstanceContext, @@ -119,7 +119,8 @@ export interface PluginSetupContract { InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = never, - RecoveryActionGroupId extends string = never + RecoveryActionGroupId extends string = never, + AlertData extends RuleAlertData = never >( ruleType: RuleType< Params, @@ -128,7 +129,8 @@ export interface PluginSetupContract { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ): void; @@ -351,7 +353,8 @@ export class AlertingPlugin { InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, ActionGroupIds extends string = never, - RecoveryActionGroupId extends string = never + RecoveryActionGroupId extends string = never, + AlertData extends RuleAlertData = never >( ruleType: RuleType< Params, @@ -360,7 +363,8 @@ export class AlertingPlugin { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ) => { if (!(ruleType.minimumLicenseRequired in LICENSE_TYPE)) { diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index e0fcc6af28a63..272232767ae98 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -16,6 +16,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock'; import { alertsServiceMock } from './alerts_service/alerts_service.mock'; import { schema } from '@kbn/config-schema'; +import { RecoveredActionGroupId } from '../common'; const logger = loggingSystemMock.create().get(); let mockedLicenseState: jest.Mocked; @@ -73,7 +74,7 @@ describe('Create Lifecycle', () => { describe('register()', () => { test('throws if RuleType Id contains invalid characters', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -109,7 +110,7 @@ describe('Create Lifecycle', () => { }); test('throws if RuleType Id isnt a string', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 123 as unknown as string, name: 'Test', actionGroups: [ @@ -135,7 +136,7 @@ describe('Create Lifecycle', () => { }); test('throws if RuleType ruleTaskTimeout is not a valid duration', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -164,7 +165,7 @@ describe('Create Lifecycle', () => { }); test('throws if defaultScheduleInterval isnt valid', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -194,7 +195,7 @@ describe('Create Lifecycle', () => { }); test('logs warning if defaultScheduleInterval is less than configured minimumScheduleInterval and enforce = false', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -222,7 +223,7 @@ describe('Create Lifecycle', () => { }); test('logs warning and updates default if defaultScheduleInterval is less than configured minimumScheduleInterval and enforce = true', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -255,7 +256,16 @@ describe('Create Lifecycle', () => { }); test('throws if RuleType action groups contains reserved group id', () => { - const ruleType: RuleType = { + const ruleType: RuleType< + never, + never, + never, + never, + never, + 'default' | 'NotReserved', + 'recovered', + {} + > = { id: 'test', name: 'Test', actionGroups: [ @@ -291,28 +301,29 @@ describe('Create Lifecycle', () => { }); test('allows an RuleType to specify a custom recovery group', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + const ruleType: RuleType = + { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + recoveryActionGroup: { + id: 'backToAwesome', + name: 'Back To Awesome', }, - ], - defaultActionGroupId: 'default', - recoveryActionGroup: { - id: 'backToAwesome', - name: 'Back To Awesome', - }, - executor: jest.fn(), - producer: 'alerts', - minimumLicenseRequired: 'basic', - isExportable: true, - validate: { - params: { validate: (params) => params }, - }, - }; + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + isExportable: true, + validate: { + params: { validate: (params) => params }, + }, + }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); expect(registry.get('test').actionGroups).toMatchInlineSnapshot(` @@ -330,25 +341,26 @@ describe('Create Lifecycle', () => { }); test('allows an RuleType to specify a custom rule task timeout', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + const ruleType: RuleType = + { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + ruleTaskTimeout: '13m', + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + isExportable: true, + validate: { + params: { validate: (params) => params }, }, - ], - defaultActionGroupId: 'default', - ruleTaskTimeout: '13m', - executor: jest.fn(), - producer: 'alerts', - minimumLicenseRequired: 'basic', - isExportable: true, - validate: { - params: { validate: (params) => params }, - }, - }; + }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); expect(registry.get('test').ruleTaskTimeout).toBe('13m'); @@ -362,7 +374,8 @@ describe('Create Lifecycle', () => { never, never, 'default' | 'backToAwesome', - 'backToAwesome' + 'backToAwesome', + {} > = { id: 'test', name: 'Test', @@ -399,7 +412,7 @@ describe('Create Lifecycle', () => { }); test('registers the executor with the task manager', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -435,7 +448,7 @@ describe('Create Lifecycle', () => { }); test('shallow clones the given rule type', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -822,8 +835,17 @@ function ruleTypeWithVariables( id: ActionGroupIds, context: string, state: string -): RuleType { - const baseAlert: RuleType = { +): RuleType { + const baseAlert: RuleType< + never, + never, + {}, + never, + never, + ActionGroupIds, + RecoveredActionGroupId, + {} + > = { id, name: `${id}-name`, actionGroups: [], diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts index fc3e2c89e16b4..abf0223a50c08 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.ts @@ -28,6 +28,7 @@ import { ActionGroup, validateDurationSchema, parseDuration, + RuleAlertData, } from '../common'; import { ILicenseState } from './lib/license_state'; import { getRuleTypeFeatureUsageName } from './lib/get_rule_type_feature_usage_name'; @@ -92,7 +93,8 @@ export type NormalizedRuleType< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > = { actionGroups: Array>; } & Omit< @@ -103,7 +105,8 @@ export type NormalizedRuleType< InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >, 'recoveryActionGroup' | 'actionGroups' > & @@ -116,7 +119,8 @@ export type NormalizedRuleType< InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > >, 'recoveryActionGroup' @@ -129,7 +133,8 @@ export type UntypedNormalizedRuleType = NormalizedRuleType< AlertInstanceState, AlertInstanceContext, string, - string + string, + RuleAlertData >; export class RuleTypeRegistry { @@ -178,7 +183,8 @@ export class RuleTypeRegistry { InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: RuleType< Params, @@ -187,7 +193,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ) { if (this.has(ruleType.id)) { @@ -258,7 +265,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >(ruleType); this.ruleTypes.set( @@ -278,7 +286,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId | RecoveredActionGroupId + RecoveryActionGroupId | RecoveredActionGroupId, + AlertData >(normalizedRuleType, context, this.inMemoryMetrics), }, }); @@ -303,7 +312,8 @@ export class RuleTypeRegistry { InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = string, - RecoveryActionGroupId extends string = string + RecoveryActionGroupId extends string = string, + AlertData extends RuleAlertData = RuleAlertData >( id: string ): NormalizedRuleType< @@ -313,7 +323,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > { if (!this.has(id)) { throw Boom.badRequest( @@ -337,7 +348,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; } @@ -406,7 +418,8 @@ function augmentActionGroupsWithReserved< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: RuleType< Params, @@ -415,7 +428,8 @@ function augmentActionGroupsWithReserved< InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ): NormalizedRuleType< Params, @@ -424,7 +438,8 @@ function augmentActionGroupsWithReserved< InstanceState, InstanceContext, ActionGroupIds, - RecoveredActionGroupId | RecoveryActionGroupId + RecoveredActionGroupId | RecoveryActionGroupId, + AlertData > { const reservedActionGroups = getBuiltinActionGroups(ruleType.recoveryActionGroup); const { id, actionGroups, recoveryActionGroup } = ruleType; diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts index e80cefc9d13d8..5f507d0d14ae8 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -52,7 +52,8 @@ const ruleType: NormalizedRuleType< AlertInstanceState, AlertInstanceContext, 'default' | 'other-group', - 'recovered' + 'recovered', + {} > = { id: 'test', name: 'Test', diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index 11512901e977c..f37c97de908a8 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -36,6 +36,7 @@ import { RuleTypeParams, RuleTypeState, SanitizedRule, + RuleAlertData, } from '../../common'; import { generateActionHash, @@ -64,7 +65,8 @@ export class ExecutionHandler< State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > { private logger: Logger; private alertingEventLogger: PublicMethodsOf; @@ -76,7 +78,8 @@ export class ExecutionHandler< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; private taskRunnerContext: TaskRunnerContext; private taskInstance: RuleTaskInstance; @@ -116,7 +119,8 @@ export class ExecutionHandler< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >) { this.logger = logger; this.alertingEventLogger = alertingEventLogger; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index d9a4e5d08ba60..1fc307f36ef8d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -346,7 +346,23 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ state: true, history: [true] })); expect(ruleType.executor).toHaveBeenCalledTimes(1); - expect(alertsService.getContextInitializationPromise).toHaveBeenCalledWith('test', 'default'); + expect(alertsService.createAlertsClient).toHaveBeenCalledWith({ + logger, + ruleType: ruleTypeWithAlerts, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); }); test.each(ephemeralTestParams)( diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 511ec9fbed8ee..e9932a0f75848 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -48,6 +48,8 @@ import { RawAlertInstance, RuleLastRunOutcomeOrderMap, MaintenanceWindow, + RuleAlertData, + SanitizedRule, } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; @@ -69,7 +71,7 @@ import { RuleMonitoringService } from '../monitoring/rule_monitoring_service'; import { ILastRun, lastRunFromState, lastRunToRaw } from '../lib/last_run_status'; import { RunningHandler } from './running_handler'; import { RuleResultService } from '../monitoring/rule_result_service'; -import { LegacyAlertsClient } from '../alerts_client/legacy_alerts_client'; +import { LegacyAlertsClient } from '../alerts_client'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -86,7 +88,8 @@ export class TaskRunner< State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > { private context: TaskRunnerContext; private logger: Logger; @@ -99,7 +102,8 @@ export class TaskRunner< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; private readonly executionId: string; private readonly ruleTypeRegistry: RuleTypeRegistry; @@ -114,12 +118,6 @@ export class TaskRunner< private ruleMonitoring: RuleMonitoringService; private ruleRunning: RunningHandler; private ruleResult: RuleResultService; - private legacyAlertsClient: LegacyAlertsClient< - State, - Context, - ActionGroupIds, - RecoveryActionGroupId - >; constructor( ruleType: NormalizedRuleType< @@ -129,7 +127,8 @@ export class TaskRunner< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >, taskInstance: ConcreteTaskInstance, context: TaskRunnerContext, @@ -158,11 +157,6 @@ export class TaskRunner< loggerId ); this.ruleResult = new RuleResultService(); - this.legacyAlertsClient = new LegacyAlertsClient({ - logger: this.logger, - maxAlerts: context.maxAlerts, - ruleType: this.ruleType as UntypedNormalizedRuleType, - }); } private async updateRuleSavedObjectPostRun( @@ -211,6 +205,19 @@ export class TaskRunner< return !this.context.cancelAlertsOnRuleTimeout || !this.ruleType.cancelAlertsOnRuleTimeout; } + private getAADRuleData(rule: SanitizedRule, spaceId: string) { + return { + consumer: rule.consumer, + executionId: this.executionId, + id: rule.id, + name: rule.name, + parameters: rule.params, + revision: rule.revision, + spaceId, + tags: rule.tags, + }; + } + // Usage counter for telemetry // This keeps track of how many times action executions were skipped after rule // execution completed successfully after the execution timeout @@ -281,18 +288,42 @@ export class TaskRunner< const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; - if (this.context.alertsService && ruleType.alerts) { - // Wait for alerts-as-data resources to be installed - // Since this occurs at the beginning of rule execution, we can be - // assured that all resources will be ready for reading/writing when - // the rule type executors are called + const rulesSettingsClient = this.context.getRulesSettingsClientWithRequest(fakeRequest); + const flappingSettings = await rulesSettingsClient.flapping().get(); - // TODO - add retry if any initialization steps have failed - await this.context.alertsService.getContextInitializationPromise( - ruleType.alerts.context, - namespace ?? DEFAULT_NAMESPACE_STRING + const alertsClientParams = { + logger: this.logger, + ruleType: this.ruleType as UntypedNormalizedRuleType, + }; + + // Create AlertsClient if rule type has registered an alerts context + // with the framework. The AlertsClient will handle reading and + // writing from alerts-as-data indices and eventually + // we will want to migrate all the processing of alerts out + // of the LegacyAlertsClient and into the AlertsClient. + const alertsClient = + (await this.context.alertsService?.createAlertsClient< + AlertData, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId + >({ + ...alertsClientParams, + namespace: namespace ?? DEFAULT_NAMESPACE_STRING, + rule: this.getAADRuleData(rule, spaceId), + })) ?? + new LegacyAlertsClient( + alertsClientParams ); - } + + await alertsClient.initializeExecution({ + maxAlerts: this.maxAlerts, + ruleLabel, + flappingSettings, + activeAlertsFromState: alertRawInstances, + recoveredAlertsFromState: alertRecoveredRawInstances, + }); const wrappedClientOptions = { rule: { @@ -314,8 +345,6 @@ export class TaskRunner< ...wrappedClientOptions, searchSourceClient, }); - const rulesSettingsClient = this.context.getRulesSettingsClientWithRequest(fakeRequest); - const flappingSettings = await rulesSettingsClient.flapping().get(); const maintenanceWindowClient = this.context.getMaintenanceWindowClientWithRequest(fakeRequest); let activeMaintenanceWindows: MaintenanceWindow[] = []; @@ -337,14 +366,8 @@ export class TaskRunner< const { updatedRuleTypeState } = await this.timer.runWithTimer( TaskRunnerTimerSpan.RuleTypeRun, async () => { - this.legacyAlertsClient.initialize( - alertRawInstances, - alertRecoveredRawInstances, - maintenanceWindowIds - ); - const checkHasReachedAlertLimit = () => { - const reachedLimit = this.legacyAlertsClient.hasReachedAlertLimit(); + const reachedLimit = alertsClient.hasReachedAlertLimit() || false; if (reachedLimit) { this.logger.warn( `rule execution generated greater than ${this.maxAlerts} alerts: ${ruleLabel}` @@ -382,7 +405,7 @@ export class TaskRunner< searchSourceClient: wrappedSearchSourceClient.searchSourceClient, uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), - alertFactory: this.legacyAlertsClient.getExecutorServices(), + alertFactory: alertsClient.getExecutorServices(), shouldWriteAlerts: () => this.shouldLogAndScheduleActionsForAlerts(), shouldStopExecution: () => this.cancelled, ruleMonitoringService: this.ruleMonitoring.getLastRunMetricsSetters(), @@ -428,7 +451,7 @@ export class TaskRunner< // or requested it and then reported back whether it exceeded the limit // If neither of these apply, this check will throw an error // These errors should show up during rule type development - this.legacyAlertsClient.checkLimitUsage(); + alertsClient.checkLimitUsage(); } catch (err) { // Check if this error is due to reaching the alert limit if (!checkHasReachedAlertLimit()) { @@ -460,11 +483,10 @@ export class TaskRunner< ); await this.timer.runWithTimer(TaskRunnerTimerSpan.ProcessAlerts, async () => { - this.legacyAlertsClient.processAndLogAlerts({ + alertsClient.processAndLogAlerts({ eventLogger: this.alertingEventLogger, - ruleLabel, ruleRunMetricsStore, - shouldLogAndScheduleActionsForAlerts: this.shouldLogAndScheduleActionsForAlerts(), + shouldLogAlerts: this.shouldLogAndScheduleActionsForAlerts(), flappingSettings, notifyWhen, maintenanceWindowIds, @@ -502,21 +524,20 @@ export class TaskRunner< this.countUsageOfActionExecutionAfterRuleCancellation(); } else { executionHandlerRunResult = await executionHandler.run({ - ...this.legacyAlertsClient.getProcessedAlerts('activeCurrent'), - ...this.legacyAlertsClient.getProcessedAlerts('recoveredCurrent'), + ...alertsClient.getProcessedAlerts('activeCurrent'), + ...alertsClient.getProcessedAlerts('recoveredCurrent'), }); } }); - this.legacyAlertsClient.setFlapping(flappingSettings); - let alertsToReturn: Record = {}; let recoveredAlertsToReturn: Record = {}; + const { alertsToReturn: alerts, recoveredAlertsToReturn: recovered } = + await alertsClient.getAlertsToSerialize(); + // Only serialize alerts into task state if we're auto-recovering, otherwise // we don't need to keep this information around. if (this.ruleType.autoRecoverAlerts) { - const { alertsToReturn: alerts, recoveredAlertsToReturn: recovered } = - this.legacyAlertsClient.getAlertsToSerialize(); alertsToReturn = alerts; recoveredAlertsToReturn = recovered; } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index 0299f07ab8de4..dc305322d1c9a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -24,6 +24,7 @@ import { IEventLogger } from '@kbn/event-log-plugin/server'; import { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import { SharePluginStart } from '@kbn/share-plugin/server'; import { + RuleAlertData, RuleTypeParams, RuleTypeRegistry, SpaceIdToNamespaceFunction, @@ -88,7 +89,8 @@ export class TaskRunnerFactory { InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: NormalizedRuleType< Params, @@ -97,7 +99,8 @@ export class TaskRunnerFactory { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >, { taskInstance }: RunContext, inMemoryMetrics: InMemoryMetrics @@ -113,7 +116,8 @@ export class TaskRunnerFactory { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >(ruleType, taskInstance, this.taskRunnerContext!, inMemoryMetrics); } } diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index fec0e53330f7f..c218104a4b4aa 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -21,6 +21,7 @@ import { SanitizedRule, RuleTypeState, RuleAction, + RuleAlertData, } from '../../common'; import { NormalizedRuleType } from '../rule_type_registry'; import { RawRule, RulesClientApi, CombinedSummarizedAlerts } from '../types'; @@ -64,7 +65,8 @@ export interface ExecutionHandlerOptions< State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > { ruleType: NormalizedRuleType< Params, @@ -73,7 +75,8 @@ export interface ExecutionHandlerOptions< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; logger: Logger; alertingEventLogger: PublicMethodsOf; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index f769fdf7a1100..ff11363b29796 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -54,6 +54,7 @@ import { SanitizedRule, AlertsFilter, AlertsFilterTimeframe, + RuleAlertData, } from '../common'; import { PublicAlertFactory } from './alert/create_alert_factory'; import { RulesSettingsFlappingProperties } from '../common/rules_settings'; @@ -86,7 +87,8 @@ export type AlertingRouter = IRouter; export interface RuleExecutorServices< State extends AlertInstanceState = AlertInstanceState, Context extends AlertInstanceContext = AlertInstanceContext, - ActionGroupIds extends string = never + ActionGroupIds extends string = never, + AlertData extends RuleAlertData = RuleAlertData > { searchSourceClient: ISearchStartSearchSource; savedObjectsClient: SavedObjectsClientContract; @@ -106,14 +108,15 @@ export interface RuleExecutorOptions< State extends RuleTypeState = never, InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never + ActionGroupIds extends string = never, + AlertData extends RuleAlertData = never > { executionId: string; logger: Logger; params: Params; previousStartedAt: Date | null; rule: SanitizedRuleConfig; - services: RuleExecutorServices; + services: RuleExecutorServices; spaceId: string; startedAt: Date; state: State; @@ -132,9 +135,17 @@ export type ExecutorType< State extends RuleTypeState = never, InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never + ActionGroupIds extends string = never, + AlertData extends RuleAlertData = never > = ( - options: RuleExecutorOptions + options: RuleExecutorOptions< + Params, + State, + InstanceState, + InstanceContext, + ActionGroupIds, + AlertData + > ) => Promise<{ state: State }>; export interface RuleTypeParamsValidator { @@ -202,6 +213,15 @@ export interface IRuleTypeAlerts { */ mappings: ComponentTemplateSpec; + /** + * Optional flag to opt into writing alerts as data. When not specified + * defaults to false. We need this because we needed all previous rule + * registry rules to register with the framework in order to install + * Elasticsearch assets but we don't want to migrate them to using + * the framework for writing alerts as data until all the pieces are ready + */ + shouldWrite?: boolean; + /** * Optional flag to include a reference to the ECS component template. */ @@ -234,7 +254,8 @@ export interface RuleType< InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, ActionGroupIds extends string = never, - RecoveryActionGroupId extends string = never + RecoveryActionGroupId extends string = never, + AlertData extends RuleAlertData = never > { id: string; name: string; @@ -253,7 +274,8 @@ export interface RuleType< * Ensure that the reserved ActionGroups (such as `Recovered`) are not * available for scheduling in the Executor */ - WithoutReservedActionGroups + WithoutReservedActionGroups, + AlertData >; producer: string; actionVariables?: { diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts index 6d716b5d3c235..ab0346aa8966e 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts @@ -93,29 +93,6 @@ function getAlwaysFiringAlertType() { context: [{ name: 'instanceContextValue', description: 'the instance context value' }], }, executor: curry(alwaysFiringExecutor)(), - alerts: { - context: 'test.always-firing', - mappings: { - fieldMap: { - instance_state_value: { - required: false, - type: 'boolean', - }, - instance_params_value: { - required: false, - type: 'boolean', - }, - instance_context_value: { - required: false, - type: 'boolean', - }, - group_in_series_index: { - required: false, - type: 'long', - }, - }, - }, - }, }; return result; } @@ -542,6 +519,83 @@ function getPatternFiringAlertType() { return result; } +function getPatternFiringAlertsAsDataRuleType() { + const paramsSchema = schema.object({ + pattern: schema.recordOf( + schema.string(), + schema.arrayOf(schema.oneOf([schema.boolean(), schema.string()])) + ), + }); + type ParamsType = TypeOf; + interface State extends RuleTypeState { + patternIndex?: number; + } + const result: RuleType = { + id: 'test.patternFiringAad', + name: 'Test: Firing on a Pattern and writing Alerts as Data', + actionGroups: [{ id: 'default', name: 'Default' }], + producer: 'alertsFixture', + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + validate: { + params: paramsSchema, + }, + async executor(alertExecutorOptions) { + const { services, state, params } = alertExecutorOptions; + const pattern = params.pattern; + if (typeof pattern !== 'object') throw new Error('pattern is not an object'); + let maxPatternLength = 0; + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + if (!Array.isArray(instancePattern)) { + throw new Error(`pattern for instance ${instanceId} is not an array`); + } + maxPatternLength = Math.max(maxPatternLength, instancePattern.length); + } + + // get the pattern index, return if past it + const patternIndex = state.patternIndex ?? 0; + if (patternIndex >= maxPatternLength) { + return { state: { patternIndex } }; + } + + // fire if pattern says to + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + const scheduleByPattern = instancePattern[patternIndex]; + if (scheduleByPattern === true) { + services.alertFactory.create(instanceId).scheduleActions('default'); + } else if (typeof scheduleByPattern === 'string') { + services.alertFactory.create(instanceId).scheduleActions('default', scheduleByPattern); + } + } + + return { + state: { + patternIndex: patternIndex + 1, + }, + }; + }, + alerts: { + context: 'test.patternfiring', + shouldWrite: true, + mappings: { + fieldMap: { + patternIndex: { + required: false, + type: 'long', + }, + instancePattern: { + required: false, + type: 'boolean', + array: true, + }, + }, + }, + }, + }; + return result; +} + function getPatternSuccessOrFailureAlertType() { const paramsSchema = schema.object({ pattern: schema.arrayOf(schema.oneOf([schema.boolean(), schema.string()])), @@ -1074,4 +1128,5 @@ export function defineAlertTypes( alerting.registerType(getExceedsAlertLimitRuleType()); alerting.registerType(getAlwaysFiringAlertAsDataRuleType(logger, { ruleRegistry })); alerting.registerType(getPatternFiringAutoRecoverFalseAlertType()); + alerting.registerType(getPatternFiringAlertsAsDataRuleType()); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts new file mode 100644 index 0000000000000..b53a710d2c316 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts @@ -0,0 +1,378 @@ +/* + * 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 expect from '@kbn/expect'; +import { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { omit } from 'lodash'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { Spaces } from '../../../../scenarios'; +import { + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createAlertsAsDataInstallResourcesTest({ getService }: FtrProviderContext) { + const es = getService('es'); + const retry = getService('retry'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const objectRemover = new ObjectRemover(supertestWithoutAuth); + + type PatternFiringAlert = Alert; + + const alertsAsDataIndex = '.alerts-test.patternfiring.alerts-default'; + const timestampPattern = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const fieldsToOmitInComparison = [ + '@timestamp', + 'kibana.alert.flapping_history', + 'kibana.alert.rule.execution.uuid', + ]; + + describe('alerts as data', () => { + afterEach(() => objectRemover.removeAll()); + after(async () => { + await es.deleteByQuery({ index: alertsAsDataIndex, query: { match_all: {} } }); + }); + + it('should write alert docs during rule execution', async () => { + const pattern = { + alertA: [true, true, true], // stays active across executions + alertB: [true, false, false], // active then recovers + alertC: [true, false, true], // active twice + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // -------------------------- + // RUN 1 - 3 new alerts + // -------------------------- + // Wait for the event log execute doc so we can get the execution UUID + let events: IValidatedEvent[] = await waitForEventLogDocs( + ruleId, + new Map([['execute', { equal: 1 }]]) + ); + let executeEvent = events[0]; + let executionUuid = executeEvent?.kibana?.alert?.rule?.execution?.uuid; + expect(executionUuid).not.to.be(undefined); + + // Query for alerts + const alertDocsRun1 = await queryForAlertDocs(); + + // After the first run, we should have 3 alert docs for the 3 active alerts + expect(alertDocsRun1.length).to.equal(3); + + testExpectRuleData(alertDocsRun1, ruleId, ruleParameters, executionUuid!); + for (let i = 0; i < alertDocsRun1.length; ++i) { + const source: PatternFiringAlert = alertDocsRun1[i]._source!; + + // Each doc should have active status and default action group id + expect(source.kibana.alert.action_group).to.equal('default'); + + // alert UUID should equal doc id + expect(source.kibana.alert.uuid).to.equal(alertDocsRun1[i]._id); + + // duration should be '0' since this is a new alert + expect(source.kibana.alert.duration?.us).to.equal('0'); + + // start should be defined + expect(source.kibana.alert.start).to.match(timestampPattern); + + // timestamp should be defined + expect(source['@timestamp']).to.match(timestampPattern); + + // status should be active + expect(source.kibana.alert.status).to.equal('active'); + + // flapping information for new alert + expect(source.kibana.alert.flapping).to.equal(false); + expect(source.kibana.alert.flapping_history).to.eql([true]); + } + + let alertDoc: SearchHit | undefined = alertDocsRun1.find( + (doc) => doc._source!.kibana.alert.instance.id === 'alertA' + ); + const alertADocRun1 = alertDoc!._source!; + + alertDoc = alertDocsRun1.find((doc) => doc._source!.kibana.alert.instance.id === 'alertB'); + const alertBDocRun1 = alertDoc!._source!; + + alertDoc = alertDocsRun1.find((doc) => doc._source!.kibana.alert.instance.id === 'alertC'); + const alertCDocRun1 = alertDoc!._source!; + + // -------------------------- + // RUN 2 - 2 recovered (alertB, alertC), 1 ongoing (alertA) + // -------------------------- + let response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Wait for the event log execute doc so we can get the execution UUID + events = await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 2 }]])); + executeEvent = events[1]; + executionUuid = executeEvent?.kibana?.alert?.rule?.execution?.uuid; + expect(executionUuid).not.to.be(undefined); + + // Query for alerts + const alertDocsRun2 = await queryForAlertDocs(); + + // After the second run, we should have 3 alert docs + expect(alertDocsRun2.length).to.equal(3); + + testExpectRuleData(alertDocsRun2, ruleId, ruleParameters, executionUuid!); + for (let i = 0; i < alertDocsRun2.length; ++i) { + const source: PatternFiringAlert = alertDocsRun2[i]._source!; + + // alert UUID should equal doc id + expect(source.kibana.alert.uuid).to.equal(alertDocsRun2[i]._id); + + // duration should be greater than 0 since these are not new alerts + const durationAsNumber = Number(source.kibana.alert.duration?.us); + expect(durationAsNumber).to.be.greaterThan(0); + } + + // alertA, run2 + // status is still active; duration is updated; no end time + alertDoc = alertDocsRun2.find((doc) => doc._source!.kibana.alert.instance.id === 'alertA'); + const alertADocRun2 = alertDoc!._source!; + // uuid is the same + expect(alertADocRun2.kibana.alert.uuid).to.equal(alertADocRun1.kibana.alert.uuid); + expect(alertADocRun2.kibana.alert.action_group).to.equal('default'); + // start time should be defined and the same as prior run + expect(alertADocRun2.kibana.alert.start).to.match(timestampPattern); + expect(alertADocRun2.kibana.alert.start).to.equal(alertADocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertADocRun2['@timestamp']).to.match(timestampPattern); + expect(alertADocRun2['@timestamp']).not.to.equal(alertADocRun1['@timestamp']); + // status should still be active + expect(alertADocRun2.kibana.alert.status).to.equal('active'); + // flapping false, flapping history updated with additional entry + expect(alertADocRun2.kibana.alert.flapping).to.equal(false); + expect(alertADocRun2.kibana.alert.flapping_history).to.eql([ + ...alertADocRun1.kibana.alert.flapping_history!, + false, + ]); + + // alertB, run 2 + // status is updated to recovered, duration is updated, end time is set + alertDoc = alertDocsRun2.find((doc) => doc._source!.kibana.alert.instance.id === 'alertB'); + const alertBDocRun2 = alertDoc!._source!; + // action group should be set to recovered + expect(alertBDocRun2.kibana.alert.action_group).to.be('recovered'); + // uuid is the same + expect(alertBDocRun2.kibana.alert.uuid).to.equal(alertBDocRun1.kibana.alert.uuid); + // start time should be defined and the same as before + expect(alertBDocRun2.kibana.alert.start).to.match(timestampPattern); + expect(alertBDocRun2.kibana.alert.start).to.equal(alertBDocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertBDocRun2['@timestamp']).to.match(timestampPattern); + expect(alertBDocRun2['@timestamp']).not.to.equal(alertBDocRun1['@timestamp']); + // end time should be defined + expect(alertBDocRun2.kibana.alert.end).to.match(timestampPattern); + // status should be set to recovered + expect(alertBDocRun2.kibana.alert.status).to.equal('recovered'); + // flapping false, flapping history updated with additional entry + expect(alertBDocRun2.kibana.alert.flapping).to.equal(false); + expect(alertBDocRun2.kibana.alert.flapping_history).to.eql([ + ...alertBDocRun1.kibana.alert.flapping_history!, + true, + ]); + + // alertB, run 2 + // status is updated to recovered, duration is updated, end time is set + alertDoc = alertDocsRun2.find((doc) => doc._source!.kibana.alert.instance.id === 'alertC'); + const alertCDocRun2 = alertDoc!._source!; + // action group should be set to recovered + expect(alertCDocRun2.kibana.alert.action_group).to.be('recovered'); + // uuid is the same + expect(alertCDocRun2.kibana.alert.uuid).to.equal(alertCDocRun1.kibana.alert.uuid); + // start time should be defined and the same as before + expect(alertCDocRun2.kibana.alert.start).to.match(timestampPattern); + expect(alertCDocRun2.kibana.alert.start).to.equal(alertCDocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertCDocRun2['@timestamp']).to.match(timestampPattern); + expect(alertCDocRun2['@timestamp']).not.to.equal(alertCDocRun1['@timestamp']); + // end time should be defined + expect(alertCDocRun2.kibana.alert.end).to.match(timestampPattern); + // status should be set to recovered + expect(alertCDocRun2.kibana.alert.status).to.equal('recovered'); + // flapping false, flapping history updated with additional entry + expect(alertCDocRun2.kibana.alert.flapping).to.equal(false); + expect(alertCDocRun2.kibana.alert.flapping_history).to.eql([ + ...alertCDocRun1.kibana.alert.flapping_history!, + true, + ]); + + // -------------------------- + // RUN 3 - 1 re-active (alertC), 1 still recovered (alertB), 1 ongoing (alertA) + // -------------------------- + response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Wait for the event log execute doc so we can get the execution UUID + events = await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 3 }]])); + executeEvent = events[2]; + executionUuid = executeEvent?.kibana?.alert?.rule?.execution?.uuid; + expect(executionUuid).not.to.be(undefined); + + // Query for alerts + const alertDocsRun3 = await queryForAlertDocs(); + + // After the third run, we should have 4 alert docs + // The docs for "alertA" and "alertB" should not have been updated + // There should be two docs for "alertC", one for the first active -> recovered span + // the second for the new active span + expect(alertDocsRun3.length).to.equal(4); + + testExpectRuleData(alertDocsRun3, ruleId, ruleParameters); + + // alertA, run3 + // status is still active; duration is updated; no end time + alertDoc = alertDocsRun3.find((doc) => doc._source!.kibana.alert.instance.id === 'alertA'); + const alertADocRun3 = alertDoc!._source!; + // uuid is the same as previous runs + expect(alertADocRun3.kibana.alert.uuid).to.equal(alertADocRun2.kibana.alert.uuid); + expect(alertADocRun3.kibana.alert.uuid).to.equal(alertADocRun1.kibana.alert.uuid); + expect(alertADocRun3.kibana.alert.action_group).to.equal('default'); + // start time should be defined and the same as prior runs + expect(alertADocRun3.kibana.alert.start).to.match(timestampPattern); + expect(alertADocRun3.kibana.alert.start).to.equal(alertADocRun2.kibana.alert.start); + expect(alertADocRun3.kibana.alert.start).to.equal(alertADocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertADocRun3['@timestamp']).to.match(timestampPattern); + expect(alertADocRun3['@timestamp']).not.to.equal(alertADocRun2['@timestamp']); + // status should still be active + expect(alertADocRun3.kibana.alert.status).to.equal('active'); + // flapping false, flapping history updated with additional entry + expect(alertADocRun3.kibana.alert.flapping).to.equal(false); + expect(alertADocRun3.kibana.alert.flapping_history).to.eql([ + ...alertADocRun2.kibana.alert.flapping_history!, + false, + ]); + + // alertB doc should be unchanged from prior run because it is still recovered + // but its flapping history should be updated + alertDoc = alertDocsRun3.find((doc) => doc._source!.kibana.alert.instance.id === 'alertB'); + const alertBDocRun3 = alertDoc!._source!; + expect(omit(alertBDocRun3, fieldsToOmitInComparison)).to.eql( + omit(alertBDocRun2, fieldsToOmitInComparison) + ); + // execution uuid should be current one + expect(alertBDocRun3.kibana.alert.rule.execution?.uuid).to.equal(executionUuid); + // flapping history should be history from prior run with additional entry + expect(alertBDocRun3.kibana.alert.flapping_history).to.eql([ + ...alertBDocRun2.kibana.alert.flapping_history!, + false, + ]); + + // alertC should have 2 docs + const alertCDocs = alertDocsRun3.filter( + (doc) => doc._source!.kibana.alert.instance.id === 'alertC' + ); + // alertC recovered doc should be exactly the same as the alertC doc from prior run + const recoveredAlertCDoc = alertCDocs.find( + (doc) => doc._source!.kibana.alert.rule.execution?.uuid !== executionUuid + )!._source!; + expect(recoveredAlertCDoc).to.eql(alertCDocRun2); + + // alertC doc from current execution + const alertCDocRun3 = alertCDocs.find( + (doc) => doc._source!.kibana.alert.rule.execution?.uuid === executionUuid + )!._source!; + // uuid is the different from prior run] + expect(alertCDocRun3.kibana.alert.uuid).not.to.equal(alertCDocRun2.kibana.alert.uuid); + expect(alertCDocRun3.kibana.alert.action_group).to.equal('default'); + // start time should be defined and different from the prior run + expect(alertCDocRun3.kibana.alert.start).to.match(timestampPattern); + expect(alertCDocRun3.kibana.alert.start).not.to.equal(alertCDocRun2.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertCDocRun3['@timestamp']).to.match(timestampPattern); + // duration should be '0' since this is a new alert + expect(alertCDocRun3.kibana.alert.duration?.us).to.equal('0'); + // flapping false, flapping history should be history from prior run with additional entry + expect(alertCDocRun3.kibana.alert.flapping).to.equal(false); + expect(alertCDocRun3.kibana.alert.flapping_history).to.eql([ + ...alertCDocRun2.kibana.alert.flapping_history!, + true, + ]); + }); + }); + + function testExpectRuleData( + alertDocs: Array>, + ruleId: string, + ruleParameters: unknown, + executionUuid?: string + ) { + for (let i = 0; i < alertDocs.length; ++i) { + const source: PatternFiringAlert = alertDocs[i]._source!; + + // Each doc should have a copy of the rule data + expect(source.kibana.alert.rule.category).to.equal( + 'Test: Firing on a Pattern and writing Alerts as Data' + ); + expect(source.kibana.alert.rule.consumer).to.equal('alertsFixture'); + expect(source.kibana.alert.rule.name).to.equal('abc'); + expect(source.kibana.alert.rule.producer).to.equal('alertsFixture'); + expect(source.kibana.alert.rule.tags).to.eql(['foo']); + expect(source.kibana.alert.rule.rule_type_id).to.equal('test.patternFiringAad'); + expect(source.kibana.alert.rule.uuid).to.equal(ruleId); + expect(source.kibana.alert.rule.parameters).to.eql(ruleParameters); + expect(source.kibana.space_ids).to.eql(['space1']); + + if (executionUuid) { + expect(source.kibana.alert.rule.execution?.uuid).to.equal(executionUuid); + } + } + } + + async function queryForAlertDocs(): Promise>> { + const searchResult = await es.search({ + index: alertsAsDataIndex, + body: { query: { match_all: {} } }, + }); + return searchResult.hits.hits as Array>; + } + + async function waitForEventLogDocs( + id: string, + actions: Map + ) { + return await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions, + }); + }); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts new file mode 100644 index 0000000000000..62d45f50a07c9 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts @@ -0,0 +1,493 @@ +/* + * 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 expect from '@kbn/expect'; +import { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { RuleNotifyWhen } from '@kbn/alerting-plugin/common'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { Spaces } from '../../../../scenarios'; +import { + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, + TaskManagerDoc, +} from '../../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createAlertsAsDataInstallResourcesTest({ getService }: FtrProviderContext) { + const es = getService('es'); + const retry = getService('retry'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const objectRemover = new ObjectRemover(supertestWithoutAuth); + + type PatternFiringAlert = Alert; + + const alertsAsDataIndex = '.alerts-test.patternfiring.alerts-default'; + + describe('alerts as data flapping', () => { + afterEach(async () => { + await es.deleteByQuery({ index: alertsAsDataIndex, query: { match_all: {} } }); + objectRemover.removeAll(); + }); + + // These are the same tests from x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts + // but testing that flapping status & flapping history is updated as expected for AAD docs + + it('should set flapping and flapping_history for flapping alerts that settle on active', async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth('superuser', 'superuser') + .send({ + enabled: true, + look_back_window: 6, + status_change_threshold: 4, + }) + .expect(200); + + const pattern = { + alertA: [true, false, false, true, false, true, false, true, false].concat( + ...new Array(8).fill(true), + false + ), + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + notify_when: RuleNotifyWhen.CHANGE, + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // Wait for the rule to run once + let run = 1; + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]])); + // Run the rule 4 more times + for (let i = 0; i < 4; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + let alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + let state: any = await getRuleState(ruleId); + + // Should be 2 alert docs because alert pattern was: + // active, recovered, recovered, active, recovered + expect(alertDocs.length).to.equal(2); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be false while flapping value for state should be true + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should be 3 alert docs now because alert became active again + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because alert is flapping + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 7 more times + for (let i = 0; i < 7; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 3 alert docs + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be false because alert was active for long + // enough to reset the flapping state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(false); + }); + + it('should set flapping and flapping_history for flapping alerts that settle on recovered', async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth('superuser', 'superuser') + .send({ + enabled: true, + look_back_window: 6, + status_change_threshold: 4, + }) + .expect(200); + + const pattern = { + alertA: [true, false, false, true, false, true, false, true, false, true].concat( + new Array(11).fill(false) + ), + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + notify_when: RuleNotifyWhen.CHANGE, + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // Wait for the rule to run once + let run = 1; + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]])); + // Run the rule 4 more times + for (let i = 0; i < 4; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + let alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + let state: any = await getRuleState(ruleId); + + // Should be 2 alert docs because alert pattern was: + // active, recovered, recovered, active, recovered + expect(alertDocs.length).to.equal(2); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be false while flapping value for state should be true + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should be 3 alert docs now because alert became active again + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because alert is flapping + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 3 more times + for (let i = 0; i < 3; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 3 alert docs + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because alert recovered while flapping + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(true); + }); + + it('should set flapping and flapping_history for flapping alerts over a period of time longer than the lookback', async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth('superuser', 'superuser') + .send({ + enabled: true, + look_back_window: 5, + status_change_threshold: 5, + }) + .expect(200); + + const pattern = { + alertA: [true, false, false, true, false, true, false, true, false].concat( + ...new Array(8).fill(true), + false + ), + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + notify_when: RuleNotifyWhen.CHANGE, + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // Wait for the rule to run once + let run = 1; + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]])); + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + let alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + let state: any = await getRuleState(ruleId); + + // Should be 3 alert docs because alert pattern was: + // active, recovered, recovered, active, recovered, active, recovered + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Alert shouldn't be flapping because the status change threshold hasn't been exceeded + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(false); + + // Run the rule 1 more time + for (let i = 0; i < 1; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should be 4 alert docs now because alert became active again + expect(alertDocs.length).to.equal(4); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be false while flapping value for state should be true + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 4 alert docs + expect(alertDocs.length).to.equal(4); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be true while flapping value for state should be false + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertInstances.alertA.meta.flapping).to.equal(false); + + // Run the rule 3 more times + for (let i = 0; i < 3; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 4 alert docs + expect(alertDocs.length).to.equal(4); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because lookback threshold exceeded + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertInstances.alertA.meta.flapping).to.equal(false); + }); + }); + + async function getRuleState(ruleId: string) { + const task = await es.get({ + id: `task:${ruleId}`, + index: '.kibana_task_manager', + }); + + return JSON.parse(task._source!.task.state); + } + + async function queryForAlertDocs(): Promise>> { + const searchResult = await es.search({ + index: alertsAsDataIndex, + body: { query: { match_all: {} } }, + }); + return searchResult.hits.hits as Array>; + } + + async function waitForEventLogDocs( + id: string, + actions: Map + ) { + return await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions, + }); + }); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts new file mode 100644 index 0000000000000..9156fb9e8ec37 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertsAsDataTests({ loadTestFile }: FtrProviderContext) { + describe('alerts_as_data', () => { + loadTestFile(require.resolve('./install_resources')); + loadTestFile(require.resolve('./alerts_as_data')); + loadTestFile(require.resolve('./alerts_as_data_flapping')); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/install_resources.ts similarity index 88% rename from x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data.ts rename to x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/install_resources.ts index c65af87d39aa7..b6c86b49c7fba 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/install_resources.ts @@ -8,16 +8,16 @@ import { alertFieldMap, ecsFieldMap, legacyAlertFieldMap } from '@kbn/alerts-as-data-utils'; import { mappingFromFieldMap } from '@kbn/alerting-plugin/common'; import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export -export default function createAlertsAsDataTest({ getService }: FtrProviderContext) { +export default function createAlertsAsDataInstallResourcesTest({ getService }: FtrProviderContext) { const es = getService('es'); const frameworkMappings = mappingFromFieldMap(alertFieldMap, 'strict'); const legacyAlertMappings = mappingFromFieldMap(legacyAlertFieldMap, 'strict'); const ecsMappings = mappingFromFieldMap(ecsFieldMap, 'strict'); - describe('alerts as data', () => { + describe('install alerts as data resources', () => { it('should install common alerts as data resources on startup', async () => { const ilmPolicyName = '.alerts-ilm-policy'; const frameworkComponentTemplateName = '.alerts-framework-mappings'; @@ -111,22 +111,16 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex }); it('should install context specific alerts as data resources on startup', async () => { - const componentTemplateName = '.alerts-test.always-firing.alerts-mappings'; - const indexTemplateName = '.alerts-test.always-firing.alerts-default-index-template'; - const indexName = '.internal.alerts-test.always-firing.alerts-default-000001'; + const componentTemplateName = '.alerts-test.patternfiring.alerts-mappings'; + const indexTemplateName = '.alerts-test.patternfiring.alerts-default-index-template'; + const indexName = '.internal.alerts-test.patternfiring.alerts-default-000001'; const contextSpecificMappings = { - instance_params_value: { - type: 'boolean', - }, - instance_state_value: { - type: 'boolean', + patternIndex: { + type: 'long', }, - instance_context_value: { + instancePattern: { type: 'boolean', }, - group_in_series_index: { - type: 'long', - }, }; const { component_templates: componentTemplates } = await es.cluster.getComponentTemplate({ @@ -148,10 +142,10 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex const contextIndexTemplate = indexTemplates[0]; expect(contextIndexTemplate.name).to.eql(indexTemplateName); expect(contextIndexTemplate.index_template.index_patterns).to.eql([ - '.internal.alerts-test.always-firing.alerts-default-*', + '.internal.alerts-test.patternfiring.alerts-default-*', ]); expect(contextIndexTemplate.index_template.composed_of).to.eql([ - '.alerts-test.always-firing.alerts-mappings', + '.alerts-test.patternfiring.alerts-mappings', '.alerts-framework-mappings', ]); expect(contextIndexTemplate.index_template.template!.mappings?.dynamic).to.eql(false); @@ -166,7 +160,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex index: { lifecycle: { name: '.alerts-ilm-policy', - rollover_alias: '.alerts-test.always-firing.alerts-default', + rollover_alias: '.alerts-test.patternfiring.alerts-default', }, mapping: { total_fields: { @@ -183,7 +177,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex }); expect(contextIndex[indexName].aliases).to.eql({ - '.alerts-test.always-firing.alerts-default': { + '.alerts-test.patternfiring.alerts-default': { is_write_index: true, }, }); @@ -198,7 +192,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex expect(contextIndex[indexName].settings?.index?.lifecycle).to.eql({ name: '.alerts-ilm-policy', - rollover_alias: '.alerts-test.always-firing.alerts-default', + rollover_alias: '.alerts-test.patternfiring.alerts-default', }); expect(contextIndex[indexName].settings?.index?.mapping).to.eql({ @@ -211,7 +205,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex expect(contextIndex[indexName].settings?.index?.number_of_shards).to.eql(1); expect(contextIndex[indexName].settings?.index?.auto_expand_replicas).to.eql('0-1'); expect(contextIndex[indexName].settings?.index?.provided_name).to.eql( - '.internal.alerts-test.always-firing.alerts-default-000001' + '.internal.alerts-test.patternfiring.alerts-default-000001' ); }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts index ec02b00593fe8..0adcfe10009ca 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts @@ -14,6 +14,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC before(async () => await buildUp(getService)); after(async () => await tearDown(getService)); + loadTestFile(require.resolve('./alerts_as_data')); loadTestFile(require.resolve('./builtin_alert_types')); loadTestFile(require.resolve('./mustache_templates.ts')); loadTestFile(require.resolve('./notify_when')); @@ -26,7 +27,6 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./run_soon')); loadTestFile(require.resolve('./flapping_history')); loadTestFile(require.resolve('./check_registered_rule_types')); - loadTestFile(require.resolve('./alerts_as_data')); loadTestFile(require.resolve('./generate_alert_schemas')); // Do not place test files here, due to https://github.com/elastic/kibana/issues/123059 From 5fa2226b06da6ff08834fa4c02882cf3c23ee7ee Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Wed, 24 May 2023 13:29:59 -0500 Subject: [PATCH 08/20] [data views] Content management api implementation (#155803) ## Summary Data views implements the content management api and associated minor changes. The bulk of the changes are in `(common|public|server)/content_management)` Closes https://github.com/elastic/kibana/issues/157069 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Lukas Olson --- .../src/saved_object_content_storage.ts | 42 ++++-- .../kbn-content-management-utils/src/types.ts | 2 +- .../search/expressions/kibana_context.test.ts | 3 +- .../search/expressions/kibana_context.ts | 2 +- .../indexed_fields_table.test.tsx.snap | 13 ++ .../indexed_fields_table.tsx | 2 + .../common/content_management/cm_services.ts | 21 +++ .../common/content_management/index.ts | 9 ++ .../content_management/v1/cm_services.ts | 119 +++++++++++++++++ .../common/content_management/v1/constants.ts | 17 +++ .../common/content_management/v1/index.ts | 15 +++ .../common/content_management/v1/types.ts | 40 ++++++ .../data_views/common/data_view.stub.ts | 6 +- .../common/data_views/data_views.test.ts | 44 +++---- .../common/data_views/data_views.ts | 37 ++---- src/plugins/data_views/common/index.ts | 2 + .../util => common}/schemas.ts | 8 +- src/plugins/data_views/common/types.ts | 36 +++--- src/plugins/data_views/common/utils.ts | 6 +- src/plugins/data_views/kibana.jsonc | 3 +- src/plugins/data_views/public/plugin.ts | 25 +++- .../saved_objects_client_wrapper.test.ts | 29 ++--- .../public/saved_objects_client_wrapper.ts | 120 +++++++++++++----- src/plugins/data_views/public/types.ts | 12 ++ .../content_management/data_views_storage.ts | 36 ++++++ .../server/content_management/index.ts | 9 ++ src/plugins/data_views/server/plugin.ts | 12 +- .../rest_api_routes/create_data_view.ts | 6 +- .../rest_api_routes/fields/update_fields.ts | 2 +- .../runtime_fields/create_runtime_field.ts | 2 +- .../runtime_fields/put_runtime_field.ts | 2 +- .../runtime_fields/update_runtime_field.ts | 2 +- .../scripted_fields/create_scripted_field.ts | 2 +- .../scripted_fields/put_scripted_field.ts | 2 +- .../scripted_fields/update_scripted_field.ts | 2 +- .../rest_api_routes/update_data_view.ts | 6 +- .../rest_api_routes/util/handle_errors.ts | 4 +- .../saved_objects_client_wrapper.test.ts | 8 +- .../server/saved_objects_client_wrapper.ts | 38 ++++-- src/plugins/data_views/server/types.ts | 5 + src/plugins/data_views/server/utils.ts | 2 +- src/plugins/data_views/tsconfig.json | 3 + .../server/content_management/maps_storage.ts | 7 + 43 files changed, 592 insertions(+), 171 deletions(-) create mode 100644 src/plugins/data_views/common/content_management/cm_services.ts create mode 100644 src/plugins/data_views/common/content_management/index.ts create mode 100644 src/plugins/data_views/common/content_management/v1/cm_services.ts create mode 100644 src/plugins/data_views/common/content_management/v1/constants.ts create mode 100644 src/plugins/data_views/common/content_management/v1/index.ts create mode 100644 src/plugins/data_views/common/content_management/v1/types.ts rename src/plugins/data_views/{server/rest_api_routes/util => common}/schemas.ts (95%) create mode 100644 src/plugins/data_views/server/content_management/data_views_storage.ts create mode 100644 src/plugins/data_views/server/content_management/index.ts diff --git a/packages/kbn-content-management-utils/src/saved_object_content_storage.ts b/packages/kbn-content-management-utils/src/saved_object_content_storage.ts index c916362578f16..ac8877e5a26da 100644 --- a/packages/kbn-content-management-utils/src/saved_object_content_storage.ts +++ b/packages/kbn-content-management-utils/src/saved_object_content_storage.ts @@ -21,6 +21,7 @@ import type { SavedObjectsUpdateOptions, SavedObjectsFindResult, } from '@kbn/core-saved-objects-api-server'; +import { pick } from 'lodash'; import type { CMCrudTypes, ServicesDefinitionSet, @@ -44,16 +45,19 @@ type PartialSavedObject = Omit>, 'references'> & { function savedObjectToItem( savedObject: SavedObject, + allowedSavedObjectAttributes: string[], partial: false ): Item; function savedObjectToItem( savedObject: PartialSavedObject, + allowedSavedObjectAttributes: string[], partial: true ): PartialItem; function savedObjectToItem( - savedObject: SavedObject | PartialSavedObject + savedObject: SavedObject | PartialSavedObject, + allowedSavedObjectAttributes: string[] ): SOWithMetadata | SOWithMetadataPartial { const { id, @@ -64,6 +68,7 @@ function savedObjectToItem( references, error, namespaces, + version, } = savedObject; return { @@ -71,10 +76,11 @@ function savedObjectToItem( type, updatedAt, createdAt, - attributes, + attributes: pick(attributes, allowedSavedObjectAttributes), references, error, namespaces, + version, }; } @@ -123,6 +129,8 @@ export type UpdateArgsToSoUpdateOptions = ( export interface SOContentStorageConstrutorParams { savedObjectType: string; cmServicesDefinition: ServicesDefinitionSet; + // this is necessary since unexpected saved object attributes could cause schema validation to fail + allowedSavedObjectAttributes: string[]; createArgsToSoCreateOptions?: CreateArgsToSoCreateOptions; updateArgsToSoUpdateOptions?: UpdateArgsToSoUpdateOptions; searchArgsToSOFindOptions?: SearchArgsToSOFindOptions; @@ -144,6 +152,7 @@ export abstract class SOContentStorage updateArgsToSoUpdateOptions, searchArgsToSOFindOptions, enableMSearch, + allowedSavedObjectAttributes, }: SOContentStorageConstrutorParams) { this.savedObjectType = savedObjectType; this.cmServicesDefinition = cmServicesDefinition; @@ -152,6 +161,7 @@ export abstract class SOContentStorage this.updateArgsToSoUpdateOptions = updateArgsToSoUpdateOptions || updateArgsToSoUpdateOptionsDefault; this.searchArgsToSOFindOptions = searchArgsToSOFindOptions || searchArgsToSOFindOptionsDefault; + this.allowedSavedObjectAttributes = allowedSavedObjectAttributes; if (enableMSearch) { this.mSearch = { @@ -163,7 +173,13 @@ export abstract class SOContentStorage const { value, error: resultError } = transforms.mSearch.out.result.down< Types['Item'], Types['Item'] - >(savedObjectToItem(savedObject as SavedObjectsFindResult, false)); + >( + savedObjectToItem( + savedObject as SavedObjectsFindResult, + this.allowedSavedObjectAttributes, + false + ) + ); if (resultError) { throw Boom.badRequest(`Invalid response. ${resultError.message}`); @@ -180,6 +196,7 @@ export abstract class SOContentStorage private createArgsToSoCreateOptions: CreateArgsToSoCreateOptions; private updateArgsToSoUpdateOptions: UpdateArgsToSoUpdateOptions; private searchArgsToSOFindOptions: SearchArgsToSOFindOptions; + private allowedSavedObjectAttributes: string[]; mSearch?: { savedObjectType: string; @@ -199,7 +216,7 @@ export abstract class SOContentStorage } = await soClient.resolve(this.savedObjectType, id); const response: Types['GetOut'] = { - item: savedObjectToItem(savedObject, false), + item: savedObjectToItem(savedObject, this.allowedSavedObjectAttributes, false), meta: { aliasPurpose, aliasTargetId, @@ -264,7 +281,7 @@ export abstract class SOContentStorage Types['CreateOut'], Types['CreateOut'] >({ - item: savedObjectToItem(savedObject, false), + item: savedObjectToItem(savedObject, this.allowedSavedObjectAttributes, false), }); if (resultError) { @@ -315,7 +332,7 @@ export abstract class SOContentStorage Types['UpdateOut'], Types['UpdateOut'] >({ - item: savedObjectToItem(partialSavedObject, true), + item: savedObjectToItem(partialSavedObject, this.allowedSavedObjectAttributes, true), }); if (resultError) { @@ -325,9 +342,14 @@ export abstract class SOContentStorage return value; } - async delete(ctx: StorageContext, id: string): Promise { + async delete( + ctx: StorageContext, + id: string, + // force is necessary to delete saved objects that exist in multiple namespaces + options?: { force: boolean } + ): Promise { const soClient = await savedObjectClientFromRequest(ctx); - await soClient.delete(this.savedObjectType, id); + await soClient.delete(this.savedObjectType, id, { force: options?.force ?? false }); return { success: true }; } @@ -361,7 +383,9 @@ export abstract class SOContentStorage Types['SearchOut'], Types['SearchOut'] >({ - hits: response.saved_objects.map((so) => savedObjectToItem(so, false)), + hits: response.saved_objects.map((so) => + savedObjectToItem(so, this.allowedSavedObjectAttributes, false) + ), pagination: { total: response.total, }, diff --git a/packages/kbn-content-management-utils/src/types.ts b/packages/kbn-content-management-utils/src/types.ts index 970aefd9a0058..ad9f805bc0998 100644 --- a/packages/kbn-content-management-utils/src/types.ts +++ b/packages/kbn-content-management-utils/src/types.ts @@ -176,7 +176,7 @@ export interface SavedObjectUpdateOptions { } /** Return value for Saved Object get, T is item returned */ -export type GetResultSO = GetResult< +export type GetResultSO = GetResult< T, { outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; diff --git a/src/plugins/data/common/search/expressions/kibana_context.test.ts b/src/plugins/data/common/search/expressions/kibana_context.test.ts index 4ef24bdc3fe3d..211327fa152bb 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.test.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.test.ts @@ -46,6 +46,7 @@ describe('kibanaContextFn', () => { delete: jest.fn(), find: jest.fn(), get: jest.fn(), + getSavedSearch: jest.fn(), update: jest.fn(), }, }; @@ -53,7 +54,7 @@ describe('kibanaContextFn', () => { it('merges and deduplicates queries from different sources', async () => { const { fn } = kibanaContextFn; - startServicesMock.savedObjectsClient.get.mockResolvedValue({ + startServicesMock.savedObjectsClient.getSavedSearch.mockResolvedValue({ attributes: { kibanaSavedObjectMeta: { searchSourceJSON: JSON.stringify({ diff --git a/src/plugins/data/common/search/expressions/kibana_context.ts b/src/plugins/data/common/search/expressions/kibana_context.ts index 382589b113959..4fa07676c0152 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.ts @@ -129,7 +129,7 @@ export const getKibanaContextFn = ( let filters = [...(input?.filters || [])]; if (args.savedSearchId) { - const obj = await savedObjectsClient.get('search', args.savedSearchId); + const obj = await savedObjectsClient.getSavedSearch(args.savedSearchId); const search = (obj.attributes as any).kibanaSavedObjectMeta.searchSourceJSON as string; const { query, filter } = getParsedValue(search, {}); diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap index 843c90d4f617b..cb43403d24a02 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap @@ -73,6 +73,7 @@ exports[`IndexedFieldsTable IndexedFieldsTable with rollup index pattern should "excluded": false, "format": "", "hasRuntime": false, + "id": "Elastic", "info": Array [ "Rollup aggregations:", "terms", @@ -92,6 +93,7 @@ exports[`IndexedFieldsTable IndexedFieldsTable with rollup index pattern should "excluded": false, "format": "", "hasRuntime": false, + "id": "timestamp", "info": Array [ "Rollup aggregations:", "date_histogram (interval: 30s, delay: 30s, UTC)", @@ -111,6 +113,7 @@ exports[`IndexedFieldsTable IndexedFieldsTable with rollup index pattern should "excluded": false, "format": "", "hasRuntime": false, + "id": "conflictingField", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -126,6 +129,7 @@ exports[`IndexedFieldsTable IndexedFieldsTable with rollup index pattern should "excluded": false, "format": "", "hasRuntime": false, + "id": "amount", "info": Array [ "Rollup aggregations:", "histogram (interval: 5)", @@ -149,6 +153,7 @@ exports[`IndexedFieldsTable IndexedFieldsTable with rollup index pattern should "excluded": false, "format": "", "hasRuntime": true, + "id": "runtime", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -191,6 +196,7 @@ exports[`IndexedFieldsTable should filter based on the query bar 1`] = ` "excluded": false, "format": "", "hasRuntime": false, + "id": "Elastic", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -228,6 +234,7 @@ exports[`IndexedFieldsTable should filter based on the schema filter 1`] = ` "excluded": false, "format": "", "hasRuntime": true, + "id": "runtime", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -270,6 +277,7 @@ exports[`IndexedFieldsTable should filter based on the type filter 1`] = ` "excluded": false, "format": "", "hasRuntime": false, + "id": "timestamp", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -306,6 +314,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` "excluded": false, "format": "", "hasRuntime": false, + "id": "Elastic", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -322,6 +331,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` "excluded": false, "format": "", "hasRuntime": false, + "id": "timestamp", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -338,6 +348,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` "excluded": false, "format": "", "hasRuntime": false, + "id": "conflictingField", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -353,6 +364,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` "excluded": false, "format": "", "hasRuntime": false, + "id": "amount", "info": Array [], "isMapped": false, "isUserEditable": false, @@ -368,6 +380,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` "excluded": false, "format": "", "hasRuntime": true, + "id": "runtime", "info": Array [], "isMapped": false, "isUserEditable": false, diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx index 2a06f0c88b54f..85ed5886163c2 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx @@ -76,6 +76,7 @@ export class IndexedFieldsTable extends Component< fields.map((field) => { return { ...field.spec, + id: field.name, type: field.esTypes?.join(', ') || '', kbnType: field.type, displayName: field.displayName, @@ -114,6 +115,7 @@ export class IndexedFieldsTable extends Component< }, }, name, + id: name, type: 'composite', kbnType: '', displayName: name, diff --git a/src/plugins/data_views/common/content_management/cm_services.ts b/src/plugins/data_views/common/content_management/cm_services.ts new file mode 100644 index 0000000000000..af32d57e0437f --- /dev/null +++ b/src/plugins/data_views/common/content_management/cm_services.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementServicesDefinition as ServicesDefinition, + Version, +} from '@kbn/object-versioning'; + +// We export the versionned service definition from this file and not the barrel to avoid adding +// the schemas in the "public" js bundle + +import { serviceDefinition as v1 } from './v1/cm_services'; + +export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = { + 1: v1, +}; diff --git a/src/plugins/data_views/common/content_management/index.ts b/src/plugins/data_views/common/content_management/index.ts new file mode 100644 index 0000000000000..e9c79f0f50f93 --- /dev/null +++ b/src/plugins/data_views/common/content_management/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './v1'; diff --git a/src/plugins/data_views/common/content_management/v1/cm_services.ts b/src/plugins/data_views/common/content_management/v1/cm_services.ts new file mode 100644 index 0000000000000..7ff2d135a9d90 --- /dev/null +++ b/src/plugins/data_views/common/content_management/v1/cm_services.ts @@ -0,0 +1,119 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning'; +import { + savedObjectSchema, + objectTypeToGetResultSchema, + createOptionsSchemas, + updateOptionsSchema, + createResultSchema, + searchOptionsSchemas, +} from '@kbn/content-management-utils'; +import { serializedFieldFormatSchema, fieldSpecSchema } from '../../schemas'; + +const dataViewAttributesSchema = schema.object( + { + title: schema.string(), + type: schema.maybe(schema.literal('rollup')), + timeFieldName: schema.maybe(schema.string()), + sourceFilters: schema.maybe( + schema.arrayOf( + schema.object({ + value: schema.string(), + }) + ) + ), + fields: schema.maybe(schema.arrayOf(fieldSpecSchema)), + typeMeta: schema.maybe(schema.object({}, { unknowns: 'allow' })), + fieldFormatMap: schema.maybe(schema.recordOf(schema.string(), serializedFieldFormatSchema)), + fieldAttrs: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ + customLabel: schema.maybe(schema.string()), + count: schema.maybe(schema.number()), + }) + ) + ), + allowNoIndex: schema.maybe(schema.boolean()), + runtimeFieldMap: schema.maybe(schema.any()), + name: schema.maybe(schema.string()), + }, + { unknowns: 'forbid' } +); + +const dataViewSavedObjectSchema = savedObjectSchema(dataViewAttributesSchema); + +const dataViewCreateOptionsSchema = schema.object({ + id: createOptionsSchemas.id, + initialNamespaces: createOptionsSchemas.initialNamespaces, +}); + +const dataViewSearchOptionsSchema = schema.object({ + searchFields: searchOptionsSchemas.searchFields, + fields: searchOptionsSchemas.fields, +}); + +const dataViewUpdateOptionsSchema = schema.object({ + version: updateOptionsSchema.version, + refresh: updateOptionsSchema.refresh, + retryOnConflict: updateOptionsSchema.retryOnConflict, +}); + +// Content management service definition. +// We need it for BWC support between different versions of the content +export const serviceDefinition: ServicesDefinition = { + get: { + out: { + result: { + schema: objectTypeToGetResultSchema(dataViewSavedObjectSchema), + }, + }, + }, + create: { + in: { + options: { + schema: dataViewCreateOptionsSchema, + }, + data: { + schema: dataViewAttributesSchema, + }, + }, + out: { + result: { + schema: createResultSchema(dataViewSavedObjectSchema), + }, + }, + }, + update: { + in: { + options: { + schema: dataViewUpdateOptionsSchema, + }, + data: { + schema: dataViewAttributesSchema, + }, + }, + }, + search: { + in: { + options: { + schema: dataViewSearchOptionsSchema, + }, + }, + }, + mSearch: { + out: { + result: { + schema: dataViewSavedObjectSchema, + }, + }, + }, +}; diff --git a/src/plugins/data_views/common/content_management/v1/constants.ts b/src/plugins/data_views/common/content_management/v1/constants.ts new file mode 100644 index 0000000000000..bf6634e90d9c6 --- /dev/null +++ b/src/plugins/data_views/common/content_management/v1/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { DATA_VIEW_SAVED_OBJECT_TYPE as DataViewSOType } from '../..'; +export { DataViewSOType }; + +/** + * Data view saved object version. + */ +export const LATEST_VERSION = 1; + +export type DataViewContentType = typeof DataViewSOType; diff --git a/src/plugins/data_views/common/content_management/v1/index.ts b/src/plugins/data_views/common/content_management/v1/index.ts new file mode 100644 index 0000000000000..668cf91f3e235 --- /dev/null +++ b/src/plugins/data_views/common/content_management/v1/index.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. + */ + +export { LATEST_VERSION } from './constants'; + +export type { DataViewCrudTypes } from './types'; + +export type { DataViewContentType } from './constants'; + +export { DataViewSOType } from './constants'; diff --git a/src/plugins/data_views/common/content_management/v1/types.ts b/src/plugins/data_views/common/content_management/v1/types.ts new file mode 100644 index 0000000000000..1bada30b8dd94 --- /dev/null +++ b/src/plugins/data_views/common/content_management/v1/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ContentManagementCrudTypes, + SavedObjectCreateOptions, + SavedObjectSearchOptions, + SavedObjectUpdateOptions, +} from '@kbn/content-management-utils'; +import { DataViewAttributes } from '../../types'; +import { DataViewContentType } from './constants'; + +interface DataViewCreateOptions { + id?: SavedObjectCreateOptions['id']; + initialNamespaces?: SavedObjectCreateOptions['initialNamespaces']; +} + +interface DataViewUpdateOptions { + version?: SavedObjectUpdateOptions['version']; + refresh?: SavedObjectUpdateOptions['refresh']; + retryOnConflict?: SavedObjectUpdateOptions['retryOnConflict']; +} + +interface DataViewSearchOptions { + searchFields?: SavedObjectSearchOptions['searchFields']; + fields?: SavedObjectSearchOptions['fields']; +} + +export type DataViewCrudTypes = ContentManagementCrudTypes< + DataViewContentType, + DataViewAttributes, + DataViewCreateOptions, + DataViewUpdateOptions, + DataViewSearchOptions +>; diff --git a/src/plugins/data_views/common/data_view.stub.ts b/src/plugins/data_views/common/data_view.stub.ts index 40fd3d6da4bd5..0f161f15deeb9 100644 --- a/src/plugins/data_views/common/data_view.stub.ts +++ b/src/plugins/data_views/common/data_view.stub.ts @@ -6,14 +6,12 @@ * Side Public License, v 1. */ -import { SavedObject } from '@kbn/core/types'; import { stubFieldSpecMap, stubLogstashFieldSpecMap } from './field.stub'; import { createStubDataView } from './data_views/data_view.stub'; export { createStubDataView, createStubDataView as createStubIndexPattern, } from './data_views/data_view.stub'; -import { DataViewAttributes } from './types'; export const stubDataView = createStubDataView({ spec: { @@ -43,9 +41,7 @@ export const stubLogstashDataView = createStubDataView({ }, }); -export function stubbedSavedObjectDataView( - id: string | null = null -): SavedObject { +export function stubbedSavedObjectDataView(id: string | null = null) { return { id: id ?? '', type: 'index-pattern', diff --git a/src/plugins/data_views/common/data_views/data_views.test.ts b/src/plugins/data_views/common/data_views/data_views.test.ts index 7714a96b15b9e..fcd21858236f6 100644 --- a/src/plugins/data_views/common/data_views/data_views.test.ts +++ b/src/plugins/data_views/common/data_views/data_views.test.ts @@ -77,7 +77,7 @@ describe('IndexPatterns', () => { savedObjectsClient.find = jest.fn( () => Promise.resolve([indexPatternObj]) as Promise>> ); - savedObjectsClient.delete = jest.fn(() => Promise.resolve({}) as Promise); + savedObjectsClient.delete = jest.fn(() => Promise.resolve() as Promise); savedObjectsClient.create = jest.fn(); savedObjectsClient.get = jest.fn().mockImplementation(async (type, id) => { await new Promise((resolve) => setTimeout(resolve, SOClientGetDelay)); @@ -87,23 +87,21 @@ describe('IndexPatterns', () => { attributes: object.attributes, }; }); - savedObjectsClient.update = jest - .fn() - .mockImplementation(async (type, id, body, { version }) => { - if (object.version !== version) { - throw new Object({ - res: { - status: 409, - }, - }); - } - object.attributes.title = body.title; - object.version += 'a'; - return { - id: object.id, - version: object.version, - }; - }); + savedObjectsClient.update = jest.fn().mockImplementation(async (id, body, { version }) => { + if (object.version !== version) { + throw new Object({ + res: { + status: 409, + }, + }); + } + object.attributes.title = body.title; + object.version += 'a'; + return { + id: object.id, + version: object.version, + }; + }); apiClient = createFieldsFetcher(); @@ -267,7 +265,6 @@ describe('IndexPatterns', () => { test('savedObjectCache pre-fetches title, type, typeMeta', async () => { expect(await indexPatterns.getIds()).toEqual(['id']); expect(savedObjectsClient.find).toHaveBeenCalledWith({ - type: 'index-pattern', fields: ['title', 'type', 'typeMeta', 'name'], perPage: 10000, }); @@ -376,7 +373,6 @@ describe('IndexPatterns', () => { await indexPatterns.find('kibana*', size); expect(savedObjectsClient.find).lastCalledWith({ - type: 'index-pattern', fields: ['title'], search, searchFields: ['title'], @@ -449,13 +445,13 @@ describe('IndexPatterns', () => { dataView.setFieldFormat('field', { id: 'formatId' }); await indexPatterns.updateSavedObject(dataView); let lastCall = (savedObjectsClient.update as jest.Mock).mock.calls.pop() ?? []; - let [, , attrs] = lastCall; + let [, attrs] = lastCall; expect(attrs).toHaveProperty('fieldFormatMap'); expect(attrs.fieldFormatMap).toMatchInlineSnapshot(`"{\\"field\\":{\\"id\\":\\"formatId\\"}}"`); dataView.deleteFieldFormat('field'); await indexPatterns.updateSavedObject(dataView); lastCall = (savedObjectsClient.update as jest.Mock).mock.calls.pop() ?? []; - [, , attrs] = lastCall; + [, attrs] = lastCall; // https://github.com/elastic/kibana/issues/134873: must keep an empty object and not delete it expect(attrs).toHaveProperty('fieldFormatMap'); @@ -554,7 +550,7 @@ describe('IndexPatterns', () => { savedObjectsClient.get = jest .fn() - .mockImplementation((type: string, id: string) => + .mockImplementation((id: string) => Promise.resolve({ id, version: 'a', attributes: { title: 'title' } }) ); @@ -586,7 +582,7 @@ describe('IndexPatterns', () => { savedObjectsClient.get = jest .fn() - .mockImplementation((type: string, id: string) => + .mockImplementation((id: string) => Promise.resolve({ id, version: 'a', attributes: { title: '1' } }) ); diff --git a/src/plugins/data_views/common/data_views/data_views.ts b/src/plugins/data_views/common/data_views/data_views.ts index df1a115b83923..691fa313ac438 100644 --- a/src/plugins/data_views/common/data_views/data_views.ts +++ b/src/plugins/data_views/common/data_views/data_views.ts @@ -10,9 +10,7 @@ import { i18n } from '@kbn/i18n'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { castEsToKbnFieldTypeName } from '@kbn/field-types'; import { FieldFormatsStartCommon, FORMATS_UI_SETTINGS } from '@kbn/field-formats-plugin/common'; -import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; import { v4 as uuidv4 } from 'uuid'; -import { DATA_VIEW_SAVED_OBJECT_TYPE } from '..'; import { SavedObjectsClientCommon } from '../types'; import { createDataViewCache } from '.'; @@ -31,6 +29,7 @@ import { DataViewFieldMap, TypeMeta, } from '../types'; + import { META_FIELDS, SavedObject } from '..'; import { DataViewMissingIndices } from '../lib'; import { findByName } from '../utils'; @@ -175,7 +174,7 @@ export interface DataViewsServicePublicMethods { * Delete data view * @param indexPatternId - Id of the data view to delete. */ - delete: (indexPatternId: string) => Promise<{}>; + delete: (indexPatternId: string) => Promise; /** * Takes field array and field attributes and returns field map by name. * @param fields - Array of fieldspecs @@ -349,8 +348,7 @@ export class DataViewsService { * Refresh cache of index pattern ids and titles. */ private async refreshSavedObjectsCache() { - const so = await this.savedObjectsClient.find({ - type: DATA_VIEW_SAVED_OBJECT_TYPE, + const so = await this.savedObjectsClient.find({ fields: ['title', 'type', 'typeMeta', 'name'], perPage: 10000, }); @@ -392,8 +390,7 @@ export class DataViewsService { * @returns DataView[] */ find = async (search: string, size: number = 10): Promise => { - const savedObjects = await this.savedObjectsClient.find({ - type: DATA_VIEW_SAVED_OBJECT_TYPE, + const savedObjects = await this.savedObjectsClient.find({ fields: ['title'], search, searchFields: ['title'], @@ -726,14 +723,7 @@ export class DataViewsService { id: string, displayErrors: boolean = true ): Promise => { - const savedObject = await this.savedObjectsClient.get( - DATA_VIEW_SAVED_OBJECT_TYPE, - id - ); - - if (!savedObject.version) { - throw new SavedObjectNotFound('data view', id, 'management/kibana/dataViews'); - } + const savedObject = await this.savedObjectsClient.get(id); return this.initFromSavedObject(savedObject, displayErrors); }; @@ -1006,14 +996,11 @@ export class DataViewsService { } const body = dataView.getAsSavedObjectBody(); - const response: SavedObject = (await this.savedObjectsClient.create( - DATA_VIEW_SAVED_OBJECT_TYPE, - body, - { - id: dataView.id, - initialNamespaces: dataView.namespaces.length > 0 ? dataView.namespaces : undefined, - } - )) as SavedObject; + + const response: SavedObject = (await this.savedObjectsClient.create(body, { + id: dataView.id, + initialNamespaces: dataView.namespaces.length > 0 ? dataView.namespaces : undefined, + })) as SavedObject; const createdIndexPattern = await this.initFromSavedObject(response, displayErrors); if (this.savedObjectsCache) { @@ -1055,7 +1042,7 @@ export class DataViewsService { }); return this.savedObjectsClient - .update(DATA_VIEW_SAVED_OBJECT_TYPE, indexPattern.id, body, { + .update(indexPattern.id, body, { version: indexPattern.version, }) .then((response) => { @@ -1136,7 +1123,7 @@ export class DataViewsService { throw new DataViewInsufficientAccessError(indexPatternId); } this.dataViewCache.clear(indexPatternId); - return this.savedObjectsClient.delete(DATA_VIEW_SAVED_OBJECT_TYPE, indexPatternId); + return this.savedObjectsClient.delete(indexPatternId); } /** diff --git a/src/plugins/data_views/common/index.ts b/src/plugins/data_views/common/index.ts index a26895f6f9d78..de167a17c0eca 100644 --- a/src/plugins/data_views/common/index.ts +++ b/src/plugins/data_views/common/index.ts @@ -13,6 +13,8 @@ export { DATA_VIEW_SAVED_OBJECT_TYPE, } from './constants'; +export { LATEST_VERSION } from './content_management/v1/constants'; + export type { ToSpecConfig } from './fields'; export type { IIndexPatternFieldList } from './fields'; export { diff --git a/src/plugins/data_views/server/rest_api_routes/util/schemas.ts b/src/plugins/data_views/common/schemas.ts similarity index 95% rename from src/plugins/data_views/server/rest_api_routes/util/schemas.ts rename to src/plugins/data_views/common/schemas.ts index e31a4314c76f3..7af8a32ea8a87 100644 --- a/src/plugins/data_views/server/rest_api_routes/util/schemas.ts +++ b/src/plugins/data_views/common/schemas.ts @@ -7,7 +7,7 @@ */ import { schema, Type } from '@kbn/config-schema'; -import { /* RUNTIME_FIELD_TYPES,*/ RuntimeType } from '../../../common'; +import { RuntimeType } from '.'; export const serializedFieldFormatSchema = schema.object({ id: schema.maybe(schema.string()), @@ -16,11 +16,11 @@ export const serializedFieldFormatSchema = schema.object({ export const fieldSpecSchemaFields = { name: schema.string({ - maxLength: 1_000, + maxLength: 1000, }), type: schema.string({ defaultValue: 'string', - maxLength: 1_000, + maxLength: 1000, }), count: schema.maybe( schema.number({ @@ -29,7 +29,7 @@ export const fieldSpecSchemaFields = { ), script: schema.maybe( schema.string({ - maxLength: 1_000_000, + maxLength: 1000000, }) ), format: schema.maybe(serializedFieldFormatSchema), diff --git a/src/plugins/data_views/common/types.ts b/src/plugins/data_views/common/types.ts index d44f8fd34df47..91943b7d87790 100644 --- a/src/plugins/data_views/common/types.ts +++ b/src/plugins/data_views/common/types.ts @@ -7,11 +7,7 @@ */ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; -import type { - SavedObject, - SavedObjectsCreateOptions, - SavedObjectsUpdateOptions, -} from '@kbn/core/public'; +import type { SavedObject } from '@kbn/core/server'; import type { ErrorToastOptions, ToastInputFields } from '@kbn/core-notifications-browser'; import type { DataViewFieldBase } from '@kbn/es-query'; import type { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; @@ -119,7 +115,7 @@ export interface DataViewAttributes { /** * Fields as a serialized array of field specs */ - fields: string; + fields?: string; /** * Data view title */ @@ -236,10 +232,6 @@ export interface UiSettingsCommon { * @public */ export interface SavedObjectsClientCommonFindArgs { - /** - * Saved object type - */ - type: string | string[]; /** * Saved object fields */ @@ -267,13 +259,23 @@ export interface SavedObjectsClientCommon { * Search for saved objects * @param options - options for search */ - find: (options: SavedObjectsClientCommonFindArgs) => Promise>>; + find: ( + options: SavedObjectsClientCommonFindArgs + ) => Promise>>; /** * Get a single saved object by id * @param type - type of saved object * @param id - id of saved object */ - get: (type: string, id: string) => Promise>; + get: (id: string) => Promise>; + /** + * Update a saved object by id + * @param type - type of saved object + * @param id - id of saved object + * @param attributes - attributes to update + * @param options - client options + */ + getSavedSearch: (id: string) => Promise; /** * Update a saved object by id * @param type - type of saved object @@ -282,28 +284,26 @@ export interface SavedObjectsClientCommon { * @param options - client options */ update: ( - type: string, id: string, attributes: DataViewAttributes, - options: SavedObjectsUpdateOptions + options: { version?: string } ) => Promise; /** * Create a saved object - * @param type - type of saved object * @param attributes - attributes to set * @param options - client options */ create: ( - type: string, attributes: DataViewAttributes, - options: SavedObjectsCreateOptions & { initialNamespaces?: string[] } + // SavedObjectsCreateOptions + options: { id?: string; initialNamespaces?: string[] } ) => Promise; /** * Delete a saved object by id * @param type - type of saved object * @param id - id of saved object */ - delete: (type: string, id: string) => Promise<{}>; + delete: (id: string) => Promise; } export interface GetFieldsOptions { diff --git a/src/plugins/data_views/common/utils.ts b/src/plugins/data_views/common/utils.ts index 9b86dfefc2631..05822ed363e9f 100644 --- a/src/plugins/data_views/common/utils.ts +++ b/src/plugins/data_views/common/utils.ts @@ -6,11 +6,8 @@ * Side Public License, v 1. */ -import type { DataViewSavedObjectAttrs } from './data_views'; import type { SavedObjectsClientCommon } from './types'; -import { DATA_VIEW_SAVED_OBJECT_TYPE } from './constants'; - /** * Returns an object matching a given name * @@ -20,8 +17,7 @@ import { DATA_VIEW_SAVED_OBJECT_TYPE } from './constants'; */ export async function findByName(client: SavedObjectsClientCommon, name: string) { if (name) { - const savedObjects = await client.find<{ name: DataViewSavedObjectAttrs['name'] }>({ - type: DATA_VIEW_SAVED_OBJECT_TYPE, + const savedObjects = await client.find({ perPage: 10, search: `"${name}"`, searchFields: ['name.keyword'], diff --git a/src/plugins/data_views/kibana.jsonc b/src/plugins/data_views/kibana.jsonc index 786f01ec226c6..7595f8e68b3c4 100644 --- a/src/plugins/data_views/kibana.jsonc +++ b/src/plugins/data_views/kibana.jsonc @@ -9,7 +9,8 @@ "browser": true, "requiredPlugins": [ "fieldFormats", - "expressions" + "expressions", + "contentManagement" ], "optionalPlugins": [ "usageCollection" diff --git a/src/plugins/data_views/public/plugin.ts b/src/plugins/data_views/public/plugin.ts index 415a6c97bc73a..929512d70d926 100644 --- a/src/plugins/data_views/public/plugin.ts +++ b/src/plugins/data_views/public/plugin.ts @@ -7,6 +7,7 @@ */ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import { getIndexPatternLoad } from './expressions'; import { DataViewsPublicPluginSetup, @@ -25,6 +26,9 @@ import { getIndices, HasData } from './services'; import { debounceByKey } from './debounce_by_key'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../common/constants'; +import { LATEST_VERSION } from '../common/content_management/v1/constants'; + export class DataViewsPublicPlugin implements Plugin< @@ -38,18 +42,28 @@ export class DataViewsPublicPlugin public setup( core: CoreSetup, - { expressions }: DataViewsPublicSetupDependencies + { expressions, contentManagement }: DataViewsPublicSetupDependencies ): DataViewsPublicPluginSetup { expressions.registerFunction(getIndexPatternLoad({ getStartServices: core.getStartServices })); + contentManagement.registry.register({ + id: DATA_VIEW_SAVED_OBJECT_TYPE, + version: { + latest: LATEST_VERSION, + }, + name: i18n.translate('dataViews.contentManagementType', { + defaultMessage: 'Data view', + }), + }); + return {}; } public start( core: CoreStart, - { fieldFormats }: DataViewsPublicStartDependencies + { fieldFormats, contentManagement }: DataViewsPublicStartDependencies ): DataViewsPublicPluginStart { - const { uiSettings, http, notifications, savedObjects, application } = core; + const { uiSettings, http, notifications, application, savedObjects } = core; const onNotifDebounced = debounceByKey( notifications.toasts.add.bind(notifications.toasts), @@ -63,7 +77,10 @@ export class DataViewsPublicPlugin return new DataViewsServicePublic({ hasData: this.hasData.start(core), uiSettings: new UiSettingsPublicToCommon(uiSettings), - savedObjectsClient: new SavedObjectsClientPublicToCommon(savedObjects.client), + savedObjectsClient: new SavedObjectsClientPublicToCommon( + contentManagement.client, + savedObjects.client + ), apiClient: new DataViewsApiClient(http), fieldFormats, onNotification: (toastInputFields, key) => { diff --git a/src/plugins/data_views/public/saved_objects_client_wrapper.test.ts b/src/plugins/data_views/public/saved_objects_client_wrapper.test.ts index faf497c60ba31..898d22b7d3637 100644 --- a/src/plugins/data_views/public/saved_objects_client_wrapper.test.ts +++ b/src/plugins/data_views/public/saved_objects_client_wrapper.test.ts @@ -7,22 +7,23 @@ */ import { SavedObjectsClientPublicToCommon } from './saved_objects_client_wrapper'; +import { ContentClient } from '@kbn/content-management-plugin/public'; import { savedObjectsServiceMock } from '@kbn/core/public/mocks'; - import { DataViewSavedObjectConflictError } from '../common'; describe('SavedObjectsClientPublicToCommon', () => { const soClient = savedObjectsServiceMock.createStartContract().client; + const cmClient = {} as ContentClient; test('get saved object - exactMatch', async () => { const mockedSavedObject = { version: 'abc', }; - soClient.resolve = jest + cmClient.get = jest .fn() - .mockResolvedValue({ outcome: 'exactMatch', saved_object: mockedSavedObject }); - const service = new SavedObjectsClientPublicToCommon(soClient); - const result = await service.get('index-pattern', '1'); + .mockResolvedValue({ meta: { outcome: 'exactMatch' }, item: mockedSavedObject }); + const service = new SavedObjectsClientPublicToCommon(cmClient, soClient); + const result = await service.get('1'); expect(result).toStrictEqual(mockedSavedObject); }); @@ -30,11 +31,11 @@ describe('SavedObjectsClientPublicToCommon', () => { const mockedSavedObject = { version: 'def', }; - soClient.resolve = jest + cmClient.get = jest .fn() - .mockResolvedValue({ outcome: 'aliasMatch', saved_object: mockedSavedObject }); - const service = new SavedObjectsClientPublicToCommon(soClient); - const result = await service.get('index-pattern', '1'); + .mockResolvedValue({ meta: { outcome: 'aliasMatch' }, item: mockedSavedObject }); + const service = new SavedObjectsClientPublicToCommon(cmClient, soClient); + const result = await service.get('1'); expect(result).toStrictEqual(mockedSavedObject); }); @@ -43,13 +44,11 @@ describe('SavedObjectsClientPublicToCommon', () => { version: 'ghi', }; - soClient.resolve = jest + cmClient.get = jest .fn() - .mockResolvedValue({ outcome: 'conflict', saved_object: mockedSavedObject }); - const service = new SavedObjectsClientPublicToCommon(soClient); + .mockResolvedValue({ meta: { outcome: 'conflict' }, item: mockedSavedObject }); + const service = new SavedObjectsClientPublicToCommon(cmClient, soClient); - await expect(service.get('index-pattern', '1')).rejects.toThrow( - DataViewSavedObjectConflictError - ); + await expect(service.get('1')).rejects.toThrow(DataViewSavedObjectConflictError); }); }); diff --git a/src/plugins/data_views/public/saved_objects_client_wrapper.ts b/src/plugins/data_views/public/saved_objects_client_wrapper.ts index e8d008e130323..2e22ef889b1ac 100644 --- a/src/plugins/data_views/public/saved_objects_client_wrapper.ts +++ b/src/plugins/data_views/public/saved_objects_client_wrapper.ts @@ -6,13 +6,9 @@ * Side Public License, v 1. */ -import { - SavedObjectsClientContract, - SavedObjectsCreateOptions, - SavedObjectsUpdateOptions, - SimpleSavedObject, -} from '@kbn/core/public'; -import { omit } from 'lodash'; +import type { ContentClient } from '@kbn/content-management-plugin/public'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core/public'; import { DataViewSavedObjectConflictError } from '../common/errors'; import { DataViewAttributes, @@ -21,53 +17,113 @@ import { SavedObjectsClientCommonFindArgs, } from '../common/types'; -type SOClient = Pick< - SavedObjectsClientContract, - 'find' | 'resolve' | 'update' | 'create' | 'delete' ->; +import type { DataViewCrudTypes } from '../common/content_management'; + +import { DataViewSOType } from '../common/content_management'; -const simpleSavedObjectToSavedObject = (simpleSavedObject: SimpleSavedObject): SavedObject => - ({ - version: simpleSavedObject._version, - ...omit(simpleSavedObject, '_version'), - } as SavedObject); +type SOClient = Pick; export class SavedObjectsClientPublicToCommon implements SavedObjectsClientCommon { + private contentManagementClient: ContentClient; private savedObjectClient: SOClient; - constructor(savedObjectClient: SOClient) { + constructor(contentManagementClient: ContentClient, savedObjectClient: SOClient) { + this.contentManagementClient = contentManagementClient; this.savedObjectClient = savedObjectClient; } - async find(options: SavedObjectsClientCommonFindArgs) { - const response = (await this.savedObjectClient.find(options)).savedObjects; - return response.map>(simpleSavedObjectToSavedObject); + async find(options: SavedObjectsClientCommonFindArgs) { + const results = await this.contentManagementClient.search< + DataViewCrudTypes['SearchIn'], + DataViewCrudTypes['SearchOut'] + >({ + contentTypeId: DataViewSOType, + query: { + text: options.search, + limit: options.perPage, + }, + options: { + searchFields: options.searchFields, + fields: options.fields, + }, + }); + return results.hits; + } + + async get(id: string) { + let response: DataViewCrudTypes['GetOut']; + try { + response = await this.contentManagementClient.get< + DataViewCrudTypes['GetIn'], + DataViewCrudTypes['GetOut'] + >({ + contentTypeId: DataViewSOType, + id, + }); + } catch (e) { + if (e.body?.statusCode === 404) { + throw new SavedObjectNotFound('data view', id, 'management/kibana/dataViews'); + } else { + throw e; + } + } + + if (response.meta.outcome === 'conflict') { + throw new DataViewSavedObjectConflictError(id); + } + + return response.item; } - async get(type: string, id: string) { - const response = await this.savedObjectClient.resolve(type, id); + async getSavedSearch(id: string) { + const response = await this.savedObjectClient.resolve('search', id); + if (response.outcome === 'conflict') { throw new DataViewSavedObjectConflictError(id); } - return simpleSavedObjectToSavedObject(response.saved_object); + return response.saved_object; } async update( - type: string, id: string, attributes: DataViewAttributes, - options: SavedObjectsUpdateOptions + options: DataViewCrudTypes['UpdateOptions'] ) { - const response = await this.savedObjectClient.update(type, id, attributes, options); - return simpleSavedObjectToSavedObject(response); + const response = await this.contentManagementClient.update< + DataViewCrudTypes['UpdateIn'], + DataViewCrudTypes['UpdateOut'] + >({ + contentTypeId: DataViewSOType, + id, + data: attributes, + options, + }); + + // cast is necessary since its the full object and not just the changes + return response.item as SavedObject; } - async create(type: string, attributes: DataViewAttributes, options?: SavedObjectsCreateOptions) { - const response = await this.savedObjectClient.create(type, attributes, options); - return simpleSavedObjectToSavedObject(response); + async create(attributes: DataViewAttributes, options: DataViewCrudTypes['CreateOptions']) { + const result = await this.contentManagementClient.create< + DataViewCrudTypes['CreateIn'], + DataViewCrudTypes['CreateOut'] + >({ + contentTypeId: DataViewSOType, + data: attributes, + options, + }); + + return result.item; } - delete(type: string, id: string) { - return this.savedObjectClient.delete(type, id, { force: true }); + async delete(id: string) { + await this.contentManagementClient.delete< + DataViewCrudTypes['DeleteIn'], + DataViewCrudTypes['DeleteOut'] + >({ + contentTypeId: DataViewSOType, + id, + options: { force: true }, + }); } } diff --git a/src/plugins/data_views/public/types.ts b/src/plugins/data_views/public/types.ts index 637d4191c671e..c30aebe9243e0 100644 --- a/src/plugins/data_views/public/types.ts +++ b/src/plugins/data_views/public/types.ts @@ -8,6 +8,10 @@ import { ExpressionsSetup } from '@kbn/expressions-plugin/public'; import { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import type { + ContentManagementPublicSetup, + ContentManagementPublicStart, +} from '@kbn/content-management-plugin/public'; import { DataViewsServicePublicMethods } from './data_views'; import { HasDataService } from '../common'; @@ -82,6 +86,10 @@ export interface DataViewsPublicSetupDependencies { * Field formats */ fieldFormats: FieldFormatsSetup; + /** + * Content management + */ + contentManagement: ContentManagementPublicSetup; } /** @@ -92,6 +100,10 @@ export interface DataViewsPublicStartDependencies { * Field formats */ fieldFormats: FieldFormatsStart; + /** + * Content management + */ + contentManagement: ContentManagementPublicStart; } /** diff --git a/src/plugins/data_views/server/content_management/data_views_storage.ts b/src/plugins/data_views/server/content_management/data_views_storage.ts new file mode 100644 index 0000000000000..c42a83f58e084 --- /dev/null +++ b/src/plugins/data_views/server/content_management/data_views_storage.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { SOContentStorage } from '@kbn/content-management-utils'; + +import type { DataViewCrudTypes } from '../../common/content_management'; +import { DataViewSOType } from '../../common/content_management'; +import { cmServicesDefinition } from '../../common/content_management/cm_services'; + +export class DataViewsStorage extends SOContentStorage { + constructor() { + super({ + savedObjectType: DataViewSOType, + cmServicesDefinition, + enableMSearch: true, + allowedSavedObjectAttributes: [ + 'fields', + 'title', + 'type', + 'typeMeta', + 'timeFieldName', + 'sourceFilters', + 'fieldFormatMap', + 'fieldAttrs', + 'runtimeFieldMap', + 'allowNoIndex', + 'name', + ], + }); + } +} diff --git a/src/plugins/data_views/server/content_management/index.ts b/src/plugins/data_views/server/content_management/index.ts new file mode 100644 index 0000000000000..34b30c3a2f37d --- /dev/null +++ b/src/plugins/data_views/server/content_management/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { DataViewsStorage } from './data_views_storage'; diff --git a/src/plugins/data_views/server/plugin.ts b/src/plugins/data_views/server/plugin.ts index 9727495553fe0..fab72338bdb33 100644 --- a/src/plugins/data_views/server/plugin.ts +++ b/src/plugins/data_views/server/plugin.ts @@ -14,12 +14,14 @@ import { capabilitiesProvider } from './capabilities_provider'; import { getIndexPatternLoad } from './expressions'; import { registerIndexPatternsUsageCollector } from './register_index_pattern_usage_collection'; import { createScriptedFieldsDeprecationsConfig } from './deprecations'; +import { DATA_VIEW_SAVED_OBJECT_TYPE, LATEST_VERSION } from '../common'; import { DataViewsServerPluginSetup, DataViewsServerPluginStart, DataViewsServerPluginSetupDependencies, DataViewsServerPluginStartDependencies, } from './types'; +import { DataViewsStorage } from './content_management'; export class DataViewsServerPlugin implements @@ -38,7 +40,7 @@ export class DataViewsServerPlugin public setup( core: CoreSetup, - { expressions, usageCollection }: DataViewsServerPluginSetupDependencies + { expressions, usageCollection, contentManagement }: DataViewsServerPluginSetupDependencies ) { core.savedObjects.registerType(dataViewSavedObjectType); core.capabilities.registerProvider(capabilitiesProvider); @@ -50,6 +52,14 @@ export class DataViewsServerPlugin registerIndexPatternsUsageCollector(core.getStartServices, usageCollection); core.deprecations.registerDeprecations(createScriptedFieldsDeprecationsConfig(core)); + contentManagement.register({ + id: DATA_VIEW_SAVED_OBJECT_TYPE, + storage: new DataViewsStorage(), + version: { + latest: LATEST_VERSION, + }, + }); + return {}; } diff --git a/src/plugins/data_views/server/rest_api_routes/create_data_view.ts b/src/plugins/data_views/server/rest_api_routes/create_data_view.ts index 58bc36ed1c869..fea732cbe10ca 100644 --- a/src/plugins/data_views/server/rest_api_routes/create_data_view.ts +++ b/src/plugins/data_views/server/rest_api_routes/create_data_view.ts @@ -12,7 +12,11 @@ import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { DataViewSpec } from '../../common/types'; import { DataViewsService } from '../../common/data_views'; import { handleErrors } from './util/handle_errors'; -import { fieldSpecSchema, runtimeFieldSchema, serializedFieldFormatSchema } from './util/schemas'; +import { + fieldSpecSchema, + runtimeFieldSchema, + serializedFieldFormatSchema, +} from '../../common/schemas'; import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; import { DATA_VIEW_PATH, diff --git a/src/plugins/data_views/server/rest_api_routes/fields/update_fields.ts b/src/plugins/data_views/server/rest_api_routes/fields/update_fields.ts index 1695f28675188..3de9bcbdc7d1d 100644 --- a/src/plugins/data_views/server/rest_api_routes/fields/update_fields.ts +++ b/src/plugins/data_views/server/rest_api_routes/fields/update_fields.ts @@ -12,7 +12,7 @@ import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; import { DataViewsService } from '../../../common'; import { handleErrors } from '../util/handle_errors'; -import { serializedFieldFormatSchema } from '../util/schemas'; +import { serializedFieldFormatSchema } from '../../../common/schemas'; import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart, diff --git a/src/plugins/data_views/server/rest_api_routes/runtime_fields/create_runtime_field.ts b/src/plugins/data_views/server/rest_api_routes/runtime_fields/create_runtime_field.ts index da3928c874930..0a31a651169fb 100644 --- a/src/plugins/data_views/server/rest_api_routes/runtime_fields/create_runtime_field.ts +++ b/src/plugins/data_views/server/rest_api_routes/runtime_fields/create_runtime_field.ts @@ -12,7 +12,7 @@ import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { DataViewsService } from '../../../common/data_views'; import { RuntimeField } from '../../../common/types'; import { handleErrors } from '../util/handle_errors'; -import { runtimeFieldSchema } from '../util/schemas'; +import { runtimeFieldSchema } from '../../../common/schemas'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, diff --git a/src/plugins/data_views/server/rest_api_routes/runtime_fields/put_runtime_field.ts b/src/plugins/data_views/server/rest_api_routes/runtime_fields/put_runtime_field.ts index 98378da328410..b8bac2be60bb8 100644 --- a/src/plugins/data_views/server/rest_api_routes/runtime_fields/put_runtime_field.ts +++ b/src/plugins/data_views/server/rest_api_routes/runtime_fields/put_runtime_field.ts @@ -12,7 +12,7 @@ import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { DataViewsService } from '../../../common/data_views'; import { RuntimeField } from '../../../common/types'; import { handleErrors } from '../util/handle_errors'; -import { runtimeFieldSchema } from '../util/schemas'; +import { runtimeFieldSchema } from '../../../common/schemas'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, diff --git a/src/plugins/data_views/server/rest_api_routes/runtime_fields/update_runtime_field.ts b/src/plugins/data_views/server/rest_api_routes/runtime_fields/update_runtime_field.ts index 880b4bc59b601..624953c40bd09 100644 --- a/src/plugins/data_views/server/rest_api_routes/runtime_fields/update_runtime_field.ts +++ b/src/plugins/data_views/server/rest_api_routes/runtime_fields/update_runtime_field.ts @@ -13,7 +13,7 @@ import { DataViewsService } from '../../../common/data_views'; import { RuntimeField } from '../../../common/types'; import { ErrorIndexPatternFieldNotFound } from '../../error'; import { handleErrors } from '../util/handle_errors'; -import { runtimeFieldSchema } from '../util/schemas'; +import { runtimeFieldSchema } from '../../../common/schemas'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, diff --git a/src/plugins/data_views/server/rest_api_routes/scripted_fields/create_scripted_field.ts b/src/plugins/data_views/server/rest_api_routes/scripted_fields/create_scripted_field.ts index a4a38c056d825..7d1ea07377a67 100644 --- a/src/plugins/data_views/server/rest_api_routes/scripted_fields/create_scripted_field.ts +++ b/src/plugins/data_views/server/rest_api_routes/scripted_fields/create_scripted_field.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { handleErrors } from '../util/handle_errors'; -import { fieldSpecSchema } from '../util/schemas'; +import { fieldSpecSchema } from '../../../common/schemas'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, diff --git a/src/plugins/data_views/server/rest_api_routes/scripted_fields/put_scripted_field.ts b/src/plugins/data_views/server/rest_api_routes/scripted_fields/put_scripted_field.ts index e42c364fac8f0..ee0d4f726a789 100644 --- a/src/plugins/data_views/server/rest_api_routes/scripted_fields/put_scripted_field.ts +++ b/src/plugins/data_views/server/rest_api_routes/scripted_fields/put_scripted_field.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { handleErrors } from '../util/handle_errors'; -import { fieldSpecSchema } from '../util/schemas'; +import { fieldSpecSchema } from '../../../common/schemas'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, diff --git a/src/plugins/data_views/server/rest_api_routes/scripted_fields/update_scripted_field.ts b/src/plugins/data_views/server/rest_api_routes/scripted_fields/update_scripted_field.ts index 642761a61b7cb..617c197ebdb47 100644 --- a/src/plugins/data_views/server/rest_api_routes/scripted_fields/update_scripted_field.ts +++ b/src/plugins/data_views/server/rest_api_routes/scripted_fields/update_scripted_field.ts @@ -11,7 +11,7 @@ import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { FieldSpec } from '../../../common'; import { ErrorIndexPatternFieldNotFound } from '../../error'; import { handleErrors } from '../util/handle_errors'; -import { fieldSpecSchemaFields } from '../util/schemas'; +import { fieldSpecSchemaFields } from '../../../common/schemas'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, diff --git a/src/plugins/data_views/server/rest_api_routes/update_data_view.ts b/src/plugins/data_views/server/rest_api_routes/update_data_view.ts index 1ac504ac652b8..fb4faf79a2d4c 100644 --- a/src/plugins/data_views/server/rest_api_routes/update_data_view.ts +++ b/src/plugins/data_views/server/rest_api_routes/update_data_view.ts @@ -12,7 +12,11 @@ import { IRouter, StartServicesAccessor } from '@kbn/core/server'; import { DataViewsService, DataView } from '../../common/data_views'; import { DataViewSpec } from '../../common/types'; import { handleErrors } from './util/handle_errors'; -import { fieldSpecSchema, runtimeFieldSchema, serializedFieldFormatSchema } from './util/schemas'; +import { + fieldSpecSchema, + runtimeFieldSchema, + serializedFieldFormatSchema, +} from '../../common/schemas'; import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; import { SPECIFIC_DATA_VIEW_PATH, diff --git a/src/plugins/data_views/server/rest_api_routes/util/handle_errors.ts b/src/plugins/data_views/server/rest_api_routes/util/handle_errors.ts index a01890c34a43d..1d20752c06f88 100644 --- a/src/plugins/data_views/server/rest_api_routes/util/handle_errors.ts +++ b/src/plugins/data_views/server/rest_api_routes/util/handle_errors.ts @@ -8,6 +8,7 @@ import Boom from '@hapi/boom'; import type { RequestHandler, RouteMethod, RequestHandlerContext } from '@kbn/core/server'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; import { ErrorIndexPatternNotFound } from '../../error'; interface ErrorResponseBody { @@ -49,7 +50,8 @@ export const handleErrors = const is404 = (error as ErrorIndexPatternNotFound).is404 || - (error as Boom.Boom)?.output?.statusCode === 404; + (error as Boom.Boom)?.output?.statusCode === 404 || + error instanceof SavedObjectNotFound; if (is404) { return response.notFound({ diff --git a/src/plugins/data_views/server/saved_objects_client_wrapper.test.ts b/src/plugins/data_views/server/saved_objects_client_wrapper.test.ts index bd7983abd46a9..e5dc83f7e109b 100644 --- a/src/plugins/data_views/server/saved_objects_client_wrapper.test.ts +++ b/src/plugins/data_views/server/saved_objects_client_wrapper.test.ts @@ -22,7 +22,7 @@ describe('SavedObjectsClientPublicToCommon', () => { .fn() .mockResolvedValue({ outcome: 'exactMatch', saved_object: mockedSavedObject }); const service = new SavedObjectsClientServerToCommon(soClient); - const result = await service.get('index-pattern', '1'); + const result = await service.get('1'); expect(result).toStrictEqual(mockedSavedObject); }); @@ -34,7 +34,7 @@ describe('SavedObjectsClientPublicToCommon', () => { .fn() .mockResolvedValue({ outcome: 'aliasMatch', saved_object: mockedSavedObject }); const service = new SavedObjectsClientServerToCommon(soClient); - const result = await service.get('index-pattern', '1'); + const result = await service.get('1'); expect(result).toStrictEqual(mockedSavedObject); }); @@ -48,8 +48,6 @@ describe('SavedObjectsClientPublicToCommon', () => { .mockResolvedValue({ outcome: 'conflict', saved_object: mockedSavedObject }); const service = new SavedObjectsClientServerToCommon(soClient); - await expect(service.get('index-pattern', '1')).rejects.toThrow( - DataViewSavedObjectConflictError - ); + await expect(service.get('1')).rejects.toThrow(DataViewSavedObjectConflictError); }); }); diff --git a/src/plugins/data_views/server/saved_objects_client_wrapper.ts b/src/plugins/data_views/server/saved_objects_client_wrapper.ts index 9ac5e5203a10e..a8902904411df 100644 --- a/src/plugins/data_views/server/saved_objects_client_wrapper.ts +++ b/src/plugins/data_views/server/saved_objects_client_wrapper.ts @@ -14,30 +14,50 @@ import { } from '../common/types'; import { DataViewSavedObjectConflictError } from '../common/errors'; +import type { DataViewCrudTypes } from '../common/content_management'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../common'; + export class SavedObjectsClientServerToCommon implements SavedObjectsClientCommon { private savedObjectClient: SavedObjectsClientContract; constructor(savedObjectClient: SavedObjectsClientContract) { this.savedObjectClient = savedObjectClient; } async find(options: SavedObjectsClientCommonFindArgs) { - const result = await this.savedObjectClient.find(options); + const result = await this.savedObjectClient.find({ + ...options, + type: DATA_VIEW_SAVED_OBJECT_TYPE, + }); return result.saved_objects; } - async get(type: string, id: string) { - const response = await this.savedObjectClient.resolve(type, id); + async get(id: string) { + const response = await this.savedObjectClient.resolve('index-pattern', id); + if (response.outcome === 'conflict') { + throw new DataViewSavedObjectConflictError(id); + } + return response.saved_object; + } + + async getSavedSearch(id: string) { + const response = await this.savedObjectClient.resolve('search', id); if (response.outcome === 'conflict') { throw new DataViewSavedObjectConflictError(id); } return response.saved_object; } - async update(type: string, id: string, attributes: DataViewAttributes, options: {}) { - return (await this.savedObjectClient.update(type, id, attributes, options)) as SavedObject; + + async update(id: string, attributes: DataViewAttributes, options: {}) { + return (await this.savedObjectClient.update( + DATA_VIEW_SAVED_OBJECT_TYPE, + id, + attributes, + options + )) as SavedObject; } - async create(type: string, attributes: DataViewAttributes, options: {}) { - return await this.savedObjectClient.create(type, attributes, options); + async create(attributes: DataViewAttributes, options: DataViewCrudTypes['CreateOptions']) { + return await this.savedObjectClient.create(DATA_VIEW_SAVED_OBJECT_TYPE, attributes, options); } - delete(type: string, id: string) { - return this.savedObjectClient.delete(type, id, { force: true }); + async delete(id: string) { + await this.savedObjectClient.delete(DATA_VIEW_SAVED_OBJECT_TYPE, id, { force: true }); } } diff --git a/src/plugins/data_views/server/types.ts b/src/plugins/data_views/server/types.ts index cce27ff305972..5b87573322379 100644 --- a/src/plugins/data_views/server/types.ts +++ b/src/plugins/data_views/server/types.ts @@ -15,6 +15,7 @@ import { import { ExpressionsServerSetup } from '@kbn/expressions-plugin/server'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/server'; +import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { DataViewsService } from '../common'; /** @@ -72,6 +73,10 @@ export interface DataViewsServerPluginSetupDependencies { * Usage collection */ usageCollection?: UsageCollectionSetup; + /** + * Content management + */ + contentManagement: ContentManagementServerSetup; } /** diff --git a/src/plugins/data_views/server/utils.ts b/src/plugins/data_views/server/utils.ts index 79374609cdaa0..eae603ca0e236 100644 --- a/src/plugins/data_views/server/utils.ts +++ b/src/plugins/data_views/server/utils.ts @@ -16,7 +16,7 @@ export const getFieldByName = ( fieldName: string, indexPattern: SavedObject ): FieldSpec | undefined => { - const fields: FieldSpec[] = indexPattern && JSON.parse(indexPattern.attributes.fields); + const fields: FieldSpec[] = indexPattern && JSON.parse(indexPattern.attributes?.fields || '[]'); const field = fields && fields.find((f) => f.name === fieldName); return field; diff --git a/src/plugins/data_views/tsconfig.json b/src/plugins/data_views/tsconfig.json index dae61e82637c6..558d22ec5b41f 100644 --- a/src/plugins/data_views/tsconfig.json +++ b/src/plugins/data_views/tsconfig.json @@ -28,6 +28,9 @@ "@kbn/utility-types-jest", "@kbn/safer-lodash-set", "@kbn/core-http-server", + "@kbn/content-management-plugin", + "@kbn/content-management-utils", + "@kbn/object-versioning", "@kbn/core-saved-objects-server", ], "exclude": [ diff --git a/x-pack/plugins/maps/server/content_management/maps_storage.ts b/x-pack/plugins/maps/server/content_management/maps_storage.ts index 4802df07957aa..4169912642945 100644 --- a/x-pack/plugins/maps/server/content_management/maps_storage.ts +++ b/x-pack/plugins/maps/server/content_management/maps_storage.ts @@ -33,6 +33,13 @@ export class MapsStorage extends SOContentStorage { cmServicesDefinition, searchArgsToSOFindOptions, enableMSearch: true, + allowedSavedObjectAttributes: [ + 'title', + 'description', + 'mapStateJSON', + 'layerListJSON', + 'uiStateJSON', + ], }); } } From bc3e312df1f219f685e49a0c2aa1828648cb8e43 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 24 May 2023 20:46:17 +0200 Subject: [PATCH 09/20] [Cases] Update io-ts types as strict (#156813) ## Summary This PR fixes https://github.com/elastic/kibana/issues/156156 It uses` io-ts strict` type to remove unnecessary attributes. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### 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: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Jonathan Buttner Co-authored-by: Christos Nasikas --- .../cases/common/api/cases/alerts.test.ts | 46 + .../plugins/cases/common/api/cases/alerts.ts | 2 +- .../cases/common/api/cases/assignee.test.ts | 41 + .../cases/common/api/cases/case.test.ts | 709 ++++++++++++++ x-pack/plugins/cases/common/api/cases/case.ts | 287 +++--- .../common/api/cases/comment/files.test.ts | 98 ++ .../cases/common/api/cases/comment/files.ts | 6 +- .../common/api/cases/comment/index.test.ts | 905 ++++++++++++++++++ .../cases/common/api/cases/comment/index.ts | 104 +- .../cases/common/api/cases/configure.test.ts | 226 +++++ .../cases/common/api/cases/configure.ts | 31 +- .../cases/common/api/cases/status.test.ts | 62 ++ .../plugins/cases/common/api/cases/status.ts | 34 +- .../api/cases/user_actions/assignees.test.ts | 92 ++ .../api/cases/user_actions/assignees.ts | 4 +- .../api/cases/user_actions/comment.test.ts | 93 ++ .../common/api/cases/user_actions/comment.ts | 4 +- .../api/cases/user_actions/common.test.ts | 95 ++ .../common/api/cases/user_actions/common.ts | 6 +- .../api/cases/user_actions/connector.test.ts | 58 ++ .../api/cases/user_actions/connector.ts | 10 +- .../cases/user_actions/create_case.test.ts | 130 +++ .../api/cases/user_actions/create_case.ts | 18 +- .../cases/user_actions/delete_case.test.ts | 45 + .../api/cases/user_actions/delete_case.ts | 4 +- .../cases/user_actions/description.test.ts | 74 ++ .../api/cases/user_actions/description.ts | 4 +- .../user_actions/operations/find.test.ts | 108 +++ .../api/cases/user_actions/operations/find.ts | 16 +- .../api/cases/user_actions/pushed.test.ts | 213 +++++ .../common/api/cases/user_actions/pushed.ts | 8 +- .../api/cases/user_actions/response.test.ts | 93 ++ .../common/api/cases/user_actions/response.ts | 2 +- .../api/cases/user_actions/settings.test.ts | 79 ++ .../common/api/cases/user_actions/settings.ts | 4 +- .../api/cases/user_actions/severity.test.ts | 75 ++ .../common/api/cases/user_actions/severity.ts | 4 +- .../api/cases/user_actions/stats.test.ts | 36 + .../common/api/cases/user_actions/stats.ts | 2 +- .../api/cases/user_actions/status.test.ts | 75 ++ .../common/api/cases/user_actions/status.ts | 4 +- .../api/cases/user_actions/tags.test.ts | 74 ++ .../common/api/cases/user_actions/tags.ts | 4 +- .../api/cases/user_actions/title.test.ts | 74 ++ .../common/api/cases/user_actions/title.ts | 4 +- .../common/api/cases/user_profile.test.ts | 89 ++ .../cases/common/api/cases/user_profiles.ts | 6 +- .../common/api/connectors/connector.test.ts | 150 +++ .../cases/common/api/connectors/connector.ts | 30 +- .../api/connectors/get_connectors.test.ts | 89 ++ .../common/api/connectors/get_connectors.ts | 14 +- .../cases/common/api/connectors/jira.test.ts | 37 + .../cases/common/api/connectors/jira.ts | 2 +- .../common/api/connectors/mappings.test.ts | 58 ++ .../cases/common/api/connectors/mappings.ts | 6 +- .../common/api/connectors/resilient.test.ts | 36 + .../cases/common/api/connectors/resilient.ts | 2 +- .../api/connectors/servicenow_itsm.test.ts | 36 + .../common/api/connectors/servicenow_itsm.ts | 2 +- .../api/connectors/servicenow_sir.test.ts | 38 + .../common/api/connectors/servicenow_sir.ts | 2 +- .../common/api/connectors/swimlane.test.ts | 28 + .../cases/common/api/connectors/swimlane.ts | 2 +- .../cases/common/api/metrics/case.test.ts | 287 ++++++ .../plugins/cases/common/api/metrics/case.ts | 82 +- .../cases/common/api/runtime_types.test.ts | 115 +++ .../plugins/cases/common/api/runtime_types.ts | 140 +-- x-pack/plugins/cases/common/api/user.test.ts | 263 +++++ x-pack/plugins/cases/common/api/user.ts | 18 +- .../plugins/cases/common/files/index.test.ts | 28 + x-pack/plugins/cases/common/files/index.ts | 2 +- .../cases/public/containers/api.test.tsx | 10 +- .../plugins/cases/public/containers/mock.ts | 41 +- .../server/client/attachments/add.test.ts | 25 + .../cases/server/client/attachments/add.ts | 13 +- .../client/attachments/bulk_create.test.ts | 25 + .../server/client/attachments/bulk_create.ts | 12 +- .../server/client/attachments/bulk_delete.ts | 10 +- .../server/client/attachments/bulk_get.ts | 11 +- .../cases/server/client/attachments/delete.ts | 9 +- .../server/client/attachments/get.test.tsx | 17 +- .../cases/server/client/attachments/get.ts | 13 +- .../server/client/cases/bulk_get.test.ts | 10 + .../cases/server/client/cases/bulk_get.ts | 11 +- .../cases/server/client/cases/create.test.ts | 16 + .../cases/server/client/cases/create.ts | 13 +- .../cases/server/client/cases/find.test.ts | 11 + .../plugins/cases/server/client/cases/find.ts | 14 +- .../cases/server/client/cases/get.test.ts | 47 + .../plugins/cases/server/client/cases/get.ts | 22 +- .../cases/server/client/cases/update.test.ts | 19 + .../cases/server/client/cases/update.ts | 11 +- .../server/client/configure/client.test.ts | 28 +- .../cases/server/client/configure/client.ts | 32 +- .../client/metrics/get_cases_metrics.test.ts | 7 + .../client/metrics/get_cases_metrics.ts | 15 +- .../client/metrics/get_status_totals.test.ts | 7 + .../client/metrics/get_status_totals.ts | 13 +- x-pack/plugins/cases/server/client/mocks.ts | 24 +- .../server/client/user_actions/find.test.ts | 26 + .../cases/server/client/user_actions/find.ts | 13 +- x-pack/plugins/cases/server/client/utils.ts | 26 +- .../common/models/case_with_comments.test.ts | 85 +- .../cases/server/common/types/case.test.ts | 2 +- .../plugins/cases/server/common/types/case.ts | 2 +- .../server/common/types/configure.test.ts | 2 +- .../cases/server/common/types/configure.ts | 4 +- .../common/types/connector_mappings.test.ts | 2 +- .../server/common/types/connector_mappings.ts | 2 +- .../server/internal_attachments/index.ts | 12 +- x-pack/plugins/cases/server/mocks.ts | 30 +- .../server/routes/api/cases/push_case.ts | 12 +- .../routes/api/comments/find_comments.ts | 12 +- .../routes/api/comments/patch_comment.ts | 11 +- .../routes/api/configure/patch_configure.ts | 12 +- .../routes/api/configure/post_configure.ts | 12 +- .../cases/server/services/cases/index.test.ts | 2 +- .../services/user_actions/index.test.ts | 2 +- .../server/services/user_actions/mocks.ts | 20 +- .../server/services/user_profiles/index.ts | 10 +- .../tests/common/cases/patch_cases.ts | 4 +- .../metrics/get_case_metrics_connectors.ts | 5 +- .../user_actions/get_all_user_actions.ts | 12 +- 123 files changed, 5614 insertions(+), 817 deletions(-) create mode 100644 x-pack/plugins/cases/common/api/cases/alerts.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/assignee.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/case.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/comment/files.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/comment/index.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/configure.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/status.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/assignees.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/comment.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/common.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/connector.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/create_case.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/delete_case.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/description.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/operations/find.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/pushed.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/response.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/settings.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/severity.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/stats.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/status.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/tags.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_actions/title.test.ts create mode 100644 x-pack/plugins/cases/common/api/cases/user_profile.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/connector.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/get_connectors.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/jira.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/mappings.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/resilient.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/servicenow_itsm.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/servicenow_sir.test.ts create mode 100644 x-pack/plugins/cases/common/api/connectors/swimlane.test.ts create mode 100644 x-pack/plugins/cases/common/api/metrics/case.test.ts create mode 100644 x-pack/plugins/cases/common/api/runtime_types.test.ts create mode 100644 x-pack/plugins/cases/common/api/user.test.ts create mode 100644 x-pack/plugins/cases/server/client/attachments/add.test.ts create mode 100644 x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts create mode 100644 x-pack/plugins/cases/server/client/cases/get.test.ts create mode 100644 x-pack/plugins/cases/server/client/user_actions/find.test.ts diff --git a/x-pack/plugins/cases/common/api/cases/alerts.test.ts b/x-pack/plugins/cases/common/api/cases/alerts.test.ts new file mode 100644 index 0000000000000..eba7e4160724c --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/alerts.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { AlertResponseRt } from './alerts'; + +describe('Alerts', () => { + describe('AlertResponseRt', () => { + it('has expected attributes in request', () => { + const defaultRequest = [{ id: '1', index: '2', attached_at: '3' }]; + + const query = AlertResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('multiple attributes in request', () => { + const defaultRequest = [ + { id: '1', index: '2', attached_at: '3' }, + { id: '2', index: '3', attached_at: '4' }, + ]; + const query = AlertResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const defaultRequest = [{ id: '1', index: '2', attached_at: '3' }]; + const query = AlertResponseRt.decode([{ ...defaultRequest[0], foo: 'bar' }]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/alerts.ts b/x-pack/plugins/cases/common/api/cases/alerts.ts index 3647b1acb3a40..0a06e2cb9ab43 100644 --- a/x-pack/plugins/cases/common/api/cases/alerts.ts +++ b/x-pack/plugins/cases/common/api/cases/alerts.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -const AlertRt = rt.type({ +const AlertRt = rt.strict({ id: rt.string, index: rt.string, attached_at: rt.string, diff --git a/x-pack/plugins/cases/common/api/cases/assignee.test.ts b/x-pack/plugins/cases/common/api/cases/assignee.test.ts new file mode 100644 index 0000000000000..0b75e5b57bbdd --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/assignee.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { CaseAssigneesRt } from './assignee'; + +describe('Assignee', () => { + describe('CaseAssigneesRt', () => { + const defaultRequest = [{ uid: '1' }, { uid: '2' }]; + + it('has expected attributes in request', () => { + const query = CaseAssigneesRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseAssigneesRt.decode([{ ...defaultRequest[0], foo: 'bar' }]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: [defaultRequest[0]], + }); + }); + + it('removes foo:bar attributes from assignees', () => { + const query = CaseAssigneesRt.decode([{ uid: '1', foo: 'bar' }, { uid: '2' }]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: [{ uid: '1' }, { uid: '2' }], + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/case.test.ts b/x-pack/plugins/cases/common/api/cases/case.test.ts new file mode 100644 index 0000000000000..dfaa0c6e50861 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/case.test.ts @@ -0,0 +1,709 @@ +/* + * 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 { ConnectorTypes } from '../connectors'; +import { + RelatedCaseInfoRt, + SettingsRt, + CaseSeverity, + CaseFullExternalServiceRt, + CasesFindRequestRt, + CasesByAlertIDRequestRt, + CasePatchRequestRt, + CasesPatchRequestRt, + CasePushRequestParamsRt, + ExternalServiceResponseRt, + AllReportersFindRequestRt, + CasesBulkGetRequestRt, + CasesBulkGetResponseRt, + CasePostRequestRt, + CaseAttributesRt, + CasesRt, + CasesFindResponseRt, + CaseResolveResponseRt, +} from './case'; +import { CommentType } from './comment'; +import { CaseStatuses } from './status'; + +const basicCase = { + owner: 'cases', + closed_at: null, + closed_by: null, + id: 'basic-case-id', + comments: [ + { + comment: 'Solve this fast!', + type: CommentType.user, + id: 'basic-comment-id', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + owner: 'cases', + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + version: 'WzQ3LDFc', + }, + ], + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + description: 'Security banana Issue', + severity: CaseSeverity.LOW, + duration: null, + external_service: null, + status: CaseStatuses.open, + tags: ['coke', 'pepsi'], + title: 'Another horrible breach!!', + totalComment: 1, + totalAlerts: 0, + updated_at: '2020-02-20T15:02:57.995Z', + updated_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, + // damaged_raccoon uid + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], +}; + +describe('Case', () => { + describe('RelatedCaseInfoRt', () => { + const defaultRequest = { + id: 'basic-case-id', + title: 'basic-case-title', + description: 'this is a simple description', + status: CaseStatuses.open, + createdAt: '2023-01-17T09:46:29.813Z', + totals: { + alerts: 5, + userComments: 2, + }, + }; + it('has expected attributes in request', () => { + const query = RelatedCaseInfoRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = RelatedCaseInfoRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from totals', () => { + const query = RelatedCaseInfoRt.decode({ + ...defaultRequest, + totals: { ...defaultRequest.totals, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('SettingsRt', () => { + it('has expected attributes in request', () => { + const query = SettingsRt.decode({ syncAlerts: true }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { syncAlerts: true }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SettingsRt.decode({ syncAlerts: false, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { syncAlerts: false }, + }); + }); + }); + + describe('CaseFullExternalServiceRt', () => { + const defaultRequest = { + connector_id: 'servicenow-1', + connector_name: 'My SN connector', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', + pushed_at: '2023-01-17T09:46:29.813Z', + pushed_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + }; + it('has expected attributes in request', () => { + const query = CaseFullExternalServiceRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseFullExternalServiceRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from pushed_by', () => { + const query = CaseFullExternalServiceRt.decode({ + ...defaultRequest, + pushed_by: { ...defaultRequest.pushed_by, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CaseAttributesRt', () => { + const defaultRequest = { + description: 'A description', + status: CaseStatuses.open, + tags: ['new', 'case'], + title: 'My new case', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + settings: { + syncAlerts: true, + }, + owner: 'cases', + severity: CaseSeverity.LOW, + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + duration: null, + closed_at: null, + closed_by: null, + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + external_service: null, + updated_at: '2020-02-20T15:02:57.995Z', + updated_by: null, + }; + + it('has expected attributes in request', () => { + const query = CaseAttributesRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseAttributesRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from connector', () => { + const query = CaseAttributesRt.decode({ + ...defaultRequest, + connector: { ...defaultRequest.connector, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from created_by', () => { + const query = CaseAttributesRt.decode({ + ...defaultRequest, + created_by: { ...defaultRequest.created_by, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasePostRequestRt', () => { + const defaultRequest = { + description: 'A description', + tags: ['new', 'case'], + title: 'My new case', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + settings: { + syncAlerts: true, + }, + owner: 'cases', + severity: CaseSeverity.LOW, + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + }; + + it('has expected attributes in request', () => { + const query = CasePostRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasePostRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from connector', () => { + const query = CasePostRequestRt.decode({ + ...defaultRequest, + connector: { ...defaultRequest.connector, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesFindRequestRt', () => { + const defaultRequest = { + tags: ['new', 'case'], + status: CaseStatuses.open, + severity: CaseSeverity.LOW, + assignees: ['damaged_racoon'], + reporters: ['damaged_racoon'], + defaultSearchOperator: 'AND', + from: 'now', + page: '1', + perPage: '10', + search: 'search text', + searchFields: 'closed_by.username', + rootSearchFields: ['_id'], + to: '1w', + sortOrder: 'desc', + sortField: 'created_at', + owner: 'cases', + }; + + it('has expected attributes in request', () => { + const query = CasesFindRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, page: 1, perPage: 10 }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, page: 1, perPage: 10 }, + }); + }); + }); + + describe('CasesByAlertIDRequestRt', () => { + it('has expected attributes in request', () => { + const query = CasesByAlertIDRequestRt.decode({ owner: 'cases' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { owner: 'cases' }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesByAlertIDRequestRt.decode({ owner: ['cases'], foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { owner: ['cases'] }, + }); + }); + }); + + describe('CaseResolveResponseRt', () => { + const defaultRequest = { + case: { ...basicCase }, + outcome: 'exactMatch', + alias_target_id: 'sample-target-id', + alias_purpose: 'savedObjectConversion', + }; + + it('has expected attributes in request', () => { + const query = CaseResolveResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseResolveResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesFindResponseRt', () => { + const defaultRequest = { + cases: [{ ...basicCase }], + page: 1, + per_page: 10, + total: 20, + count_open_cases: 10, + count_in_progress_cases: 5, + count_closed_cases: 5, + }; + + it('has expected attributes in request', () => { + const query = CasesFindResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesFindResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from cases', () => { + const query = CasesFindResponseRt.decode({ + ...defaultRequest, + cases: [{ ...basicCase, foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasePatchRequestRt', () => { + const defaultRequest = { + id: 'basic-case-id', + version: 'WzQ3LDFd', + description: 'Updated description', + }; + + it('has expected attributes in request', () => { + const query = CasePatchRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasePatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesPatchRequestRt', () => { + const defaultRequest = { + cases: [ + { + id: 'basic-case-id', + version: 'WzQ3LDFd', + description: 'Updated description', + }, + ], + }; + + it('has expected attributes in request', () => { + const query = CasesPatchRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesRt', () => { + const defaultRequest = [ + { + ...basicCase, + }, + ]; + + it('has expected attributes in request', () => { + const query = CasesRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesRt.decode([{ ...defaultRequest[0], foo: 'bar' }]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasePushRequestParamsRt', () => { + const defaultRequest = { + case_id: 'basic-case-id', + connector_id: 'basic-connector-id', + }; + + it('has expected attributes in request', () => { + const query = CasePushRequestParamsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasePushRequestParamsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('ExternalServiceResponseRt', () => { + const defaultRequest = { + title: 'case_title', + id: 'basic-case-id', + pushedDate: '2020-02-19T23:06:33.798Z', + url: 'https://atlassian.com', + comments: [ + { + commentId: 'basic-comment-id', + pushedDate: '2020-02-19T23:06:33.798Z', + externalCommentId: 'external-comment-id', + }, + ], + }; + + it('has expected attributes in request', () => { + const query = ExternalServiceResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ExternalServiceResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from comments', () => { + const query = ExternalServiceResponseRt.decode({ + ...defaultRequest, + comments: [{ ...defaultRequest.comments[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('AllReportersFindRequestRt', () => { + const defaultRequest = { + owner: ['cases', 'security-solution'], + }; + + it('has expected attributes in request', () => { + const query = AllReportersFindRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = AllReportersFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesBulkGetRequestRt', () => { + const defaultRequest = { + ids: ['case-1', 'case-2'], + }; + + it('has expected attributes in request', () => { + const query = CasesBulkGetRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesBulkGetRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesBulkGetResponseRt', () => { + const defaultRequest = { + cases: [basicCase], + errors: [ + { + error: 'error', + message: 'error-message', + status: 403, + caseId: 'basic-case-id', + }, + ], + }; + + it('has expected attributes in request', () => { + const query = CasesBulkGetResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesBulkGetResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from cases', () => { + const query = CasesBulkGetResponseRt.decode({ + ...defaultRequest, + cases: [{ ...defaultRequest.cases[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, cases: defaultRequest.cases }, + }); + }); + + it('removes foo:bar attributes from errors', () => { + const query = CasesBulkGetResponseRt.decode({ + ...defaultRequest, + errors: [{ ...defaultRequest.errors[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index 2d0d7a2767129..ed873f90e68e2 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -14,12 +14,12 @@ import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt } from '../connectors/connector'; import { CaseAssigneesRt } from './assignee'; -export const AttachmentTotalsRt = rt.type({ +export const AttachmentTotalsRt = rt.strict({ alerts: rt.number, userComments: rt.number, }); -export const RelatedCaseInfoRt = rt.type({ +export const RelatedCaseInfoRt = rt.strict({ id: rt.string, title: rt.string, description: rt.string, @@ -30,7 +30,7 @@ export const RelatedCaseInfoRt = rt.type({ export const CasesByAlertIdRt = rt.array(RelatedCaseInfoRt); -export const SettingsRt = rt.type({ +export const SettingsRt = rt.strict({ syncAlerts: rt.boolean, }); @@ -48,7 +48,7 @@ export const CaseSeverityRt = rt.union([ rt.literal(CaseSeverity.CRITICAL), ]); -const CaseBasicRt = rt.type({ +const CaseBasicRt = rt.strict({ /** * The description of the case */ @@ -91,7 +91,7 @@ const CaseBasicRt = rt.type({ * This represents the push to service UserAction. It lacks the connector_id because that is stored in a different field * within the user action object in the API response. */ -export const CaseUserActionExternalServiceRt = rt.type({ +export const CaseUserActionExternalServiceRt = rt.strict({ connector_name: rt.string, external_id: rt.string, external_title: rt.string, @@ -101,7 +101,7 @@ export const CaseUserActionExternalServiceRt = rt.type({ }); export const CaseExternalServiceBasicRt = rt.intersection([ - rt.type({ + rt.strict({ connector_id: rt.string, }), CaseUserActionExternalServiceRt, @@ -111,7 +111,7 @@ export const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, r export const CaseAttributesRt = rt.intersection([ CaseBasicRt, - rt.type({ + rt.strict({ duration: rt.union([rt.number, rt.null]), closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRt, rt.null]), @@ -124,7 +124,7 @@ export const CaseAttributesRt = rt.intersection([ ]); export const CasePostRequestRt = rt.intersection([ - rt.type({ + rt.strict({ /** * Description of the case */ @@ -151,17 +151,19 @@ export const CasePostRequestRt = rt.intersection([ */ owner: rt.string, }), - rt.partial({ - /** - * The users assigned to the case - */ - assignees: CaseAssigneesRt, - /** - * The severity of the case. The severity is - * default it to "low" if not provided. - */ - severity: CaseSeverityRt, - }), + rt.exact( + rt.partial({ + /** + * The users assigned to the case + */ + assignees: CaseAssigneesRt, + /** + * The severity of the case. The severity is + * default it to "low" if not provided. + */ + severity: CaseSeverityRt, + }) + ), ]); const CasesFindRequestSearchFieldsRt = rt.keyof({ @@ -192,116 +194,127 @@ const CasesFindRequestSearchFieldsRt = rt.keyof({ 'updated_by.profile_uid': null, }); -export const CasesFindRequestRt = rt.partial({ - /** - * Tags to filter by - */ - tags: rt.union([rt.array(rt.string), rt.string]), - /** - * The status of the case (open, closed, in-progress) - */ - status: CaseStatusRt, - /** - * The severity of the case - */ - severity: CaseSeverityRt, - /** - * The uids of the user profiles to filter by - */ - assignees: rt.union([rt.array(rt.string), rt.string]), - /** - * The reporters to filter by - */ - reporters: rt.union([rt.array(rt.string), rt.string]), - /** - * Operator to use for the `search` field - */ - defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), - /** - * A KQL date. If used all cases created after (gte) the from date will be returned - */ - from: rt.string, - /** - * The page of objects to return - */ - page: NumberFromString, - /** - * The number of objects to include in each page - */ - perPage: NumberFromString, - /** - * An Elasticsearch simple_query_string - */ - search: rt.string, - /** - * The fields to perform the simple_query_string parsed query against - */ - searchFields: rt.union([ - rt.array(CasesFindRequestSearchFieldsRt), - CasesFindRequestSearchFieldsRt, - ]), - /** - * The root fields to perform the simple_query_string parsed query against - */ - rootSearchFields: rt.array(rt.string), - /** - * The field to use for sorting the found objects. - * - * This only supports, `create_at`, `closed_at`, and `status` - */ - sortField: rt.string, - /** - * The order to sort by - */ - sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +export const CasesFindRequestRt = rt.exact( + rt.partial({ + /** + * Tags to filter by + */ + tags: rt.union([rt.array(rt.string), rt.string]), + /** + * The status of the case (open, closed, in-progress) + */ + status: CaseStatusRt, + /** + * The severity of the case + */ + severity: CaseSeverityRt, + /** + * The uids of the user profiles to filter by + */ + assignees: rt.union([rt.array(rt.string), rt.string]), + /** + * The reporters to filter by + */ + reporters: rt.union([rt.array(rt.string), rt.string]), + /** + * Operator to use for the `search` field + */ + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + /** + * A KQL date. If used all cases created after (gte) the from date will be returned + */ + from: rt.string, + /** + * The page of objects to return + */ + page: NumberFromString, + /** + * The number of objects to include in each page + */ + perPage: NumberFromString, + /** + * An Elasticsearch simple_query_string + */ + search: rt.string, + /** + * The fields to perform the simple_query_string parsed query against + */ + searchFields: rt.union([ + rt.array(CasesFindRequestSearchFieldsRt), + CasesFindRequestSearchFieldsRt, + ]), + /** + * The root fields to perform the simple_query_string parsed query against + */ + rootSearchFields: rt.array(rt.string), + /** + * The field to use for sorting the found objects. + * + * This only supports, `create_at`, `closed_at`, and `status` + */ + sortField: rt.string, + /** + * The order to sort by + */ + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), - /** - * A KQL date. If used all cases created before (lte) the to date will be returned. - */ - to: rt.string, - /** - * The owner(s) to filter by. The user making the request must have privileges to retrieve cases of that - * ownership or they will be ignored. If no owner is included, then all ownership types will be included in the response - * that the user has access to. - */ + /** + * A KQL date. If used all cases created before (lte) the to date will be returned. + */ + to: rt.string, + /** + * The owner(s) to filter by. The user making the request must have privileges to retrieve cases of that + * ownership or they will be ignored. If no owner is included, then all ownership types will be included in the response + * that the user has access to. + */ - owner: rt.union([rt.array(rt.string), rt.string]), -}); + owner: rt.union([rt.array(rt.string), rt.string]), + }) +); -export const CasesByAlertIDRequestRt = rt.partial({ - /** - * The type of cases to retrieve given an alert ID. If no owner is provided, all cases - * that the user has access to will be returned. - */ - owner: rt.union([rt.array(rt.string), rt.string]), -}); +export const CasesByAlertIDRequestRt = rt.exact( + rt.partial({ + /** + * The type of cases to retrieve given an alert ID. If no owner is provided, all cases + * that the user has access to will be returned. + */ + owner: rt.union([rt.array(rt.string), rt.string]), + }) +); export const CaseRt = rt.intersection([ CaseAttributesRt, - rt.type({ + rt.strict({ id: rt.string, totalComment: rt.number, totalAlerts: rt.number, version: rt.string, }), - rt.partial({ - comments: rt.array(CommentRt), - }), + rt.exact( + rt.partial({ + comments: rt.array(CommentRt), + }) + ), ]); export const CaseResolveResponseRt = rt.intersection([ - rt.type({ + rt.strict({ case: CaseRt, outcome: rt.union([rt.literal('exactMatch'), rt.literal('aliasMatch'), rt.literal('conflict')]), }), - rt.partial({ - alias_target_id: rt.string, - alias_purpose: rt.union([rt.literal('savedObjectConversion'), rt.literal('savedObjectImport')]), - }), + rt.exact( + rt.partial({ + alias_target_id: rt.string, + alias_purpose: rt.union([ + rt.literal('savedObjectConversion'), + rt.literal('savedObjectImport'), + ]), + }) + ), ]); export const CasesFindResponseRt = rt.intersection([ - rt.type({ + rt.strict({ cases: rt.array(CaseRt), page: rt.number, per_page: rt.number, @@ -311,59 +324,63 @@ export const CasesFindResponseRt = rt.intersection([ ]); export const CasePatchRequestRt = rt.intersection([ - rt.partial(CaseBasicRt.props), + rt.exact(rt.partial(CaseBasicRt.type.props)), /** * The saved object ID and version */ - rt.type({ id: rt.string, version: rt.string }), + rt.strict({ id: rt.string, version: rt.string }), ]); -export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); +export const CasesPatchRequestRt = rt.strict({ cases: rt.array(CasePatchRequestRt) }); export const CasesRt = rt.array(CaseRt); -export const CasePushRequestParamsRt = rt.type({ +export const CasePushRequestParamsRt = rt.strict({ case_id: rt.string, connector_id: rt.string, }); export const ExternalServiceResponseRt = rt.intersection([ - rt.type({ + rt.strict({ title: rt.string, id: rt.string, pushedDate: rt.string, url: rt.string, }), - rt.partial({ - comments: rt.array( - rt.intersection([ - rt.type({ - commentId: rt.string, - pushedDate: rt.string, - }), - rt.partial({ externalCommentId: rt.string }), - ]) - ), - }), + rt.exact( + rt.partial({ + comments: rt.array( + rt.intersection([ + rt.strict({ + commentId: rt.string, + pushedDate: rt.string, + }), + rt.exact(rt.partial({ externalCommentId: rt.string })), + ]) + ), + }) + ), ]); -export const AllTagsFindRequestRt = rt.partial({ - /** - * The owner of the cases to retrieve the tags from. If no owner is provided the tags from all cases - * that the user has access to will be returned. - */ - owner: rt.union([rt.array(rt.string), rt.string]), -}); +export const AllTagsFindRequestRt = rt.exact( + rt.partial({ + /** + * The owner of the cases to retrieve the tags from. If no owner is provided the tags from all cases + * that the user has access to will be returned. + */ + owner: rt.union([rt.array(rt.string), rt.string]), + }) +); export const AllReportersFindRequestRt = AllTagsFindRequestRt; -export const CasesBulkGetRequestRt = rt.type({ +export const CasesBulkGetRequestRt = rt.strict({ ids: rt.array(rt.string), }); -export const CasesBulkGetResponseRt = rt.type({ +export const CasesBulkGetResponseRt = rt.strict({ cases: CasesRt, errors: rt.array( - rt.type({ + rt.strict({ error: rt.string, message: rt.string, status: rt.union([rt.undefined, rt.number]), diff --git a/x-pack/plugins/cases/common/api/cases/comment/files.test.ts b/x-pack/plugins/cases/common/api/cases/comment/files.test.ts new file mode 100644 index 0000000000000..e30496cf5cb42 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/comment/files.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { + SingleFileAttachmentMetadataRt, + FileAttachmentMetadataRt, + BulkDeleteFileAttachmentsRequestRt, +} from './files'; + +describe('Files', () => { + describe('SingleFileAttachmentMetadataRt', () => { + const defaultRequest = { + created: '2020-02-19T23:06:33.798Z', + extension: 'png', + mimeType: 'image/png', + name: 'my-super-cool-screenshot', + }; + + it('has expected attributes in request', () => { + const query = SingleFileAttachmentMetadataRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SingleFileAttachmentMetadataRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + describe('FileAttachmentMetadataRt', () => { + const defaultRequest = { + created: '2020-02-19T23:06:33.798Z', + extension: 'png', + mimeType: 'image/png', + name: 'my-super-cool-screenshot', + }; + + it('has expected attributes in request', () => { + const query = FileAttachmentMetadataRt.decode({ files: [defaultRequest] }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + files: [ + { + ...defaultRequest, + }, + ], + }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = FileAttachmentMetadataRt.decode({ files: [{ ...defaultRequest, foo: 'bar' }] }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + files: [ + { + ...defaultRequest, + }, + ], + }, + }); + }); + }); + describe('BulkDeleteFileAttachmentsRequestRt', () => { + it('has expected attributes in request', () => { + const query = BulkDeleteFileAttachmentsRequestRt.decode({ ids: ['abc', 'xyz'] }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ids: ['abc', 'xyz'] }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = BulkDeleteFileAttachmentsRequestRt.decode({ ids: ['abc', 'xyz'], foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ids: ['abc', 'xyz'] }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/comment/files.ts b/x-pack/plugins/cases/common/api/cases/comment/files.ts index af42a7a779e55..5ac9a94f4118a 100644 --- a/x-pack/plugins/cases/common/api/cases/comment/files.ts +++ b/x-pack/plugins/cases/common/api/cases/comment/files.ts @@ -9,14 +9,14 @@ import * as rt from 'io-ts'; import { MAX_DELETE_FILES } from '../../../constants'; import { limitedArraySchema, NonEmptyString } from '../../../schema'; -export const SingleFileAttachmentMetadataRt = rt.type({ +export const SingleFileAttachmentMetadataRt = rt.strict({ name: rt.string, extension: rt.string, mimeType: rt.string, created: rt.string, }); -export const FileAttachmentMetadataRt = rt.type({ +export const FileAttachmentMetadataRt = rt.strict({ files: rt.array(SingleFileAttachmentMetadataRt), }); @@ -26,7 +26,7 @@ export const FILE_ATTACHMENT_TYPE = '.files'; const MIN_DELETE_IDS = 1; -export const BulkDeleteFileAttachmentsRequestRt = rt.type({ +export const BulkDeleteFileAttachmentsRequestRt = rt.strict({ ids: limitedArraySchema(NonEmptyString, MIN_DELETE_IDS, MAX_DELETE_FILES), }); diff --git a/x-pack/plugins/cases/common/api/cases/comment/index.test.ts b/x-pack/plugins/cases/common/api/cases/comment/index.test.ts new file mode 100644 index 0000000000000..5e3a56fc1b077 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/comment/index.test.ts @@ -0,0 +1,905 @@ +/* + * 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 { + CommentAttributesBasicRt, + CommentType, + ContextTypeUserRt, + AlertCommentRequestRt, + ActionsCommentRequestRt, + ExternalReferenceStorageType, + ExternalReferenceRt, + PersistableStateAttachmentRt, + CommentRequestRt, + CommentRt, + CommentResponseTypeUserRt, + CommentResponseTypeAlertsRt, + CommentResponseTypeActionsRt, + CommentResponseTypeExternalReferenceRt, + CommentResponseTypePersistableStateRt, + CommentPatchRequestRt, + CommentPatchAttributesRt, + CommentsFindResponseRt, + FindCommentsQueryParamsRt, + BulkCreateCommentRequestRt, + BulkGetAttachmentsRequestRt, + BulkGetAttachmentsResponseRt, + FindCommentsArgsRt, +} from '.'; + +describe('Comments', () => { + describe('CommentAttributesBasicRt', () => { + const defaultRequest = { + created_at: '2019-11-25T22:32:30.608Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + owner: 'cases', + updated_at: null, + updated_by: null, + pushed_at: null, + pushed_by: null, + }; + + it('has expected attributes in request', () => { + const query = CommentAttributesBasicRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentAttributesBasicRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('ContextTypeUserRt', () => { + const defaultRequest = { + comment: 'This is a sample comment', + type: CommentType.user, + owner: 'cases', + }; + + it('has expected attributes in request', () => { + const query = ContextTypeUserRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ContextTypeUserRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('AlertCommentRequestRt', () => { + const defaultRequest = { + alertId: 'alert-id-1', + index: 'alert-index-1', + type: CommentType.alert, + owner: 'cases', + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, + }; + + it('has expected attributes in request', () => { + const query = AlertCommentRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = AlertCommentRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from rule', () => { + const query = AlertCommentRequestRt.decode({ + ...defaultRequest, + rule: { id: 'rule-id-1', name: 'Awesome rule', foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('ActionsCommentRequestRt', () => { + const defaultRequest = { + type: CommentType.actions, + comment: 'I just isolated the host!', + actions: { + targets: [ + { + hostname: 'host1', + endpointId: '001', + }, + ], + type: 'isolate', + }, + owner: 'cases', + }; + + it('has expected attributes in request', () => { + const query = ActionsCommentRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ActionsCommentRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from actions', () => { + const query = ActionsCommentRequestRt.decode({ + ...defaultRequest, + actions: { + targets: [ + { + hostname: 'host1', + endpointId: '001', + }, + ], + type: 'isolate', + foo: 'bar', + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from targets', () => { + const query = ActionsCommentRequestRt.decode({ + ...defaultRequest, + actions: { + targets: [ + { + hostname: 'host1', + endpointId: '001', + foo: 'bar', + }, + ], + type: 'isolate', + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('ExternalReferenceRt', () => { + const defaultRequest = { + type: CommentType.externalReference, + externalReferenceId: 'my-id', + externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc }, + externalReferenceAttachmentTypeId: '.test', + externalReferenceMetadata: { test_foo: 'foo' }, + owner: 'cases', + }; + it('has expected attributes in request', () => { + const query = ExternalReferenceRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ExternalReferenceRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from externalReferenceStorage', () => { + const query = ExternalReferenceRt.decode({ + ...defaultRequest, + externalReferenceStorage: { + type: ExternalReferenceStorageType.elasticSearchDoc, + foo: 'bar', + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from externalReferenceStorage with soType', () => { + const query = ExternalReferenceRt.decode({ + ...defaultRequest, + externalReferenceStorage: { + type: ExternalReferenceStorageType.savedObject, + soType: 'awesome', + foo: 'bar', + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + ...defaultRequest, + externalReferenceStorage: { + type: ExternalReferenceStorageType.savedObject, + soType: 'awesome', + }, + }, + }); + }); + }); + + describe('PersistableStateAttachmentRt', () => { + const defaultRequest = { + type: CommentType.persistableState, + persistableStateAttachmentState: { test_foo: 'foo' }, + persistableStateAttachmentTypeId: '.test', + owner: 'cases', + }; + it('has expected attributes in request', () => { + const query = PersistableStateAttachmentRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = PersistableStateAttachmentRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from persistableStateAttachmentState', () => { + const query = PersistableStateAttachmentRt.decode({ + ...defaultRequest, + persistableStateAttachmentState: { test_foo: 'foo', foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + ...defaultRequest, + persistableStateAttachmentState: { test_foo: 'foo', foo: 'bar' }, + }, + }); + }); + }); + + describe('CommentRequestRt', () => { + const defaultRequest = { + comment: 'Solve this fast!', + type: CommentType.user, + owner: 'cases', + }; + it('has expected attributes in request', () => { + const query = CommentRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentRt', () => { + const defaultRequest = { + comment: 'Solve this fast!', + type: CommentType.user, + owner: 'cases', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + it('has expected attributes in request', () => { + const query = CommentRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentResponseTypeUserRt', () => { + const defaultRequest = { + comment: 'Solve this fast!', + type: CommentType.user, + owner: 'cases', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + it('has expected attributes in request', () => { + const query = CommentResponseTypeUserRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentResponseTypeUserRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentResponseTypeAlertsRt', () => { + const defaultRequest = { + alertId: 'alert-id-1', + index: 'alert-index-1', + type: CommentType.alert, + id: 'alert-comment-id', + owner: 'cases', + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, + version: 'WzQ3LDFc', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + it('has expected attributes in request', () => { + const query = CommentResponseTypeAlertsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentResponseTypeAlertsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from created_by', () => { + const query = CommentResponseTypeAlertsRt.decode({ + ...defaultRequest, + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + foo: 'bar', + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentResponseTypeActionsRt', () => { + const defaultRequest = { + type: CommentType.actions, + comment: 'I just isolated the host!', + actions: { + targets: [ + { + hostname: 'host1', + endpointId: '001', + }, + ], + type: 'isolate', + }, + owner: 'cases', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + it('has expected attributes in request', () => { + const query = CommentResponseTypeActionsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentResponseTypeActionsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentResponseTypeExternalReferenceRt', () => { + const defaultRequest = { + type: CommentType.externalReference, + externalReferenceId: 'my-id', + externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc }, + externalReferenceAttachmentTypeId: '.test', + externalReferenceMetadata: { test_foo: 'foo' }, + owner: 'cases', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + it('has expected attributes in request', () => { + const query = CommentResponseTypeExternalReferenceRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentResponseTypeExternalReferenceRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentResponseTypePersistableStateRt', () => { + const defaultRequest = { + type: CommentType.persistableState, + persistableStateAttachmentState: { test_foo: 'foo' }, + persistableStateAttachmentTypeId: '.test', + owner: 'cases', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + it('has expected attributes in request', () => { + const query = CommentResponseTypePersistableStateRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentResponseTypePersistableStateRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentPatchRequestRt', () => { + const defaultRequest = { + alertId: 'alert-id-1', + index: 'alert-index-1', + type: CommentType.alert, + id: 'alert-comment-id', + owner: 'cases', + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, + version: 'WzQ3LDFc', + }; + it('has expected attributes in request', () => { + const query = CommentPatchRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentPatchAttributesRt', () => { + const defaultRequest = { + type: CommentType.actions, + actions: { + targets: [ + { + hostname: 'host1', + endpointId: '001', + }, + ], + type: 'isolate', + }, + owner: 'cases', + }; + it('has expected attributes in request', () => { + const query = CommentPatchAttributesRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentPatchAttributesRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CommentsFindResponseRt', () => { + const defaultRequest = { + comments: [ + { + comment: 'Solve this fast!', + type: CommentType.user, + owner: 'cases', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + id: 'basic-comment-id', + version: 'WzQ3LDFc', + }, + ], + page: 1, + per_page: 10, + total: 1, + }; + it('has expected attributes in request', () => { + const query = CommentsFindResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentsFindResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from comments', () => { + const query = CommentsFindResponseRt.decode({ + ...defaultRequest, + comments: [{ ...defaultRequest.comments[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('FindCommentsQueryParamsRt', () => { + const defaultRequest = { + page: 1, + perPage: 10, + sortOrder: 'asc', + }; + + it('has expected attributes in request', () => { + const query = FindCommentsQueryParamsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = FindCommentsQueryParamsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('FindCommentsArgsRt', () => { + const defaultRequest = { + caseID: 'basic-case-id', + queryParams: { + page: 1, + perPage: 10, + sortOrder: 'asc', + }, + }; + + it('has expected attributes in request', () => { + const query = FindCommentsArgsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = FindCommentsArgsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('BulkCreateCommentRequestRt', () => { + const defaultRequest = [ + { + comment: 'Solve this fast!', + type: CommentType.user, + owner: 'cases', + }, + ]; + + it('has expected attributes in request', () => { + const query = BulkCreateCommentRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = BulkCreateCommentRequestRt.decode([ + { comment: 'Solve this fast!', type: CommentType.user, owner: 'cases', foo: 'bar' }, + ]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('BulkGetAttachmentsRequestRt', () => { + it('has expected attributes in request', () => { + const query = BulkGetAttachmentsRequestRt.decode({ ids: ['abc', 'xyz'] }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ids: ['abc', 'xyz'] }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = BulkGetAttachmentsRequestRt.decode({ ids: ['abc', 'xyz'], foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ids: ['abc', 'xyz'] }, + }); + }); + }); + + describe('BulkGetAttachmentsResponseRt', () => { + const defaultRequest = { + attachments: [ + { + comment: 'Solve this fast!', + type: CommentType.user, + owner: 'cases', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }, + ], + errors: [ + { + error: 'error', + message: 'not found', + status: 404, + attachmentId: 'abc', + }, + ], + }; + + it('has expected attributes in request', () => { + const query = BulkGetAttachmentsResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = BulkGetAttachmentsResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from attachments', () => { + const query = BulkGetAttachmentsResponseRt.decode({ + ...defaultRequest, + attachments: [{ ...defaultRequest.attachments[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from errors', () => { + const query = BulkGetAttachmentsResponseRt.decode({ + ...defaultRequest, + errors: [{ ...defaultRequest.errors[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/comment/index.ts b/x-pack/plugins/cases/common/api/cases/comment/index.ts index 4e0e9c2f41f0e..44ca049745b09 100644 --- a/x-pack/plugins/cases/common/api/cases/comment/index.ts +++ b/x-pack/plugins/cases/common/api/cases/comment/index.ts @@ -13,7 +13,7 @@ import { UserRt } from '../../user'; export * from './files'; -export const CommentAttributesBasicRt = rt.type({ +export const CommentAttributesBasicRt = rt.strict({ created_at: rt.string, created_by: UserRt, owner: rt.string, @@ -36,7 +36,7 @@ export enum IsolateHostActionType { unisolate = 'unisolate', } -export const ContextTypeUserRt = rt.type({ +export const ContextTypeUserRt = rt.strict({ comment: rt.string, type: rt.literal(CommentType.user), owner: rt.string, @@ -47,23 +47,23 @@ export const ContextTypeUserRt = rt.type({ * represents of an alert after it has been transformed. A generated alert will be transformed by the connector so that * it matches this structure. User attached alerts do not need to be transformed. */ -export const AlertCommentRequestRt = rt.type({ +export const AlertCommentRequestRt = rt.strict({ type: rt.literal(CommentType.alert), alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.union([rt.array(rt.string), rt.string]), - rule: rt.type({ + rule: rt.strict({ id: rt.union([rt.string, rt.null]), name: rt.union([rt.string, rt.null]), }), owner: rt.string, }); -export const ActionsCommentRequestRt = rt.type({ +export const ActionsCommentRequestRt = rt.strict({ type: rt.literal(CommentType.actions), comment: rt.string, - actions: rt.type({ + actions: rt.strict({ targets: rt.array( - rt.type({ + rt.strict({ hostname: rt.string, endpointId: rt.string, }) @@ -78,37 +78,37 @@ export enum ExternalReferenceStorageType { elasticSearchDoc = 'elasticSearchDoc', } -const ExternalReferenceStorageNoSORt = rt.type({ +const ExternalReferenceStorageNoSORt = rt.strict({ type: rt.literal(ExternalReferenceStorageType.elasticSearchDoc), }); -const ExternalReferenceStorageSORt = rt.type({ +const ExternalReferenceStorageSORt = rt.strict({ type: rt.literal(ExternalReferenceStorageType.savedObject), soType: rt.string, }); -export const ExternalReferenceBaseRt = rt.type({ +export const ExternalReferenceBaseRt = rt.strict({ externalReferenceAttachmentTypeId: rt.string, externalReferenceMetadata: rt.union([rt.null, rt.record(rt.string, jsonValueRt)]), type: rt.literal(CommentType.externalReference), owner: rt.string, }); -export const ExternalReferenceNoSORt = rt.type({ - ...ExternalReferenceBaseRt.props, +export const ExternalReferenceNoSORt = rt.strict({ + ...ExternalReferenceBaseRt.type.props, externalReferenceId: rt.string, externalReferenceStorage: ExternalReferenceStorageNoSORt, }); -export const ExternalReferenceSORt = rt.type({ - ...ExternalReferenceBaseRt.props, +export const ExternalReferenceSORt = rt.strict({ + ...ExternalReferenceBaseRt.type.props, externalReferenceId: rt.string, externalReferenceStorage: ExternalReferenceStorageSORt, }); // externalReferenceId is missing. -export const ExternalReferenceSOWithoutRefsRt = rt.type({ - ...ExternalReferenceBaseRt.props, +export const ExternalReferenceSOWithoutRefsRt = rt.strict({ + ...ExternalReferenceBaseRt.type.props, externalReferenceStorage: ExternalReferenceStorageSORt, }); @@ -118,7 +118,7 @@ export const ExternalReferenceWithoutRefsRt = rt.union([ ExternalReferenceSOWithoutRefsRt, ]); -export const PersistableStateAttachmentRt = rt.type({ +export const PersistableStateAttachmentRt = rt.strict({ type: rt.literal(CommentType.persistableState), owner: rt.string, persistableStateAttachmentTypeId: rt.string, @@ -187,7 +187,7 @@ export const CommentRequestRt = rt.union([ export const CommentRt = rt.intersection([ CommentAttributesRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, }), @@ -195,7 +195,7 @@ export const CommentRt = rt.intersection([ export const CommentResponseTypeUserRt = rt.intersection([ AttributesTypeUserRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, }), @@ -203,7 +203,7 @@ export const CommentResponseTypeUserRt = rt.intersection([ export const CommentResponseTypeAlertsRt = rt.intersection([ AttributesTypeAlertsRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, }), @@ -211,7 +211,7 @@ export const CommentResponseTypeAlertsRt = rt.intersection([ export const CommentResponseTypeActionsRt = rt.intersection([ AttributesTypeActionsRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, }), @@ -219,7 +219,7 @@ export const CommentResponseTypeActionsRt = rt.intersection([ export const CommentResponseTypeExternalReferenceRt = rt.intersection([ AttributesTypeExternalReferenceRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, }), @@ -227,7 +227,7 @@ export const CommentResponseTypeExternalReferenceRt = rt.intersection([ export const CommentResponseTypePersistableStateRt = rt.intersection([ AttributesTypePersistableStateRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, }), @@ -243,7 +243,7 @@ export const CommentPatchRequestRt = rt.intersection([ * persistableStateAttachmentState on a patch. */ CommentRequestRt, - rt.type({ id: rt.string, version: rt.string }), + rt.strict({ id: rt.string, version: rt.string }), ]); /** @@ -254,17 +254,17 @@ export const CommentPatchRequestRt = rt.intersection([ */ export const CommentPatchAttributesRt = rt.intersection([ rt.union([ - rt.partial(ContextTypeUserRt.props), - rt.partial(AlertCommentRequestRt.props), - rt.partial(ActionsCommentRequestRt.props), - rt.partial(ExternalReferenceNoSORt.props), - rt.partial(ExternalReferenceSORt.props), - rt.partial(PersistableStateAttachmentRt.props), + rt.exact(rt.partial(ContextTypeUserRt.type.props)), + rt.exact(rt.partial(AlertCommentRequestRt.type.props)), + rt.exact(rt.partial(ActionsCommentRequestRt.type.props)), + rt.exact(rt.partial(ExternalReferenceNoSORt.type.props)), + rt.exact(rt.partial(ExternalReferenceSORt.type.props)), + rt.exact(rt.partial(PersistableStateAttachmentRt.type.props)), ]), - rt.partial(CommentAttributesBasicRt.props), + rt.exact(rt.partial(CommentAttributesBasicRt.type.props)), ]); -export const CommentsFindResponseRt = rt.type({ +export const CommentsFindResponseRt = rt.strict({ comments: rt.array(CommentRt), page: rt.number, per_page: rt.number, @@ -273,38 +273,40 @@ export const CommentsFindResponseRt = rt.type({ export const CommentsRt = rt.array(CommentRt); -export const FindCommentsQueryParamsRt = rt.partial({ - /** - * The page of objects to return - */ - page: rt.union([rt.number, NumberFromString]), - /** - * The number of objects to return for a page - */ - perPage: rt.union([rt.number, NumberFromString]), - /** - * Order to sort the response - */ - sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), -}); +export const FindCommentsQueryParamsRt = rt.exact( + rt.partial({ + /** + * The page of objects to return + */ + page: rt.union([rt.number, NumberFromString]), + /** + * The number of objects to return for a page + */ + perPage: rt.union([rt.number, NumberFromString]), + /** + * Order to sort the response + */ + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), + }) +); export const FindCommentsArgsRt = rt.intersection([ - rt.type({ + rt.strict({ caseID: rt.string, }), - rt.type({ queryParams: rt.union([FindCommentsQueryParamsRt, rt.undefined]) }), + rt.strict({ queryParams: rt.union([FindCommentsQueryParamsRt, rt.undefined]) }), ]); export const BulkCreateCommentRequestRt = rt.array(CommentRequestRt); -export const BulkGetAttachmentsRequestRt = rt.type({ +export const BulkGetAttachmentsRequestRt = rt.strict({ ids: rt.array(rt.string), }); -export const BulkGetAttachmentsResponseRt = rt.type({ +export const BulkGetAttachmentsResponseRt = rt.strict({ attachments: CommentsRt, errors: rt.array( - rt.type({ + rt.strict({ error: rt.string, message: rt.string, status: rt.union([rt.undefined, rt.number]), diff --git a/x-pack/plugins/cases/common/api/cases/configure.test.ts b/x-pack/plugins/cases/common/api/cases/configure.test.ts new file mode 100644 index 0000000000000..f1b462bd97ace --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/configure.test.ts @@ -0,0 +1,226 @@ +/* + * 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 { ConnectorTypes } from '../connectors'; +import { + ConfigurationAttributesRt, + CaseConfigureRequestParamsRt, + ConfigurationRt, + ConfigurationPatchRequestRt, + ConfigurationRequestRt, + GetConfigurationFindRequestRt, +} from './configure'; + +describe('configure', () => { + const serviceNow = { + id: 'servicenow-1', + name: 'SN 1', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }; + + const resilient = { + id: 'resilient-2', + name: 'Resilient', + type: ConnectorTypes.resilient, + fields: null, + }; + + describe('ConfigurationRequestRt', () => { + const defaultRequest = { + connector: serviceNow, + closure_type: 'close-by-user', + owner: 'Cases', + }; + + it('has expected attributes in request', () => { + const query = ConfigurationRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ConfigurationRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('ConfigurationPatchRequestRt', () => { + const defaultRequest = { + connector: serviceNow, + closure_type: 'close-by-user', + version: 'WzQ3LDFd', + }; + + it('has expected attributes in request', () => { + const query = ConfigurationPatchRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('ConfigurationAttributesRt', () => { + const defaultRequest = { + connector: resilient, + closure_type: 'close-by-user', + owner: 'cases', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + updated_at: '2020-02-19T23:06:33.798Z', + updated_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + }; + + it('has expected attributes in request', () => { + const query = ConfigurationAttributesRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ConfigurationAttributesRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('ConfigurationRt', () => { + const defaultRequest = { + connector: serviceNow, + closure_type: 'close-by-user', + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + updated_at: '2020-02-19T23:06:33.798Z', + updated_by: null, + mappings: [ + { + source: 'description', + target: 'description', + action_type: 'overwrite', + }, + ], + owner: 'cases', + version: 'WzQ3LDFd', + id: 'case-id', + error: null, + }; + + it('has expected attributes in request', () => { + const query = ConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from mappings', () => { + const query = ConfigurationRt.decode({ + ...defaultRequest, + mappings: [{ ...defaultRequest.mappings[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('GetConfigurationFindRequestRt', () => { + const defaultRequest = { + owner: ['cases'], + }; + + it('has expected attributes in request', () => { + const query = GetConfigurationFindRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = GetConfigurationFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CaseConfigureRequestParamsRt', () => { + const defaultRequest = { + configuration_id: 'basic-configuration-id', + }; + + it('has expected attributes in request', () => { + const query = CaseConfigureRequestParamsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseConfigureRequestParamsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/configure.ts b/x-pack/plugins/cases/common/api/cases/configure.ts index 6b79a478f6b7b..356dabdf89a7f 100644 --- a/x-pack/plugins/cases/common/api/cases/configure.ts +++ b/x-pack/plugins/cases/common/api/cases/configure.ts @@ -8,12 +8,11 @@ import * as rt from 'io-ts'; import { CaseConnectorRt } from '../connectors/connector'; import { ConnectorMappingsRt } from '../connectors/mappings'; - import { UserRt } from '../user'; const ClosureTypeRt = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); -export const ConfigurationBasicWithoutOwnerRt = rt.type({ +export const ConfigurationBasicWithoutOwnerRt = rt.strict({ /** * The external connector */ @@ -26,7 +25,7 @@ export const ConfigurationBasicWithoutOwnerRt = rt.type({ const CasesConfigureBasicRt = rt.intersection([ ConfigurationBasicWithoutOwnerRt, - rt.type({ + rt.strict({ /** * The plugin owner that manages this configuration */ @@ -36,11 +35,11 @@ const CasesConfigureBasicRt = rt.intersection([ export const ConfigurationRequestRt = CasesConfigureBasicRt; export const ConfigurationPatchRequestRt = rt.intersection([ - rt.partial(ConfigurationBasicWithoutOwnerRt.props), - rt.type({ version: rt.string }), + rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.type.props)), + rt.strict({ version: rt.string }), ]); -export const ConfigurationActivityFieldsRt = rt.type({ +export const ConfigurationActivityFieldsRt = rt.strict({ created_at: rt.string, created_by: UserRt, updated_at: rt.union([rt.string, rt.null]), @@ -55,7 +54,7 @@ export const ConfigurationAttributesRt = rt.intersection([ export const ConfigurationRt = rt.intersection([ ConfigurationAttributesRt, ConnectorMappingsRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, error: rt.union([rt.string, rt.null]), @@ -63,15 +62,17 @@ export const ConfigurationRt = rt.intersection([ }), ]); -export const GetConfigurationFindRequestRt = rt.partial({ - /** - * The configuration plugin owner to filter the search by. If this is left empty the results will include all configurations - * that the user has permissions to access - */ - owner: rt.union([rt.array(rt.string), rt.string]), -}); +export const GetConfigurationFindRequestRt = rt.exact( + rt.partial({ + /** + * The configuration plugin owner to filter the search by. If this is left empty the results will include all configurations + * that the user has permissions to access + */ + owner: rt.union([rt.array(rt.string), rt.string]), + }) +); -export const CaseConfigureRequestParamsRt = rt.type({ +export const CaseConfigureRequestParamsRt = rt.strict({ configuration_id: rt.string, }); diff --git a/x-pack/plugins/cases/common/api/cases/status.test.ts b/x-pack/plugins/cases/common/api/cases/status.test.ts new file mode 100644 index 0000000000000..d46c0a0d7b1f4 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/status.test.ts @@ -0,0 +1,62 @@ +/* + * 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 { CasesStatusRequestRt, CasesStatusResponseRt } from './status'; + +describe('status', () => { + describe('CasesStatusRequestRt', () => { + const defaultRequest = { + from: '2022-04-28T15:18:00.000Z', + to: '2022-04-28T15:22:00.000Z', + owner: 'cases', + }; + + it('has expected attributes in request', () => { + const query = CasesStatusRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('has removes foo:bar attributes from request', () => { + const query = CasesStatusRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesStatusResponseRt', () => { + const defaultResponse = { + count_closed_cases: 1, + count_in_progress_cases: 2, + count_open_cases: 1, + }; + + it('has expected attributes in response', () => { + const query = CasesStatusResponseRt.decode(defaultResponse); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultResponse, + }); + }); + + it('removes foo:bar attributes from response', () => { + const query = CasesStatusResponseRt.decode({ ...defaultResponse, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultResponse, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/status.ts b/x-pack/plugins/cases/common/api/cases/status.ts index 0f06712e7f925..e1d342e0d6dad 100644 --- a/x-pack/plugins/cases/common/api/cases/status.ts +++ b/x-pack/plugins/cases/common/api/cases/status.ts @@ -18,27 +18,29 @@ export const CaseStatusRt = rt.union([ export const caseStatuses = Object.values(CaseStatuses); -export const CasesStatusResponseRt = rt.type({ +export const CasesStatusResponseRt = rt.strict({ count_open_cases: rt.number, count_in_progress_cases: rt.number, count_closed_cases: rt.number, }); -export const CasesStatusRequestRt = rt.partial({ - /** - * A KQL date. If used all cases created after (gte) the from date will be returned - */ - from: rt.string, - /** - * A KQL date. If used all cases created before (lte) the to date will be returned. - */ - to: rt.string, - /** - * The owner of the cases to retrieve the status stats from. If no owner is provided the stats for all cases - * that the user has access to will be returned. - */ - owner: rt.union([rt.array(rt.string), rt.string]), -}); +export const CasesStatusRequestRt = rt.exact( + rt.partial({ + /** + * A KQL date. If used all cases created after (gte) the from date will be returned + */ + from: rt.string, + /** + * A KQL date. If used all cases created before (lte) the to date will be returned. + */ + to: rt.string, + /** + * The owner of the cases to retrieve the status stats from. If no owner is provided the stats for all cases + * that the user has access to will be returned. + */ + owner: rt.union([rt.array(rt.string), rt.string]), + }) +); export type CasesStatusResponse = rt.TypeOf; export type CasesStatusRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/assignees.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/assignees.test.ts new file mode 100644 index 0000000000000..6f22deb22251f --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/assignees.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { AssigneesUserActionPayloadRt, AssigneesUserActionRt } from './assignees'; +import { ActionTypes } from './common'; + +describe('Assignees', () => { + describe('AssigneesUserActionPayloadRt', () => { + const defaultRequest = { + assignees: [{ uid: '1' }, { uid: '2' }, { uid: '3' }], + }; + + it('has expected attributes in request', () => { + const query = AssigneesUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = AssigneesUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + describe('AssigneesUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.assignees, + payload: { + assignees: [{ uid: '1' }, { uid: '2' }, { uid: '3' }], + }, + }; + + it('has expected attributes in request', () => { + const query = AssigneesUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = AssigneesUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from assignees', () => { + const query = AssigneesUserActionRt.decode({ + type: ActionTypes.assignees, + payload: { + assignees: [{ uid: '1', foo: 'bar' }, { uid: '2' }, { uid: '3' }], + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + type: ActionTypes.assignees, + payload: { + assignees: [{ uid: '1' }, { uid: '2' }, { uid: '3' }], + }, + }, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = AssigneesUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/assignees.ts b/x-pack/plugins/cases/common/api/cases/user_actions/assignees.ts index 4f218f5751960..4eed89ee7faef 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/assignees.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/assignees.ts @@ -10,9 +10,9 @@ import { CaseAssigneesRt } from '../assignee'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const AssigneesUserActionPayloadRt = rt.type({ assignees: CaseAssigneesRt }); +export const AssigneesUserActionPayloadRt = rt.strict({ assignees: CaseAssigneesRt }); -export const AssigneesUserActionRt = rt.type({ +export const AssigneesUserActionRt = rt.strict({ type: rt.literal(ActionTypes.assignees), payload: AssigneesUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/comment.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/comment.test.ts new file mode 100644 index 0000000000000..6c69b6c79e145 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/comment.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { CommentType } from '../comment'; +import { CommentUserActionPayloadRt, CommentUserActionRt } from './comment'; +import { ActionTypes } from './common'; + +describe('Comment', () => { + describe('CommentUserActionPayloadRt', () => { + const defaultRequest = { + comment: { + comment: 'this is a sample comment', + type: CommentType.user, + owner: 'cases', + }, + }; + + it('has expected attributes in request', () => { + const query = CommentUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from comment', () => { + const query = CommentUserActionPayloadRt.decode({ + comment: { ...defaultRequest.comment, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + describe('CommentUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.comment, + payload: { + comment: { + comment: 'this is a sample comment', + type: CommentType.user, + owner: 'cases', + }, + }, + }; + + it('has expected attributes in request', () => { + const query = CommentUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CommentUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = CommentUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/comment.ts b/x-pack/plugins/cases/common/api/cases/user_actions/comment.ts index 7826f450bd03b..dd3d84fc8e53e 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/comment.ts @@ -10,9 +10,9 @@ import { CommentRequestRt } from '../comment'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const CommentUserActionPayloadRt = rt.type({ comment: CommentRequestRt }); +export const CommentUserActionPayloadRt = rt.strict({ comment: CommentRequestRt }); -export const CommentUserActionRt = rt.type({ +export const CommentUserActionRt = rt.strict({ type: rt.literal(ActionTypes.comment), payload: CommentUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/common.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/common.test.ts new file mode 100644 index 0000000000000..7c72a9c812d11 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/common.test.ts @@ -0,0 +1,95 @@ +/* + * 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 { + UserActionCommonAttributesRt, + CaseUserActionInjectedIdsRt, + CaseUserActionInjectedDeprecatedIdsRt, +} from './common'; + +describe('Common', () => { + describe('UserActionCommonAttributesRt', () => { + const defaultRequest = { + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + owner: 'cases', + action: 'add', + }; + + it('has expected attributes in request', () => { + const query = UserActionCommonAttributesRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = UserActionCommonAttributesRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CaseUserActionInjectedIdsRt', () => { + const defaultRequest = { + comment_id: 'comment-id', + }; + + it('has expected attributes in request', () => { + const query = CaseUserActionInjectedIdsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseUserActionInjectedIdsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CaseUserActionInjectedDeprecatedIdsRt', () => { + const defaultRequest = { + action_id: 'basic-action-id', + case_id: 'basic-case-id', + comment_id: 'basic-comment-id', + }; + + it('has expected attributes in request', () => { + const query = CaseUserActionInjectedDeprecatedIdsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseUserActionInjectedDeprecatedIdsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/common.ts b/x-pack/plugins/cases/common/api/cases/user_actions/common.ts index fac254e33094a..47189e1c148c1 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/common.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/common.ts @@ -40,7 +40,7 @@ export const Actions = { export const ActionsRt = rt.keyof(Actions); -export const UserActionCommonAttributesRt = rt.type({ +export const UserActionCommonAttributesRt = rt.strict({ created_at: rt.string, created_by: UserRt, owner: rt.string, @@ -51,13 +51,13 @@ export const UserActionCommonAttributesRt = rt.type({ * This should only be used for the getAll route and it should be removed when the route is removed * @deprecated use CaseUserActionInjectedIdsRt instead */ -export const CaseUserActionInjectedDeprecatedIdsRt = rt.type({ +export const CaseUserActionInjectedDeprecatedIdsRt = rt.strict({ action_id: rt.string, case_id: rt.string, comment_id: rt.union([rt.string, rt.null]), }); -export const CaseUserActionInjectedIdsRt = rt.type({ +export const CaseUserActionInjectedIdsRt = rt.strict({ comment_id: rt.union([rt.string, rt.null]), }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/connector.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/connector.test.ts new file mode 100644 index 0000000000000..70eb7648df118 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/connector.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { ConnectorTypes } from '../../connectors'; +import { ConnectorUserActionPayloadWithoutConnectorIdRt } from './connector'; + +describe('Connector', () => { + describe('ConnectorUserActionPayloadWithoutConnectorIdRt', () => { + const defaultRequest = { + connector: { + name: 'My JIRA connector', + type: ConnectorTypes.jira, + fields: { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }, + }; + + it('has expected attributes in request', () => { + const query = ConnectorUserActionPayloadWithoutConnectorIdRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ConnectorUserActionPayloadWithoutConnectorIdRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from fields', () => { + const query = ConnectorUserActionPayloadWithoutConnectorIdRt.decode({ + ...defaultRequest, + fields: { ...defaultRequest.connector.fields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/connector.ts b/x-pack/plugins/cases/common/api/cases/user_actions/connector.ts index 521ac99721e02..956992c2daa96 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/connector.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/connector.ts @@ -6,24 +6,24 @@ */ import * as rt from 'io-ts'; -import { CaseUserActionConnectorRt, CaseConnectorRt } from '../../connectors'; +import { CaseUserActionConnectorRt, CaseConnectorRt } from '../../connectors/connector'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const ConnectorUserActionPayloadWithoutConnectorIdRt = rt.type({ +export const ConnectorUserActionPayloadWithoutConnectorIdRt = rt.strict({ connector: CaseUserActionConnectorRt, }); -export const ConnectorUserActionPayloadRt = rt.type({ +export const ConnectorUserActionPayloadRt = rt.strict({ connector: CaseConnectorRt, }); -export const ConnectorUserActionWithoutConnectorIdRt = rt.type({ +export const ConnectorUserActionWithoutConnectorIdRt = rt.strict({ type: rt.literal(ActionTypes.connector), payload: ConnectorUserActionPayloadWithoutConnectorIdRt, }); -export const ConnectorUserActionRt = rt.type({ +export const ConnectorUserActionRt = rt.strict({ type: rt.literal(ActionTypes.connector), payload: ConnectorUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/create_case.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/create_case.test.ts new file mode 100644 index 0000000000000..080b0d2fb6a10 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/create_case.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorTypes } from '../../connectors'; +import { ActionTypes } from './common'; +import { CreateCaseUserActionRt, CreateCaseUserActionWithoutConnectorIdRt } from './create_case'; + +describe('Create case', () => { + describe('CreateCaseUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.create_case, + payload: { + connector: { + id: 'jira-connector-id', + type: ConnectorTypes.jira, + name: 'jira-connector', + fields: { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }, + assignees: [{ uid: '1' }], + description: 'sample description', + status: 'open', + severity: 'low', + tags: ['one'], + title: 'sample title', + settings: { + syncAlerts: false, + }, + owner: 'cases', + }, + }; + + it('has expected attributes in request', () => { + const query = CreateCaseUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CreateCaseUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = CreateCaseUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CreateCaseUserActionWithoutConnectorIdRt', () => { + const defaultRequest = { + type: ActionTypes.create_case, + payload: { + connector: { + type: ConnectorTypes.jira, + name: 'jira-connector', + fields: { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }, + assignees: [{ uid: '1' }], + description: 'sample description', + status: 'open', + severity: 'low', + tags: ['one'], + title: 'sample title', + settings: { + syncAlerts: false, + }, + owner: 'cases', + }, + }; + + it('has expected attributes in request', () => { + const query = CreateCaseUserActionWithoutConnectorIdRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CreateCaseUserActionWithoutConnectorIdRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = CreateCaseUserActionWithoutConnectorIdRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/create_case.ts b/x-pack/plugins/cases/common/api/cases/user_actions/create_case.ts index 60aa8132ff413..94b303190dae0 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/create_case.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/create_case.ts @@ -18,31 +18,31 @@ import { SettingsUserActionPayloadRt } from './settings'; import { TagsUserActionPayloadRt } from './tags'; import { TitleUserActionPayloadRt } from './title'; -export const CommonFieldsRt = rt.type({ +export const CommonFieldsRt = rt.strict({ type: rt.literal(ActionTypes.create_case), }); -const CommonPayloadAttributesRt = rt.type({ - assignees: AssigneesUserActionPayloadRt.props.assignees, - description: DescriptionUserActionPayloadRt.props.description, +const CommonPayloadAttributesRt = rt.strict({ + assignees: AssigneesUserActionPayloadRt.type.props.assignees, + description: DescriptionUserActionPayloadRt.type.props.description, status: rt.string, severity: rt.string, - tags: TagsUserActionPayloadRt.props.tags, - title: TitleUserActionPayloadRt.props.title, - settings: SettingsUserActionPayloadRt.props.settings, + tags: TagsUserActionPayloadRt.type.props.tags, + title: TitleUserActionPayloadRt.type.props.title, + settings: SettingsUserActionPayloadRt.type.props.settings, owner: rt.string, }); export const CreateCaseUserActionRt = rt.intersection([ CommonFieldsRt, - rt.type({ + rt.strict({ payload: rt.intersection([ConnectorUserActionPayloadRt, CommonPayloadAttributesRt]), }), ]); export const CreateCaseUserActionWithoutConnectorIdRt = rt.intersection([ CommonFieldsRt, - rt.type({ + rt.strict({ payload: rt.intersection([ ConnectorUserActionPayloadWithoutConnectorIdRt, CommonPayloadAttributesRt, diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/delete_case.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/delete_case.test.ts new file mode 100644 index 0000000000000..c8f21585961e8 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/delete_case.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionTypes } from './common'; +import { DeleteCaseUserActionRt } from './delete_case'; + +describe('Delete_case', () => { + describe('DeleteCaseUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.delete_case, + payload: {}, + }; + + it('has expected attributes in request', () => { + const query = DeleteCaseUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = DeleteCaseUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = DeleteCaseUserActionRt.decode({ ...defaultRequest, payload: { foo: 'bar' } }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/delete_case.ts b/x-pack/plugins/cases/common/api/cases/user_actions/delete_case.ts index f137b19616255..92a591f5c8f17 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/delete_case.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/delete_case.ts @@ -9,9 +9,9 @@ import * as rt from 'io-ts'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const DeleteCaseUserActionRt = rt.type({ +export const DeleteCaseUserActionRt = rt.strict({ type: rt.literal(ActionTypes.delete_case), - payload: rt.type({}), + payload: rt.strict({}), }); export type DeleteCaseUserAction = UserActionWithAttributes< diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/description.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/description.test.ts new file mode 100644 index 0000000000000..5d5ad8a3d0027 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/description.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionTypes } from './common'; +import { DescriptionUserActionPayloadRt, DescriptionUserActionRt } from './description'; + +describe('Description', () => { + describe('DescriptionUserActionPayloadRt', () => { + const defaultRequest = { + description: 'this is sample description', + }; + + it('has expected attributes in request', () => { + const query = DescriptionUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = DescriptionUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('DescriptionUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.description, + payload: { + description: 'this is sample description', + }, + }; + + it('has expected attributes in request', () => { + const query = DescriptionUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = DescriptionUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = DescriptionUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/description.ts b/x-pack/plugins/cases/common/api/cases/user_actions/description.ts index 89fc4627ae83e..14639b9fbd483 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/description.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/description.ts @@ -9,9 +9,9 @@ import * as rt from 'io-ts'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const DescriptionUserActionPayloadRt = rt.type({ description: rt.string }); +export const DescriptionUserActionPayloadRt = rt.strict({ description: rt.string }); -export const DescriptionUserActionRt = rt.type({ +export const DescriptionUserActionRt = rt.strict({ type: rt.literal(ActionTypes.description), payload: DescriptionUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/operations/find.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/operations/find.test.ts new file mode 100644 index 0000000000000..0b5f1e1a73849 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/operations/find.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { UserActionFindResponseRt, UserActionFindRequestRt } from './find'; +import { ActionTypes } from '../common'; +import { CommentType } from '../../comment'; + +describe('Find UserActions', () => { + describe('UserActionFindRequestRt', () => { + const defaultRequest = { + types: [ActionTypes.comment], + sortOrder: 'desc', + page: '1', + perPage: '10', + }; + + it('has expected attributes in request', () => { + const query = UserActionFindRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + ...defaultRequest, + page: 1, + perPage: 10, + }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = UserActionFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + ...defaultRequest, + page: 1, + perPage: 10, + }, + }); + }); + }); + + describe('UserActionFindResponseRt', () => { + const defaultRequest = { + userActions: [ + { + type: ActionTypes.comment, + payload: { + comment: { + comment: 'this is a sample comment', + type: CommentType.user, + owner: 'cases', + }, + }, + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + owner: 'cases', + action: 'create', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + comment_id: 'basic-comment-id', + }, + ], + page: 1, + perPage: 10, + total: 20, + }; + + it('has expected attributes in request', () => { + const query = UserActionFindResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = UserActionFindResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from userActions', () => { + const query = UserActionFindResponseRt.decode({ + ...defaultRequest, + userActions: [{ ...defaultRequest.userActions[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/operations/find.ts b/x-pack/plugins/cases/common/api/cases/user_actions/operations/find.ts index ce107c73010c1..336c94a7a4fec 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/operations/find.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/operations/find.ts @@ -26,16 +26,18 @@ const FindTypeFieldRt = rt.keyof(FindTypes); export type FindTypeField = rt.TypeOf; -export const UserActionFindRequestRt = rt.partial({ - types: rt.array(FindTypeFieldRt), - sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), - page: NumberFromString, - perPage: NumberFromString, -}); +export const UserActionFindRequestRt = rt.exact( + rt.partial({ + types: rt.array(FindTypeFieldRt), + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), + page: NumberFromString, + perPage: NumberFromString, + }) +); export type UserActionFindRequest = rt.TypeOf; -export const UserActionFindResponseRt = rt.type({ +export const UserActionFindResponseRt = rt.strict({ userActions: UserActionsRt, page: rt.number, perPage: rt.number, diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/pushed.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/pushed.test.ts new file mode 100644 index 0000000000000..9aa6668eaeadf --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/pushed.test.ts @@ -0,0 +1,213 @@ +/* + * 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 { ActionTypes } from './common'; +import { + PushedUserActionPayloadWithoutConnectorIdRt, + PushedUserActionPayloadRt, + PushedUserActionWithoutConnectorIdRt, + PushedUserActionRt, +} from './pushed'; + +describe('Pushed', () => { + describe('PushedUserActionPayloadWithoutConnectorIdRt', () => { + const defaultRequest = { + externalService: { + connector_name: 'My SN connector', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', + pushed_at: '2023-01-17T09:46:29.813Z', + pushed_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + }, + }; + + it('has expected attributes in request', () => { + const query = PushedUserActionPayloadWithoutConnectorIdRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = PushedUserActionPayloadWithoutConnectorIdRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from externalService', () => { + const query = PushedUserActionPayloadWithoutConnectorIdRt.decode({ + externalService: { ...defaultRequest.externalService, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('PushedUserActionPayloadRt', () => { + const defaultRequest = { + externalService: { + connector_id: 'servicenow-1', + connector_name: 'My SN connector', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', + pushed_at: '2023-01-17T09:46:29.813Z', + pushed_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + }, + }; + + it('has expected attributes in request', () => { + const query = PushedUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = PushedUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from externalService', () => { + const query = PushedUserActionPayloadRt.decode({ + externalService: { ...defaultRequest.externalService, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('PushedUserActionWithoutConnectorIdRt', () => { + const defaultRequest = { + type: ActionTypes.pushed, + payload: { + externalService: { + connector_name: 'My SN connector', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', + pushed_at: '2023-01-17T09:46:29.813Z', + pushed_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + }, + }, + }; + + it('has expected attributes in request', () => { + const query = PushedUserActionWithoutConnectorIdRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = PushedUserActionWithoutConnectorIdRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = PushedUserActionWithoutConnectorIdRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('PushedUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.pushed, + payload: { + externalService: { + connector_id: 'servicenow-1', + connector_name: 'My SN connector', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', + pushed_at: '2023-01-17T09:46:29.813Z', + pushed_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + }, + }, + }; + + it('has expected attributes in request', () => { + const query = PushedUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = PushedUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from externalService', () => { + const query = PushedUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/pushed.ts b/x-pack/plugins/cases/common/api/cases/user_actions/pushed.ts index 2ae500e32ec9f..52530944c1883 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/pushed.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/pushed.ts @@ -10,20 +10,20 @@ import { CaseUserActionExternalServiceRt, CaseExternalServiceBasicRt } from '../ import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const PushedUserActionPayloadWithoutConnectorIdRt = rt.type({ +export const PushedUserActionPayloadWithoutConnectorIdRt = rt.strict({ externalService: CaseUserActionExternalServiceRt, }); -export const PushedUserActionPayloadRt = rt.type({ +export const PushedUserActionPayloadRt = rt.strict({ externalService: CaseExternalServiceBasicRt, }); -export const PushedUserActionWithoutConnectorIdRt = rt.type({ +export const PushedUserActionWithoutConnectorIdRt = rt.strict({ type: rt.literal(ActionTypes.pushed), payload: PushedUserActionPayloadWithoutConnectorIdRt, }); -export const PushedUserActionRt = rt.type({ +export const PushedUserActionRt = rt.strict({ type: rt.literal(ActionTypes.pushed), payload: PushedUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/response.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/response.test.ts new file mode 100644 index 0000000000000..8375b67d06d23 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/response.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { CommentType } from '../comment'; +import { ActionTypes } from './common'; +import { UserActionsRt, CaseUserActionStatsResponseRt } from './response'; + +describe('Response', () => { + describe('UserActionsRt', () => { + const defaultRequest = [ + { + type: ActionTypes.comment, + payload: { + comment: { + comment: 'this is a sample comment', + type: CommentType.user, + owner: 'cases', + }, + }, + created_at: '2020-02-19T23:06:33.798Z', + created_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + owner: 'cases', + action: 'create', + id: 'basic-comment-id', + version: 'WzQ3LDFc', + comment_id: 'basic-comment-id', + }, + ]; + + it('has expected attributes in request', () => { + const query = UserActionsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: [...defaultRequest], + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = UserActionsRt.decode([{ ...defaultRequest[0], foo: 'bar' }]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = UserActionsRt.decode([ + { ...defaultRequest[0], payload: { ...defaultRequest[0].payload, foo: 'bar' } }, + ]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CaseUserActionStatsResponseRt', () => { + const defaultRequest = { + total: 15, + total_comments: 10, + total_other_actions: 5, + }; + + it('has expected attributes in request', () => { + const query = CaseUserActionStatsResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseUserActionStatsResponseRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/response.ts b/x-pack/plugins/cases/common/api/cases/user_actions/response.ts index edd4533dd6444..af9b15ddf44fe 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/response.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/response.ts @@ -76,7 +76,7 @@ export const UserActionAttributesRt = rt.intersection([ const UserActionRt = rt.intersection([ UserActionAttributesRt, - rt.type({ + rt.strict({ id: rt.string, version: rt.string, }), diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/settings.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/settings.test.ts new file mode 100644 index 0000000000000..76a76ff473349 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/settings.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { ActionTypes } from './common'; +import { SettingsUserActionPayloadRt, SettingsUserActionRt } from './settings'; + +describe('Settings', () => { + describe('SettingsUserActionPayloadRt', () => { + const defaultRequest = { + settings: { syncAlerts: true }, + }; + + it('has expected attributes in request', () => { + const query = SettingsUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SettingsUserActionPayloadRt.decode({ + settings: { syncAlerts: false }, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + settings: { syncAlerts: false }, + }, + }); + }); + }); + + describe('SettingsUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.settings, + payload: { + settings: { syncAlerts: true }, + }, + }; + + it('has expected attributes in request', () => { + const query = SettingsUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SettingsUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = SettingsUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/settings.ts b/x-pack/plugins/cases/common/api/cases/user_actions/settings.ts index 5094ec7680b1c..cfd0658d4cb60 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/settings.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/settings.ts @@ -10,9 +10,9 @@ import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; import { SettingsRt } from '../case'; -export const SettingsUserActionPayloadRt = rt.type({ settings: SettingsRt }); +export const SettingsUserActionPayloadRt = rt.strict({ settings: SettingsRt }); -export const SettingsUserActionRt = rt.type({ +export const SettingsUserActionRt = rt.strict({ type: rt.literal(ActionTypes.settings), payload: SettingsUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/severity.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/severity.test.ts new file mode 100644 index 0000000000000..7f7b893c27572 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/severity.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { CaseSeverity } from '../case'; +import { ActionTypes } from './common'; +import { SeverityUserActionPayloadRt, SeverityUserActionRt } from './severity'; + +describe('Severity', () => { + describe('SeverityUserActionPayloadRt', () => { + const defaultRequest = { + severity: CaseSeverity.MEDIUM, + }; + + it('has expected attributes in request', () => { + const query = SeverityUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SeverityUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('SeverityUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.severity, + payload: { + severity: CaseSeverity.CRITICAL, + }, + }; + + it('has expected attributes in request', () => { + const query = SeverityUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SeverityUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = SeverityUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/severity.ts b/x-pack/plugins/cases/common/api/cases/user_actions/severity.ts index c7ea0cb1e4b3f..d5065225e2d1c 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/severity.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/severity.ts @@ -10,9 +10,9 @@ import { CaseSeverityRt } from '../case'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const SeverityUserActionPayloadRt = rt.type({ severity: CaseSeverityRt }); +export const SeverityUserActionPayloadRt = rt.strict({ severity: CaseSeverityRt }); -export const SeverityUserActionRt = rt.type({ +export const SeverityUserActionRt = rt.strict({ type: rt.literal(ActionTypes.severity), payload: SeverityUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/stats.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/stats.test.ts new file mode 100644 index 0000000000000..95ae660783c68 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/stats.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseUserActionStatsRt } from './stats'; + +describe('Stats', () => { + describe('CaseUserActionStatsRt', () => { + const defaultRequest = { + total: 100, + total_comments: 60, + total_other_actions: 40, + }; + + it('has expected attributes in request', () => { + const query = CaseUserActionStatsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseUserActionStatsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/stats.ts b/x-pack/plugins/cases/common/api/cases/user_actions/stats.ts index de0a6439e0bdb..acd8dadc84a4c 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/stats.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/stats.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -export const CaseUserActionStatsRt = rt.type({ +export const CaseUserActionStatsRt = rt.strict({ total: rt.number, total_comments: rt.number, total_other_actions: rt.number, diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/status.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/status.test.ts new file mode 100644 index 0000000000000..5f60e215584f1 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/status.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { CaseStatuses } from '../status'; +import { ActionTypes } from './common'; +import { StatusUserActionPayloadRt, StatusUserActionRt } from './status'; + +describe('Status', () => { + describe('StatusUserActionPayloadRt', () => { + const defaultRequest = { + status: CaseStatuses['in-progress'], + }; + + it('has expected attributes in request', () => { + const query = StatusUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = StatusUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('StatusUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.status, + payload: { + status: CaseStatuses.closed, + }, + }; + + it('has expected attributes in request', () => { + const query = StatusUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = StatusUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = StatusUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/status.ts b/x-pack/plugins/cases/common/api/cases/user_actions/status.ts index 97502433d69e0..d2017fd0b68ce 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/status.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/status.ts @@ -10,9 +10,9 @@ import { CaseStatusRt } from '../status'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const StatusUserActionPayloadRt = rt.type({ status: CaseStatusRt }); +export const StatusUserActionPayloadRt = rt.strict({ status: CaseStatusRt }); -export const StatusUserActionRt = rt.type({ +export const StatusUserActionRt = rt.strict({ type: rt.literal(ActionTypes.status), payload: StatusUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/tags.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/tags.test.ts new file mode 100644 index 0000000000000..5bcecab88255c --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/tags.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionTypes } from './common'; +import { TagsUserActionPayloadRt, TagsUserActionRt } from './tags'; + +describe('Tags', () => { + describe('TagsUserActionPayloadRt', () => { + const defaultRequest = { + tags: ['one', 'two'], + }; + + it('has expected attributes in request', () => { + const query = TagsUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TagsUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('TagsUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.tags, + payload: { + tags: ['one', '2-two'], + }, + }; + + it('has expected attributes in request', () => { + const query = TagsUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TagsUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = TagsUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/tags.ts b/x-pack/plugins/cases/common/api/cases/user_actions/tags.ts index 7da5798e85ebd..4bac11f5e39cd 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/tags.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/tags.ts @@ -9,9 +9,9 @@ import * as rt from 'io-ts'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const TagsUserActionPayloadRt = rt.type({ tags: rt.array(rt.string) }); +export const TagsUserActionPayloadRt = rt.strict({ tags: rt.array(rt.string) }); -export const TagsUserActionRt = rt.type({ +export const TagsUserActionRt = rt.strict({ type: rt.literal(ActionTypes.tags), payload: TagsUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/title.test.ts b/x-pack/plugins/cases/common/api/cases/user_actions/title.test.ts new file mode 100644 index 0000000000000..56cf555d881d7 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_actions/title.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionTypes } from './common'; +import { TitleUserActionPayloadRt, TitleUserActionRt } from './title'; + +describe('Title', () => { + describe('TitleUserActionPayloadRt', () => { + const defaultRequest = { + title: 'sample title', + }; + + it('has expected attributes in request', () => { + const query = TitleUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TitleUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('TitleUserActionRt', () => { + const defaultRequest = { + type: ActionTypes.title, + payload: { + title: 'sample title', + }, + }; + + it('has expected attributes in request', () => { + const query = TitleUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TitleUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = TitleUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/title.ts b/x-pack/plugins/cases/common/api/cases/user_actions/title.ts index b0b54422488bd..cfc2443f273ca 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/title.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/title.ts @@ -9,9 +9,9 @@ import * as rt from 'io-ts'; import type { UserActionWithAttributes } from './common'; import { ActionTypes } from './common'; -export const TitleUserActionPayloadRt = rt.type({ title: rt.string }); +export const TitleUserActionPayloadRt = rt.strict({ title: rt.string }); -export const TitleUserActionRt = rt.type({ +export const TitleUserActionRt = rt.strict({ type: rt.literal(ActionTypes.title), payload: TitleUserActionPayloadRt, }); diff --git a/x-pack/plugins/cases/common/api/cases/user_profile.test.ts b/x-pack/plugins/cases/common/api/cases/user_profile.test.ts new file mode 100644 index 0000000000000..296182223aedd --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/user_profile.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { SuggestUserProfilesRequestRt, CaseUserProfileRt } from './user_profiles'; + +describe('userProfile', () => { + describe('SuggestUserProfilesRequestRt', () => { + const defaultRequest = { + name: 'damaged_raccoon', + owners: ['cases'], + size: 5, + }; + + it('has expected attributes in request', () => { + const query = SuggestUserProfilesRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + name: 'damaged_raccoon', + owners: ['cases'], + size: 5, + }, + }); + }); + + it('has only name and owner in request', () => { + const query = SuggestUserProfilesRequestRt.decode({ + name: 'damaged_raccoon', + owners: ['cases'], + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + name: 'damaged_raccoon', + owners: ['cases'], + }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SuggestUserProfilesRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + name: 'damaged_raccoon', + owners: ['cases'], + size: 5, + }, + }); + }); + }); + + describe('CaseUserProfileRt', () => { + it('has expected attributes in response', () => { + const query = CaseUserProfileRt.decode({ + uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }, + }); + }); + + it('removes foo:bar attributes from response', () => { + const query = CaseUserProfileRt.decode({ + uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/cases/user_profiles.ts b/x-pack/plugins/cases/common/api/cases/user_profiles.ts index d6ec5b05910f3..90c75561367d3 100644 --- a/x-pack/plugins/cases/common/api/cases/user_profiles.ts +++ b/x-pack/plugins/cases/common/api/cases/user_profiles.ts @@ -8,16 +8,16 @@ import * as rt from 'io-ts'; export const SuggestUserProfilesRequestRt = rt.intersection([ - rt.type({ + rt.strict({ name: rt.string, owners: rt.array(rt.string), }), - rt.partial({ size: rt.number }), + rt.exact(rt.partial({ size: rt.number })), ]); export type SuggestUserProfilesRequest = rt.TypeOf; -export const CaseUserProfileRt = rt.type({ +export const CaseUserProfileRt = rt.strict({ uid: rt.string, }); diff --git a/x-pack/plugins/cases/common/api/connectors/connector.test.ts b/x-pack/plugins/cases/common/api/connectors/connector.test.ts new file mode 100644 index 0000000000000..b92dd5e2d6f17 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/connector.test.ts @@ -0,0 +1,150 @@ +/* + * 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 { + ConnectorTypeFieldsRt, + CaseUserActionConnectorRt, + CaseConnectorRt, + ConnectorTypes, +} from './connector'; + +describe('Connector', () => { + describe('ConnectorTypeFieldsRt', () => { + const defaultRequest = { + type: ConnectorTypes.jira, + fields: { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }; + + it('has expected attributes in request', () => { + const query = ConnectorTypeFieldsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ConnectorTypeFieldsRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from fields', () => { + const query = ConnectorTypeFieldsRt.decode({ + ...defaultRequest, + fields: { ...defaultRequest.fields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CaseUserActionConnectorRt', () => { + const defaultRequest = { + type: ConnectorTypes.jira, + name: 'jira connector', + fields: { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }; + + it('has expected attributes in request', () => { + const query = CaseUserActionConnectorRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseUserActionConnectorRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from fields', () => { + const query = CaseUserActionConnectorRt.decode({ + ...defaultRequest, + fields: { ...defaultRequest.fields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CaseConnectorRt', () => { + const defaultRequest = { + type: ConnectorTypes.jira, + name: 'jira connector', + id: 'jira-connector-id', + fields: { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }; + + it('has expected attributes in request', () => { + const query = CaseConnectorRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseConnectorRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from fields', () => { + const query = CaseConnectorRt.decode({ + ...defaultRequest, + fields: { ...defaultRequest.fields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/connector.ts b/x-pack/plugins/cases/common/api/connectors/connector.ts index 7d96593d01316..ba86c7166df47 100644 --- a/x-pack/plugins/cases/common/api/connectors/connector.ts +++ b/x-pack/plugins/cases/common/api/connectors/connector.ts @@ -28,37 +28,37 @@ export enum ConnectorTypes { swimlane = '.swimlane', } -const ConnectorCasesWebhookTypeFieldsRt = rt.type({ +const ConnectorCasesWebhookTypeFieldsRt = rt.strict({ type: rt.literal(ConnectorTypes.casesWebhook), fields: rt.null, }); -const ConnectorJiraTypeFieldsRt = rt.type({ +const ConnectorJiraTypeFieldsRt = rt.strict({ type: rt.literal(ConnectorTypes.jira), fields: rt.union([JiraFieldsRT, rt.null]), }); -const ConnectorResilientTypeFieldsRt = rt.type({ +const ConnectorResilientTypeFieldsRt = rt.strict({ type: rt.literal(ConnectorTypes.resilient), fields: rt.union([ResilientFieldsRT, rt.null]), }); -const ConnectorServiceNowITSMTypeFieldsRt = rt.type({ +const ConnectorServiceNowITSMTypeFieldsRt = rt.strict({ type: rt.literal(ConnectorTypes.serviceNowITSM), fields: rt.union([ServiceNowITSMFieldsRT, rt.null]), }); -const ConnectorSwimlaneTypeFieldsRt = rt.type({ +const ConnectorSwimlaneTypeFieldsRt = rt.strict({ type: rt.literal(ConnectorTypes.swimlane), fields: rt.union([SwimlaneFieldsRT, rt.null]), }); -const ConnectorServiceNowSIRTypeFieldsRt = rt.type({ +const ConnectorServiceNowSIRTypeFieldsRt = rt.strict({ type: rt.literal(ConnectorTypes.serviceNowSIR), fields: rt.union([ServiceNowSIRFieldsRT, rt.null]), }); -const ConnectorNoneTypeFieldsRt = rt.type({ +const ConnectorNoneTypeFieldsRt = rt.strict({ type: rt.literal(ConnectorTypes.none), fields: rt.null, }); @@ -79,17 +79,17 @@ export const ConnectorTypeFieldsRt = rt.union([ * This type represents the connector's format when it is encoded within a user action. */ export const CaseUserActionConnectorRt = rt.union([ - rt.intersection([ConnectorCasesWebhookTypeFieldsRt, rt.type({ name: rt.string })]), - rt.intersection([ConnectorJiraTypeFieldsRt, rt.type({ name: rt.string })]), - rt.intersection([ConnectorNoneTypeFieldsRt, rt.type({ name: rt.string })]), - rt.intersection([ConnectorResilientTypeFieldsRt, rt.type({ name: rt.string })]), - rt.intersection([ConnectorServiceNowITSMTypeFieldsRt, rt.type({ name: rt.string })]), - rt.intersection([ConnectorServiceNowSIRTypeFieldsRt, rt.type({ name: rt.string })]), - rt.intersection([ConnectorSwimlaneTypeFieldsRt, rt.type({ name: rt.string })]), + rt.intersection([ConnectorCasesWebhookTypeFieldsRt, rt.strict({ name: rt.string })]), + rt.intersection([ConnectorJiraTypeFieldsRt, rt.strict({ name: rt.string })]), + rt.intersection([ConnectorNoneTypeFieldsRt, rt.strict({ name: rt.string })]), + rt.intersection([ConnectorResilientTypeFieldsRt, rt.strict({ name: rt.string })]), + rt.intersection([ConnectorServiceNowITSMTypeFieldsRt, rt.strict({ name: rt.string })]), + rt.intersection([ConnectorServiceNowSIRTypeFieldsRt, rt.strict({ name: rt.string })]), + rt.intersection([ConnectorSwimlaneTypeFieldsRt, rt.strict({ name: rt.string })]), ]); export const CaseConnectorRt = rt.intersection([ - rt.type({ + rt.strict({ id: rt.string, }), CaseUserActionConnectorRt, diff --git a/x-pack/plugins/cases/common/api/connectors/get_connectors.test.ts b/x-pack/plugins/cases/common/api/connectors/get_connectors.test.ts new file mode 100644 index 0000000000000..9f8838a7a8230 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/get_connectors.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { GetCaseConnectorsResponseRt } from './get_connectors'; + +describe('GetCaseConnectorsResponseRt', () => { + const externalService = { + connector_id: 'servicenow-1', + connector_name: 'My SN connector', + external_id: 'external_id', + external_title: 'external title', + external_url: 'basicPush.com', + pushed_at: '2023-01-17T09:46:29.813Z', + pushed_by: { + full_name: 'Leslie Knope', + username: 'lknope', + email: 'leslie.knope@elastic.co', + }, + }; + const defaultReq = { + 'servicenow-1': { + id: 'servicenow-1', + name: 'My SN connector', + type: '.servicenow', + fields: null, + push: { + needsToBePushed: false, + hasBeenPushed: true, + details: { + oldestUserActionPushDate: '2023-01-17T09:46:29.813Z', + latestUserActionPushDate: '2023-01-17T09:46:29.813Z', + externalService, + }, + }, + }, + }; + + it('has expected attributes in request', () => { + const query = GetCaseConnectorsResponseRt.decode(defaultReq); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = GetCaseConnectorsResponseRt.decode({ + 'servicenow-1': { ...defaultReq['servicenow-1'], externalService, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); + + it('removes foo:bar attributes from externalService object', () => { + const query = GetCaseConnectorsResponseRt.decode({ + 'servicenow-1': { + ...defaultReq['servicenow-1'], + externalService: { ...externalService, foo: 'bar' }, + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); + + it('removes foo:bar attributes from push object', () => { + const query = GetCaseConnectorsResponseRt.decode({ + 'servicenow-1': { + ...defaultReq['servicenow-1'], + push: { ...defaultReq['servicenow-1'].push, foo: 'bar' }, + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/get_connectors.ts b/x-pack/plugins/cases/common/api/connectors/get_connectors.ts index 8342c270b160e..748ad59de2649 100644 --- a/x-pack/plugins/cases/common/api/connectors/get_connectors.ts +++ b/x-pack/plugins/cases/common/api/connectors/get_connectors.ts @@ -9,26 +9,28 @@ import * as rt from 'io-ts'; import { CaseConnectorRt } from './connector'; import { CaseExternalServiceBasicRt } from '../cases'; -const PushDetailsRt = rt.type({ +const PushDetailsRt = rt.strict({ latestUserActionPushDate: rt.string, oldestUserActionPushDate: rt.string, externalService: CaseExternalServiceBasicRt, }); const CaseConnectorPushInfoRt = rt.intersection([ - rt.type({ + rt.strict({ needsToBePushed: rt.boolean, hasBeenPushed: rt.boolean, }), - rt.partial({ - details: PushDetailsRt, - }), + rt.exact( + rt.partial({ + details: PushDetailsRt, + }) + ), ]); export const GetCaseConnectorsResponseRt = rt.record( rt.string, rt.intersection([ - rt.type({ + rt.strict({ push: CaseConnectorPushInfoRt, }), CaseConnectorRt, diff --git a/x-pack/plugins/cases/common/api/connectors/jira.test.ts b/x-pack/plugins/cases/common/api/connectors/jira.test.ts new file mode 100644 index 0000000000000..0350e7ce2bcb1 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/jira.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 { JiraFieldsRT } from './jira'; + +describe('JiraFieldsRT', () => { + const defaultRequest = { + issueType: 'bug', + priority: 'high', + parent: '2', + }; + + it('has expected attributes in request', () => { + const query = JiraFieldsRT.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = JiraFieldsRT.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/jira.ts b/x-pack/plugins/cases/common/api/connectors/jira.ts index 15a6768b07561..49ddc6fcdea5c 100644 --- a/x-pack/plugins/cases/common/api/connectors/jira.ts +++ b/x-pack/plugins/cases/common/api/connectors/jira.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -export const JiraFieldsRT = rt.type({ +export const JiraFieldsRT = rt.strict({ issueType: rt.union([rt.string, rt.null]), priority: rt.union([rt.string, rt.null]), parent: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/cases/common/api/connectors/mappings.test.ts b/x-pack/plugins/cases/common/api/connectors/mappings.test.ts new file mode 100644 index 0000000000000..ecd5f240d810e --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/mappings.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { ConnectorMappingsRt } from './mappings'; + +describe('Mappings', () => { + describe('ConnectorMappingsRt', () => { + const defaultRequest = { + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'unknown', + }, + { + action_type: 'append', + source: 'description', + target: 'not_mapped', + }, + ], + owner: 'cases', + }; + + it('has expected attributes in request', () => { + const query = ConnectorMappingsRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ConnectorMappingsRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from mappings', () => { + const query = ConnectorMappingsRt.decode({ + ...defaultRequest, + mappings: [{ ...defaultRequest.mappings[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, mappings: [{ ...defaultRequest.mappings[0] }] }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/mappings.ts b/x-pack/plugins/cases/common/api/connectors/mappings.ts index 888c7d5f40385..6978508c3cffb 100644 --- a/x-pack/plugins/cases/common/api/connectors/mappings.ts +++ b/x-pack/plugins/cases/common/api/connectors/mappings.ts @@ -24,13 +24,13 @@ export type ActionType = rt.TypeOf; export type CaseField = rt.TypeOf; export type ThirdPartyField = rt.TypeOf; -const ConnectorMappingsAttributesRt = rt.type({ +const ConnectorMappingsAttributesRt = rt.strict({ action_type: ActionTypeRt, source: CaseFieldRt, target: ThirdPartyFieldRt, }); -export const ConnectorMappingsRt = rt.type({ +export const ConnectorMappingsRt = rt.strict({ mappings: rt.array(ConnectorMappingsAttributesRt), owner: rt.string, }); @@ -40,7 +40,7 @@ export type ConnectorMappings = rt.TypeOf; const FieldTypeRt = rt.union([rt.literal('text'), rt.literal('textarea')]); -const ConnectorFieldRt = rt.type({ +const ConnectorFieldRt = rt.strict({ id: rt.string, name: rt.string, required: rt.boolean, diff --git a/x-pack/plugins/cases/common/api/connectors/resilient.test.ts b/x-pack/plugins/cases/common/api/connectors/resilient.test.ts new file mode 100644 index 0000000000000..31643bcba5749 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/resilient.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ResilientFieldsRT } from './resilient'; + +describe('ResilientFieldsRT', () => { + const defaultRequest = { + severityCode: '6', + incidentTypes: ['19'], + }; + + it('has expected attributes in request', () => { + const query = ResilientFieldsRT.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ResilientFieldsRT.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/resilient.ts b/x-pack/plugins/cases/common/api/connectors/resilient.ts index d19aa5b21fb52..a5bf69a5ed567 100644 --- a/x-pack/plugins/cases/common/api/connectors/resilient.ts +++ b/x-pack/plugins/cases/common/api/connectors/resilient.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -export const ResilientFieldsRT = rt.type({ +export const ResilientFieldsRT = rt.strict({ incidentTypes: rt.union([rt.array(rt.string), rt.null]), severityCode: rt.union([rt.string, rt.null]), }); diff --git a/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.test.ts b/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.test.ts new file mode 100644 index 0000000000000..ca9c4122547b7 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; + +describe('ServiceNowITSMFieldsRT', () => { + const defaultReq = { + severity: '2', + urgency: '2', + impact: '2', + category: 'software', + subcategory: 'os', + }; + + it('has expected attributes in request', () => { + const query = ServiceNowITSMFieldsRT.decode(defaultReq); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ServiceNowITSMFieldsRT.decode({ ...defaultReq, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts b/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts index c3cf298a6aade..1f56fac694d57 100644 --- a/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts +++ b/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -export const ServiceNowITSMFieldsRT = rt.type({ +export const ServiceNowITSMFieldsRT = rt.strict({ impact: rt.union([rt.string, rt.null]), severity: rt.union([rt.string, rt.null]), urgency: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/cases/common/api/connectors/servicenow_sir.test.ts b/x-pack/plugins/cases/common/api/connectors/servicenow_sir.test.ts new file mode 100644 index 0000000000000..24df0bac78f97 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/servicenow_sir.test.ts @@ -0,0 +1,38 @@ +/* + * 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 { ServiceNowSIRFieldsRT } from './servicenow_sir'; + +describe('ServiceNowSIRFieldsRT', () => { + const defaultReq = { + destIp: true, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }; + + it('has expected attributes in request', () => { + const query = ServiceNowSIRFieldsRT.decode(defaultReq); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ServiceNowSIRFieldsRT.decode({ ...defaultReq, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultReq, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts b/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts index 749abdea87437..afa2d3fd544da 100644 --- a/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts +++ b/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -export const ServiceNowSIRFieldsRT = rt.type({ +export const ServiceNowSIRFieldsRT = rt.strict({ category: rt.union([rt.string, rt.null]), destIp: rt.union([rt.boolean, rt.null]), malwareHash: rt.union([rt.boolean, rt.null]), diff --git a/x-pack/plugins/cases/common/api/connectors/swimlane.test.ts b/x-pack/plugins/cases/common/api/connectors/swimlane.test.ts new file mode 100644 index 0000000000000..d7632f65f7780 --- /dev/null +++ b/x-pack/plugins/cases/common/api/connectors/swimlane.test.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SwimlaneFieldsRT } from './swimlane'; + +describe('SwimlaneFieldsRT', () => { + it('has expected attributes in request', () => { + const query = SwimlaneFieldsRT.decode({ caseId: 'basic-case-id' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { caseId: 'basic-case-id' }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SwimlaneFieldsRT.decode({ caseId: 'basic-case-id', foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { caseId: 'basic-case-id' }, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/connectors/swimlane.ts b/x-pack/plugins/cases/common/api/connectors/swimlane.ts index a5bf60edbf1cd..9d4df1b492e71 100644 --- a/x-pack/plugins/cases/common/api/connectors/swimlane.ts +++ b/x-pack/plugins/cases/common/api/connectors/swimlane.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -export const SwimlaneFieldsRT = rt.type({ +export const SwimlaneFieldsRT = rt.strict({ caseId: rt.union([rt.string, rt.null]), }); diff --git a/x-pack/plugins/cases/common/api/metrics/case.test.ts b/x-pack/plugins/cases/common/api/metrics/case.test.ts new file mode 100644 index 0000000000000..931970d90bcf5 --- /dev/null +++ b/x-pack/plugins/cases/common/api/metrics/case.test.ts @@ -0,0 +1,287 @@ +/* + * 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 { + SingleCaseMetricsRequestRt, + CasesMetricsRequestRt, + SingleCaseMetricsResponseRt, + CasesMetricsResponseRt, +} from './case'; + +describe('Metrics case', () => { + describe('SingleCaseMetricsRequestRt', () => { + const defaultRequest = { + caseId: 'basic-case-id', + features: ['alerts.count', 'lifespan'], + }; + + it('has expected attributes in request', () => { + const query = SingleCaseMetricsRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SingleCaseMetricsRequestRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesMetricsRequestRt', () => { + const defaultRequest = { features: ['mttr'], to: 'now-1d', from: 'now-1d', owner: ['cases'] }; + + it('has expected attributes in request', () => { + const query = CasesMetricsRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesMetricsRequestRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from when partial fields', () => { + const query = CasesMetricsRequestRt.decode({ features: ['mttr'], to: 'now-1d', foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + features: ['mttr'], + to: 'now-1d', + }, + }); + }); + }); + + describe('SingleCaseMetricsResponseRt', () => { + const defaultRequest = { + alerts: { + count: 5, + hosts: { + total: 3, + values: [ + { name: 'first-host', id: 'first-host-id', count: 3 }, + { id: 'second-host-id', count: 2, name: undefined }, + { id: 'third-host-id', count: 3, name: undefined }, + ], + }, + users: { + total: 2, + values: [ + { name: 'first-user', count: 3 }, + { name: 'second-userd', count: 2 }, + ], + }, + }, + connectors: { total: 1 }, + actions: { + isolateHost: { + isolate: { total: 1 }, + unisolate: { total: 2 }, + }, + }, + lifespan: { + creationDate: new Date(0).toISOString(), + closeDate: new Date(2).toISOString(), + statusInfo: { + inProgressDuration: 20, + openDuration: 10, + reopenDates: [new Date(1).toISOString()], + }, + }, + }; + + it('has expected attributes in request', () => { + const query = SingleCaseMetricsResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from alerts', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + alerts: { ...defaultRequest.alerts, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from hosts', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + alerts: { ...defaultRequest.alerts, hosts: { ...defaultRequest.alerts.hosts, foo: 'bar' } }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from users', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + alerts: { ...defaultRequest.alerts, users: { ...defaultRequest.alerts.users, foo: 'bar' } }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from connectors', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + connectors: { total: 1, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from actions', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + actions: { ...defaultRequest.actions, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from isolate hosts', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + actions: { + ...defaultRequest.actions, + isolateHost: { ...defaultRequest.actions.isolateHost, foo: 'bar' }, + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from unisolate host', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + actions: { + ...defaultRequest.actions, + isolateHost: { + ...defaultRequest.actions.isolateHost, + unisolate: { foo: 'bar', total: 2 }, + }, + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from lifespan', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + lifespan: { ...defaultRequest.lifespan, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from status info', () => { + const query = SingleCaseMetricsResponseRt.decode({ + ...defaultRequest, + lifespan: { + ...defaultRequest.lifespan, + statusInfo: { foo: 'bar', ...defaultRequest.lifespan.statusInfo }, + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CasesMetricsResponseRt', () => { + const defaultRequest = { mttr: 1 }; + + it('has expected attributes in request', () => { + const query = CasesMetricsResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CasesMetricsResponseRt.decode({ + mttr: null, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + mttr: null, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/metrics/case.ts b/x-pack/plugins/cases/common/api/metrics/case.ts index 0f15ea607572b..7e7fb6a3f4a4c 100644 --- a/x-pack/plugins/cases/common/api/metrics/case.ts +++ b/x-pack/plugins/cases/common/api/metrics/case.ts @@ -15,7 +15,7 @@ export type AlertHostsMetrics = rt.TypeOf; export type AlertUsersMetrics = rt.TypeOf; export type StatusInfo = rt.TypeOf; -const StatusInfoRt = rt.type({ +const StatusInfoRt = rt.strict({ /** * Duration the case was in the open status in milliseconds */ @@ -30,13 +30,13 @@ const StatusInfoRt = rt.type({ reopenDates: rt.array(rt.string), }); -const AlertHostsMetricsRt = rt.type({ +const AlertHostsMetricsRt = rt.strict({ /** * Total unique hosts represented in the alerts */ total: rt.number, values: rt.array( - rt.type({ + rt.strict({ /** * Host name */ @@ -53,13 +53,13 @@ const AlertHostsMetricsRt = rt.type({ ), }); -const AlertUsersMetricsRt = rt.type({ +const AlertUsersMetricsRt = rt.strict({ /** * Total unique users represented in the alerts */ total: rt.number, values: rt.array( - rt.type({ + rt.strict({ /** * Username */ @@ -72,7 +72,7 @@ const AlertUsersMetricsRt = rt.type({ ), }); -export const SingleCaseMetricsRequestRt = rt.type({ +export const SingleCaseMetricsRequestRt = rt.strict({ /** * The ID of the case. */ @@ -84,34 +84,36 @@ export const SingleCaseMetricsRequestRt = rt.type({ }); export const CasesMetricsRequestRt = rt.intersection([ - rt.type({ + rt.strict({ /** * The metrics to retrieve. */ features: rt.array(rt.string), }), - rt.partial({ - /** - * A KQL date. If used all cases created after (gte) the from date will be returned - */ - from: rt.string, - /** - * A KQL date. If used all cases created before (lte) the to date will be returned. - */ - to: rt.string, - /** - * The owner(s) to filter by. The user making the request must have privileges to retrieve cases of that - * ownership or they will be ignored. If no owner is included, then all ownership types will be included in the response - * that the user has access to. - */ - owner: rt.union([rt.array(rt.string), rt.string]), - }), + rt.exact( + rt.partial({ + /** + * A KQL date. If used all cases created after (gte) the from date will be returned + */ + from: rt.string, + /** + * A KQL date. If used all cases created before (lte) the to date will be returned. + */ + to: rt.string, + /** + * The owner(s) to filter by. The user making the request must have privileges to retrieve cases of that + * ownership or they will be ignored. If no owner is included, then all ownership types will be included in the response + * that the user has access to. + */ + owner: rt.union([rt.array(rt.string), rt.string]), + }) + ), ]); -export const SingleCaseMetricsResponseRt = rt.partial( - rt.type({ - alerts: rt.partial( - rt.type({ +export const SingleCaseMetricsResponseRt = rt.exact( + rt.partial({ + alerts: rt.exact( + rt.partial({ /** * Number of alerts attached to the case */ @@ -124,12 +126,12 @@ export const SingleCaseMetricsResponseRt = rt.partial( * User information represented from the alerts attached to this case */ users: AlertUsersMetricsRt, - }).props + }) ), /** * External connectors associated with the case */ - connectors: rt.type({ + connectors: rt.strict({ /** * Total number of connectors in the case */ @@ -138,13 +140,13 @@ export const SingleCaseMetricsResponseRt = rt.partial( /** * Actions taken within the case */ - actions: rt.partial( - rt.type({ - isolateHost: rt.type({ + actions: rt.exact( + rt.partial({ + isolateHost: rt.strict({ /** * Isolate host action information */ - isolate: rt.type({ + isolate: rt.strict({ /** * Total times the isolate host action has been performed */ @@ -153,19 +155,19 @@ export const SingleCaseMetricsResponseRt = rt.partial( /** * Unisolate host action information */ - unisolate: rt.type({ + unisolate: rt.strict({ /** * Total times the unisolate host action has been performed */ total: rt.number, }), }), - }).props + }) ), /** * The case's open,close,in-progress details */ - lifespan: rt.type({ + lifespan: rt.strict({ /** * Date the case was created, in ISO format */ @@ -179,14 +181,14 @@ export const SingleCaseMetricsResponseRt = rt.partial( */ statusInfo: StatusInfoRt, }), - }).props + }) ); -export const CasesMetricsResponseRt = rt.partial( - rt.type({ +export const CasesMetricsResponseRt = rt.exact( + rt.partial({ /** * The average resolve time of all cases in seconds */ mttr: rt.union([rt.number, rt.null]), - }).props + }) ); diff --git a/x-pack/plugins/cases/common/api/runtime_types.test.ts b/x-pack/plugins/cases/common/api/runtime_types.test.ts new file mode 100644 index 0000000000000..657a07018beeb --- /dev/null +++ b/x-pack/plugins/cases/common/api/runtime_types.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { BulkCreateCommentRequestRt, CommentType } from './cases'; + +import { decodeWithExcessOrThrow } from './runtime_types'; + +describe('runtime_types', () => { + describe('decodeWithExcessOrThrow', () => { + it('does not throw when all required fields are present for rt.type', () => { + const schemaRt = rt.type({ + a: rt.string, + }); + + expect(() => decodeWithExcessOrThrow(schemaRt)({ a: 'hi' })).not.toThrow(); + }); + + it('does not throw when all required fields are present for rt.strict', () => { + const schemaRt = rt.strict({ + a: rt.string, + }); + + expect(() => decodeWithExcessOrThrow(schemaRt)({ a: 'hi' })).not.toThrow(); + }); + + it('throws when a required field is not present for rt.type', () => { + const schemaRt = rt.type({ + a: rt.string, + }); + + expect(() => decodeWithExcessOrThrow(schemaRt)({})).toThrowErrorMatchingInlineSnapshot( + `"Invalid value \\"undefined\\" supplied to \\"a\\""` + ); + }); + + it('throws when a required field is not present for rt.strict', () => { + const schemaRt = rt.strict({ + a: rt.string, + }); + + expect(() => decodeWithExcessOrThrow(schemaRt)({})).toThrowErrorMatchingInlineSnapshot( + `"Invalid value \\"undefined\\" supplied to \\"a\\""` + ); + }); + + it('throws when an excess field exists for rt.strict', () => { + const schemaRt = rt.strict({ + a: rt.string, + }); + + expect(() => + decodeWithExcessOrThrow(schemaRt)({ a: 'hi', b: 1 }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"b\\""`); + }); + + it('does not throw when an excess field exists for rt.type', () => { + const schemaRt = rt.type({ + a: rt.string, + }); + + expect(() => decodeWithExcessOrThrow(schemaRt)({ a: 'hi', b: 1 })).not.toThrow(); + }); + + it('throws when a nested excess field exists for rt.strict', () => { + const schemaRt = rt.strict({ + a: rt.strict({ + b: rt.string, + }), + }); + + expect(() => + decodeWithExcessOrThrow(schemaRt)({ a: { b: 'hi', c: 1 } }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"c\\""`); + }); + + it('does not throw when a nested excess field exists for rt.type', () => { + const schemaRt = rt.type({ + a: rt.type({ b: rt.string }), + }); + + expect(() => decodeWithExcessOrThrow(schemaRt)({ a: { b: 'hi', c: 1 } })).not.toThrow(); + }); + + it('returns the object after decoding for rt.type', () => { + const schemaRt = rt.type({ + a: rt.string, + }); + + expect(decodeWithExcessOrThrow(schemaRt)({ a: 'hi' })).toStrictEqual({ a: 'hi' }); + }); + + it('returns the object after decoding for rt.strict', () => { + const schemaRt = rt.strict({ + a: rt.string, + }); + + expect(decodeWithExcessOrThrow(schemaRt)({ a: 'hi' })).toStrictEqual({ a: 'hi' }); + }); + + describe('BulkCreateCommentRequestRt', () => { + it('does not throw an error for BulkCreateCommentRequestRt', () => { + expect(() => + decodeWithExcessOrThrow(BulkCreateCommentRequestRt)([ + { comment: 'hi', type: CommentType.user, owner: 'owner' }, + ]) + ).not.toThrow(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/runtime_types.ts b/x-pack/plugins/cases/common/api/runtime_types.ts index c1c967145599d..bc8cd0401f129 100644 --- a/x-pack/plugins/cases/common/api/runtime_types.ts +++ b/x-pack/plugins/cases/common/api/runtime_types.ts @@ -6,23 +6,16 @@ */ import * as rt from 'io-ts'; -import { either, fold } from 'fp-ts/lib/Either'; +import Boom from '@hapi/boom'; +import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import type { JsonArray, JsonObject, JsonValue } from '@kbn/utility-types'; -import { formatErrors } from '@kbn/securitysolution-io-ts-utils'; +import { exactCheck } from '@kbn/securitysolution-io-ts-utils/src/exact_check'; +import { formatErrors } from '@kbn/securitysolution-io-ts-utils/src/format_errors'; type ErrorFactory = (message: string) => Error; -export type GenericIntersectionC = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - | rt.IntersectionC<[any, any]> - // eslint-disable-next-line @typescript-eslint/no-explicit-any - | rt.IntersectionC<[any, any, any]> - // eslint-disable-next-line @typescript-eslint/no-explicit-any - | rt.IntersectionC<[any, any, any, any]> - // eslint-disable-next-line @typescript-eslint/no-explicit-any - | rt.IntersectionC<[any, any, any, any, any]>; export const createPlainError = (message: string) => new Error(message); @@ -30,86 +23,31 @@ export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => throw createError(formatErrors(errors).join()); }; +export const throwBadRequestError = (errors: rt.Errors) => { + throw Boom.badRequest(formatErrors(errors).join()); +}; + +/** + * This function will throw if a required field is missing or an excess field is present. + * NOTE: This will only throw for an excess field if the type passed in leverages exact from io-ts. + */ +export const decodeWithExcessOrThrow = + (runtimeType: rt.Type) => + (inputValue: I): A => + pipe( + runtimeType.decode(inputValue), + (decoded) => exactCheck(inputValue, decoded), + fold(throwBadRequestError, identity) + ); + +/** + * This function will throw if a required field is missing. + */ export const decodeOrThrow = (runtimeType: rt.Type, createError: ErrorFactory = createPlainError) => (inputValue: I) => pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); -export const getTypeProps = ( - codec: - | rt.HasProps - // eslint-disable-next-line @typescript-eslint/no-explicit-any - | rt.RecordC - | GenericIntersectionC -): rt.Props | null => { - if (codec == null) { - return null; - } - switch (codec._tag) { - case 'DictionaryType': - if (codec.codomain.props != null) { - return codec.codomain.props; - } - const dTypes: rt.HasProps[] = codec.codomain.types; - return dTypes.reduce((props, type) => Object.assign(props, getTypeProps(type)), {}); - case 'RefinementType': - case 'ReadonlyType': - return getTypeProps(codec.type); - case 'InterfaceType': - case 'StrictType': - case 'PartialType': - return codec.props; - case 'IntersectionType': - const iTypes = codec.types as rt.HasProps[]; - return iTypes.reduce((props, type) => { - return Object.assign(props, getTypeProps(type) as rt.Props); - }, {} as rt.Props) as rt.Props; - default: - return null; - } -}; - -const getExcessProps = (props: rt.Props, r: Record): string[] => { - const ex: string[] = []; - for (const k of Object.keys(r)) { - if (!Object.prototype.hasOwnProperty.call(props, k)) { - ex.push(k); - } - } - return ex; -}; - -export function excess< - C extends rt.InterfaceType | GenericIntersectionC | rt.PartialType ->(codec: C): C { - const codecProps = getTypeProps(codec); - - const r = new rt.InterfaceType( - codec.name, - codec.is, - (i, c) => - either.chain(rt.UnknownRecord.validate(i, c), (s: Record) => { - if (codecProps == null) { - return rt.failure(i, c, 'unknown codec'); - } - - const ex = getExcessProps(codecProps, s); - return ex.length > 0 - ? rt.failure( - i, - c, - `Invalid value ${JSON.stringify(i)} supplied to : ${ - codec.name - }, excess properties: ${JSON.stringify(ex)}` - ) - : codec.validate(i, c); - }), - codec.encode, - codecProps - ); - return r as C; -} - export const jsonScalarRt = rt.union([rt.null, rt.boolean, rt.number, rt.string]); export const jsonValueRt: rt.Type = rt.recursion('JsonValue', () => @@ -123,33 +61,3 @@ export const jsonArrayRt: rt.Type = rt.recursion('JsonArray', () => export const jsonObjectRt: rt.Type = rt.recursion('JsonObject', () => rt.record(rt.string, jsonValueRt) ); - -type Type = rt.InterfaceType | GenericIntersectionC; - -export const getTypeForCertainFields = (type: Type, fields: string[] = []): Type => { - if (fields.length === 0) { - return type; - } - - const codecProps = getTypeProps(type) ?? {}; - const typeProps: rt.Props = {}; - - for (const field of fields) { - if (codecProps[field]) { - typeProps[field] = codecProps[field]; - } - } - - return rt.type(typeProps); -}; - -export const getTypeForCertainFieldsFromArray = ( - type: rt.ArrayType, - fields: string[] = [] -): rt.ArrayType => { - if (fields.length === 0) { - return type; - } - - return rt.array(getTypeForCertainFields(type.type, fields)); -}; diff --git a/x-pack/plugins/cases/common/api/user.test.ts b/x-pack/plugins/cases/common/api/user.test.ts new file mode 100644 index 0000000000000..3fd8e9e4e8b98 --- /dev/null +++ b/x-pack/plugins/cases/common/api/user.test.ts @@ -0,0 +1,263 @@ +/* + * 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 { UserRt, UserWithProfileInfoRt, UsersRt, GetCaseUsersResponseRt } from './user'; + +describe('User', () => { + describe('UserRt', () => { + const defaultRequest = { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + profile_uid: 'profile-uid-1', + }; + it('has expected attributes in request', () => { + const query = UserRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = UserRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('UserWithProfileInfoRt', () => { + const defaultRequest = { + uid: '1', + avatar: { + initials: 'SU', + color: 'red', + imageUrl: 'https://google.com/image1', + }, + user: { + username: 'user', + email: 'some.user@google.com', + full_name: 'Some Super User', + }, + }; + + it('has expected attributes in request', () => { + const query = UserWithProfileInfoRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = UserWithProfileInfoRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from avatar', () => { + const query = UserWithProfileInfoRt.decode({ + ...defaultRequest, + avatar: { ...defaultRequest.avatar, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('UsersRt', () => { + const defaultRequest = [ + { + email: 'reporter_no_uid@elastic.co', + full_name: 'Reporter No UID', + username: 'reporter_no_uid', + profile_uid: 'reporter-uid', + }, + { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + ]; + + it('has expected attributes in request', () => { + const query = UsersRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = UsersRt.decode([ + { + ...defaultRequest[0], + foo: 'bar', + }, + ]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: [defaultRequest[0]], + }); + }); + }); + + describe('GetCaseUsersResponseRt', () => { + const defaultRequest = { + assignees: [ + { + user: { + email: null, + full_name: null, + username: null, + }, + uid: 'u_62h24XVQzG4-MuH1-DqPmookrJY23aRa9h4fyULR6I8_0', + }, + { + user: { + email: null, + full_name: null, + username: 'elastic', + }, + uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + }, + ], + unassignedUsers: [ + { + user: { + email: '', + full_name: '', + username: 'cases_no_connectors', + }, + uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + }, + { + user: { + email: 'valid_chimpanzee@profiles.elastic.co', + full_name: 'Valid Chimpanzee', + username: 'valid_chimpanzee', + }, + uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0', + }, + ], + participants: [ + { + user: { + email: 'participant_1@elastic.co', + full_name: 'Participant 1', + username: 'participant_1', + }, + }, + { + user: { + email: 'participant_2@elastic.co', + full_name: null, + username: 'participant_2', + }, + }, + ], + reporter: { + user: { + email: 'reporter_no_uid@elastic.co', + full_name: 'Reporter No UID', + username: 'reporter_no_uid', + }, + }, + }; + + it('has expected attributes in request', () => { + const query = GetCaseUsersResponseRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = GetCaseUsersResponseRt.decode({ + ...defaultRequest, + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from assigned users', () => { + const query = GetCaseUsersResponseRt.decode({ + ...defaultRequest, + assignees: [{ ...defaultRequest.assignees[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, assignees: [{ ...defaultRequest.assignees[0] }] }, + }); + }); + + it('removes foo:bar attributes from unassigned users', () => { + const query = GetCaseUsersResponseRt.decode({ + ...defaultRequest, + unassignedUsers: [{ ...defaultRequest.unassignedUsers[1], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, unassignedUsers: [{ ...defaultRequest.unassignedUsers[1] }] }, + }); + }); + + it('removes foo:bar attributes from participants', () => { + const query = GetCaseUsersResponseRt.decode({ + ...defaultRequest, + participants: [{ ...defaultRequest.participants[0], foo: 'bar' }], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, participants: [{ ...defaultRequest.participants[0] }] }, + }); + }); + + it('removes foo:bar attributes from reporter', () => { + const query = GetCaseUsersResponseRt.decode({ + ...defaultRequest, + reporter: { + ...defaultRequest.reporter, + foo: 'bar', + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/api/user.ts b/x-pack/plugins/cases/common/api/user.ts index 0fd349c298f1e..c832e71d2a33d 100644 --- a/x-pack/plugins/cases/common/api/user.ts +++ b/x-pack/plugins/cases/common/api/user.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -const UserWithoutProfileUidRt = rt.type({ +const UserWithoutProfileUidRt = rt.strict({ email: rt.union([rt.undefined, rt.null, rt.string]), full_name: rt.union([rt.undefined, rt.null, rt.string]), username: rt.union([rt.undefined, rt.null, rt.string]), @@ -15,17 +15,19 @@ const UserWithoutProfileUidRt = rt.type({ export const UserRt = rt.intersection([ UserWithoutProfileUidRt, - rt.partial({ profile_uid: rt.string }), + rt.exact(rt.partial({ profile_uid: rt.string })), ]); export const UserWithProfileInfoRt = rt.intersection([ - rt.type({ + rt.strict({ user: UserWithoutProfileUidRt, }), - rt.partial({ uid: rt.string }), - rt.partial({ - avatar: rt.partial({ initials: rt.string, color: rt.string, imageUrl: rt.string }), - }), + rt.exact(rt.partial({ uid: rt.string })), + rt.exact( + rt.partial({ + avatar: rt.exact(rt.partial({ initials: rt.string, color: rt.string, imageUrl: rt.string })), + }) + ), ]); export const UsersRt = rt.array(UserRt); @@ -33,7 +35,7 @@ export const UsersRt = rt.array(UserRt); export type User = rt.TypeOf; export type UserWithProfileInfo = rt.TypeOf; -export const GetCaseUsersResponseRt = rt.type({ +export const GetCaseUsersResponseRt = rt.strict({ assignees: rt.array(UserWithProfileInfoRt), unassignedUsers: rt.array(UserWithProfileInfoRt), participants: rt.array(UserWithProfileInfoRt), diff --git a/x-pack/plugins/cases/common/files/index.test.ts b/x-pack/plugins/cases/common/files/index.test.ts index 6f8770b268f97..e09a5f682633a 100644 --- a/x-pack/plugins/cases/common/files/index.test.ts +++ b/x-pack/plugins/cases/common/files/index.test.ts @@ -6,6 +6,7 @@ */ import { + CaseFileMetadataForDeletionRt, constructFileKindIdByOwner, constructFilesHttpOperationTag, constructOwnerFromFileKind, @@ -63,4 +64,31 @@ describe('files index', () => { expect(constructOwnerFromFileKind('casesFilesCases')).toEqual('cases'); }); }); + + describe('CaseFileMetadataForDeletionRt', () => { + const defaultRequest = { + caseIds: ['case-id-1', 'case-id-2'], + }; + + it('has expected attributes in request', () => { + const query = CaseFileMetadataForDeletionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CaseFileMetadataForDeletionRt.decode({ + caseIds: ['case-id-1', 'case-id-2'], + foo: 'bar', + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/files/index.ts b/x-pack/plugins/cases/common/files/index.ts index 2e72430a24d88..4715ac2120e3e 100644 --- a/x-pack/plugins/cases/common/files/index.ts +++ b/x-pack/plugins/cases/common/files/index.ts @@ -14,7 +14,7 @@ import type { HttpApiTagOperation, Owner } from '../constants/types'; * This type is only used to validate for deletion, it does not check all the fields that should exist in the file * metadata. */ -export const CaseFileMetadataForDeletionRt = rt.type({ +export const CaseFileMetadataForDeletionRt = rt.strict({ caseIds: rt.array(rt.string), }); diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 3f623795c819c..7b661a73f6ffd 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -150,7 +150,7 @@ describe('Cases API', () => { }); describe('resolveCase', () => { - const targetAliasId = '12345'; + const aliasTargetId = '12345'; const basicResolveCase = { outcome: 'aliasMatch', case: basicCaseSnake, @@ -159,7 +159,7 @@ describe('Cases API', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue({ ...basicResolveCase, target_alias_id: targetAliasId }); + fetchMock.mockResolvedValue({ ...basicResolveCase, alias_target_id: aliasTargetId }); }); it('should be called with correct check url, method, signal', async () => { @@ -173,21 +173,21 @@ describe('Cases API', () => { it('should return correct response', async () => { const resp = await resolveCase(caseId, true, abortCtrl.signal); - expect(resp).toEqual({ ...basicResolveCase, case: basicCase, targetAliasId }); + expect(resp).toEqual({ ...basicResolveCase, case: basicCase, aliasTargetId }); }); it('should not covert to camel case registered attachments', async () => { fetchMock.mockResolvedValue({ ...basicResolveCase, case: caseWithRegisteredAttachmentsSnake, - target_alias_id: targetAliasId, + alias_target_id: aliasTargetId, }); const resp = await resolveCase(caseId, true, abortCtrl.signal); expect(resp).toEqual({ ...basicResolveCase, case: caseWithRegisteredAttachments, - targetAliasId, + aliasTargetId, }); }); }); diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index db94167caead4..000679b07337a 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -414,7 +414,6 @@ export const pushedCase: CaseUI = { const basicAction = { createdAt: basicCreatedAt, createdBy: elasticUser, - caseId: basicCaseId, commentId: null, owner: SECURITY_SOLUTION_OWNER, payload: { title: 'a title' }, @@ -770,7 +769,16 @@ export const caseUserActionsWithRegisteredAttachmentsSnake: UserActions = [ type: 'comment', action: 'create', id: 'create-comment-id', - payload: { comment: externalReferenceAttachmentSnake }, + payload: { + comment: { + type: CommentType.externalReference, + externalReferenceId: 'my-id', + externalReferenceMetadata: { test_foo: 'foo' }, + externalReferenceAttachmentTypeId: '.test', + externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc }, + owner: SECURITY_SOLUTION_OWNER, + }, + }, version: 'WzQ3LDFc', }, { @@ -781,7 +789,14 @@ export const caseUserActionsWithRegisteredAttachmentsSnake: UserActions = [ type: 'comment', action: 'create', id: 'create-comment-id', - payload: { comment: persistableStateAttachmentSnake }, + payload: { + comment: { + type: CommentType.persistableState, + persistableStateAttachmentState: { test_foo: 'foo' }, + persistableStateAttachmentTypeId: '.test', + owner: SECURITY_SOLUTION_OWNER, + }, + }, version: 'WzQ3LDFc', }, ]; @@ -876,7 +891,16 @@ export const caseUserActionsWithRegisteredAttachments: UserActionUI[] = [ type: 'comment', action: 'create', id: 'create-comment-id', - payload: { comment: externalReferenceAttachment }, + payload: { + comment: { + type: CommentType.externalReference, + externalReferenceId: 'my-id', + externalReferenceMetadata: { test_foo: 'foo' }, + externalReferenceAttachmentTypeId: '.test', + externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc }, + owner: SECURITY_SOLUTION_OWNER, + }, + }, version: 'WzQ3LDFc', }, { @@ -887,7 +911,14 @@ export const caseUserActionsWithRegisteredAttachments: UserActionUI[] = [ type: 'comment', action: 'create', id: 'create-comment-id', - payload: { comment: persistableStateAttachment }, + payload: { + comment: { + type: CommentType.persistableState, + persistableStateAttachmentState: { test_foo: 'foo' }, + persistableStateAttachmentTypeId: '.test', + owner: SECURITY_SOLUTION_OWNER, + }, + }, version: 'WzQ3LDFc', }, ]; diff --git a/x-pack/plugins/cases/server/client/attachments/add.test.ts b/x-pack/plugins/cases/server/client/attachments/add.test.ts new file mode 100644 index 0000000000000..0e39ff30a65ec --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/add.test.ts @@ -0,0 +1,25 @@ +/* + * 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 { comment } from '../../mocks'; +import { createCasesClientMockArgs } from '../mocks'; +import { addComment } from './add'; + +describe('addComment', () => { + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws with excess fields', async () => { + await expect( + // @ts-expect-error: excess attribute + addComment({ comment: { ...comment, foo: 'bar' }, caseId: 'test-case' }, clientArgs) + ).rejects.toThrow('invalid keys "foo"'); + }); +}); diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index ae4d4aab68e1d..d3b4a088f8d4a 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -5,15 +5,10 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - import { SavedObjectsUtils } from '@kbn/core/server'; import type { Case } from '../../../common/api'; -import { CommentRequestRt, throwErrors } from '../../../common/api'; +import { CommentRequestRt, decodeWithExcessOrThrow } from '../../../common/api'; import { CaseCommentModel } from '../../common/models'; import { createCaseError } from '../../common/error'; @@ -31,10 +26,8 @@ import { validateRegisteredAttachments } from './validators'; */ export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs): Promise => { const { comment, caseId } = addArgs; - const query = pipe( - CommentRequestRt.decode(comment), - fold(throwErrors(Boom.badRequest), identity) - ); + + const query = decodeWithExcessOrThrow(CommentRequestRt)(comment); const { logger, diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts b/x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts new file mode 100644 index 0000000000000..7d9cdcf150a20 --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts @@ -0,0 +1,25 @@ +/* + * 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 { comment } from '../../mocks'; +import { createCasesClientMockArgs } from '../mocks'; +import { bulkCreate } from './bulk_create'; + +describe('bulkCreate', () => { + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws with excess fields', async () => { + await expect( + // @ts-expect-error: excess attribute + bulkCreate({ attachments: [{ ...comment, foo: 'bar' }], caseId: 'test-case' }, clientArgs) + ).rejects.toThrow('invalid keys "foo"'); + }); +}); diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_create.ts b/x-pack/plugins/cases/server/client/attachments/bulk_create.ts index e989410a7a389..dfa5b71bdbd55 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_create.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_create.ts @@ -5,15 +5,10 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - import { SavedObjectsUtils } from '@kbn/core/server'; import type { Case, CommentRequest } from '../../../common/api'; -import { BulkCreateCommentRequestRt, throwErrors } from '../../../common/api'; +import { BulkCreateCommentRequestRt, decodeWithExcessOrThrow } from '../../../common/api'; import { CaseCommentModel } from '../../common/models'; import { createCaseError } from '../../common/error'; @@ -36,10 +31,7 @@ export const bulkCreate = async ( ): Promise => { const { attachments, caseId } = args; - pipe( - BulkCreateCommentRequestRt.decode(attachments), - fold(throwErrors(Boom.badRequest), identity) - ); + decodeWithExcessOrThrow(BulkCreateCommentRequestRt)(attachments); const { logger, diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts b/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts index 9f9aaaf59c736..87f4176f15304 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts @@ -6,9 +6,6 @@ */ import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import type { PromiseResult, PromiseRejectedResult } from 'p-settle'; import pSettle from 'p-settle'; @@ -17,7 +14,7 @@ import type { Logger } from '@kbn/core/server'; import type { File, FileJSON } from '@kbn/files-plugin/common'; import type { FileServiceStart } from '@kbn/files-plugin/server'; import { FileNotFoundError } from '@kbn/files-plugin/server/file_service/errors'; -import { BulkDeleteFileAttachmentsRequestRt, excess, throwErrors } from '../../../common/api'; +import { BulkDeleteFileAttachmentsRequestRt, decodeWithExcessOrThrow } from '../../../common/api'; import { MAX_CONCURRENT_SEARCHES } from '../../../common/constants'; import type { CasesClientArgs } from '../types'; import { createCaseError } from '../../common/error'; @@ -41,10 +38,7 @@ export const bulkDeleteFileAttachments = async ( } = clientArgs; try { - const request = pipe( - excess(BulkDeleteFileAttachmentsRequestRt).decode({ ids: fileIds }), - fold(throwErrors(Boom.badRequest), identity) - ); + const request = decodeWithExcessOrThrow(BulkDeleteFileAttachmentsRequestRt)({ ids: fileIds }); await casesClient.cases.resolve({ id: caseId, includeComments: false }); diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_get.ts b/x-pack/plugins/cases/server/client/attachments/bulk_get.ts index 342cf8af2713e..0dca750c11651 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_get.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_get.ts @@ -6,16 +6,12 @@ */ import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import { partition } from 'lodash'; import { MAX_BULK_GET_ATTACHMENTS } from '../../../common/constants'; import type { BulkGetAttachmentsResponse, CommentAttributes } from '../../../common/api'; import { - excess, - throwErrors, + decodeWithExcessOrThrow, BulkGetAttachmentsResponseRt, BulkGetAttachmentsRequestRt, } from '../../../common/api'; @@ -46,10 +42,7 @@ export async function bulkGet( } = clientArgs; try { - const request = pipe( - excess(BulkGetAttachmentsRequestRt).decode({ ids: attachmentIDs }), - fold(throwErrors(Boom.badRequest), identity) - ); + const request = decodeWithExcessOrThrow(BulkGetAttachmentsRequestRt)({ ids: attachmentIDs }); throwErrorIfIdsExceedTheLimit(request.ids); diff --git a/x-pack/plugins/cases/server/client/attachments/delete.ts b/x-pack/plugins/cases/server/client/attachments/delete.ts index 718ff4eb7e56c..bd14bd3f7fd3a 100644 --- a/x-pack/plugins/cases/server/client/attachments/delete.ts +++ b/x-pack/plugins/cases/server/client/attachments/delete.ts @@ -8,7 +8,7 @@ import Boom from '@hapi/boom'; import type { CommentRequest, CommentRequestAlertType } from '../../../common/api'; -import { Actions, ActionTypes } from '../../../common/api'; +import { Actions, ActionTypes, CommentRequestRt, decodeOrThrow } from '../../../common/api'; import { CASE_SAVED_OBJECT } from '../../../common/constants'; import { getAlertInfoFromComments, isCommentRequestTypeAlert } from '../../common/utils'; import type { CasesClientArgs } from '../types'; @@ -115,12 +115,17 @@ export async function deleteComment( refresh: false, }); + // we only want to store the fields related to the original request of the attachment, not fields like + // created_at etc. So we'll use the decode to strip off the other fields. This is necessary because we don't know + // what type of attachment this is. Depending on the type it could have various fields. + const attachmentRequestAttributes = decodeOrThrow(CommentRequestRt)(attachment.attributes); + await userActionService.creator.createUserAction({ type: ActionTypes.comment, action: Actions.delete, caseId: id, attachmentId: attachmentID, - payload: { attachment: { ...attachment.attributes } }, + payload: { attachment: attachmentRequestAttributes }, user, owner: attachment.attributes.owner, }); diff --git a/x-pack/plugins/cases/server/client/attachments/get.test.tsx b/x-pack/plugins/cases/server/client/attachments/get.test.tsx index d7cdfc516476a..c289b4b8f6edb 100644 --- a/x-pack/plugins/cases/server/client/attachments/get.test.tsx +++ b/x-pack/plugins/cases/server/client/attachments/get.test.tsx @@ -16,13 +16,6 @@ describe('get', () => { jest.clearAllMocks(); }); - it('Invalid fields result in error', async () => { - expect(() => - // @ts-expect-error - findComment({ caseID: 'mock-id', foo: 'bar' }, clientArgs) - ).rejects.toThrow('excess properties: ["foo"]'); - }); - it('Invalid total items results in error', async () => { await expect(() => findComment({ caseID: 'mock-id', queryParams: { page: 2, perPage: 9001 } }, clientArgs) @@ -30,5 +23,15 @@ describe('get', () => { 'The number of documents is too high. Paginating through more than 10,000 documents is not possible.' ); }); + + it('throws with excess fields', async () => { + await expect( + findComment( + // @ts-expect-error: excess attribute + { caseID: 'mock-id', queryParams: { page: 2, perPage: 9001 }, foo: 'bar' }, + clientArgs + ) + ).rejects.toThrow('invalid keys "foo"'); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/attachments/get.ts b/x-pack/plugins/cases/server/client/attachments/get.ts index 66e00384a8a1f..d866350ec8298 100644 --- a/x-pack/plugins/cases/server/client/attachments/get.ts +++ b/x-pack/plugins/cases/server/client/attachments/get.ts @@ -5,11 +5,6 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - import type { SavedObject } from '@kbn/core/server'; import type { CasesClient } from '../client'; @@ -30,8 +25,7 @@ import { CommentsRt, CommentRt, CommentsFindResponseRt, - excess, - throwErrors, + decodeWithExcessOrThrow, } from '../../../common/api'; import { defaultSortField, @@ -123,10 +117,7 @@ export async function find( authorization, } = clientArgs; - const { caseID, queryParams } = pipe( - excess(FindCommentsArgsRt).decode(data), - fold(throwErrors(Boom.badRequest), identity) - ); + const { caseID, queryParams } = decodeWithExcessOrThrow(FindCommentsArgsRt)(data); validateFindCommentsPagination(queryParams); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_get.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_get.test.ts index b20101adbd6ba..0ee8955c9adb5 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_get.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_get.test.ts @@ -23,5 +23,15 @@ describe('bulkGet', () => { 'Maximum request limit of 1000 cases reached' ); }); + + it('throws with excess fields', async () => { + await expect( + bulkGet( + // @ts-expect-error: excess attribute + { ids: ['1'], foo: 'bar' }, + clientArgs + ) + ).rejects.toThrow('invalid keys "foo"'); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_get.ts b/x-pack/plugins/cases/server/client/cases/bulk_get.ts index 9a8f8012b8ede..2ab6000bc7fd2 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_get.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_get.ts @@ -6,9 +6,6 @@ */ import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import { partition } from 'lodash'; import { MAX_BULK_GET_CASES } from '../../../common/constants'; @@ -19,8 +16,7 @@ import type { } from '../../../common/api'; import { CasesBulkGetRequestRt, - excess, - throwErrors, + decodeWithExcessOrThrow, CasesBulkGetResponseRt, } from '../../../common/api'; import { createCaseError } from '../../common/error'; @@ -46,10 +42,7 @@ export const bulkGet = async ( } = clientArgs; try { - const request = pipe( - excess(CasesBulkGetRequestRt).decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); + const request = decodeWithExcessOrThrow(CasesBulkGetRequestRt)(params); throwErrorIfCaseIdsReachTheLimit(request.ids); diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index fe814be543e2e..648f2bb4c2b05 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -77,4 +77,20 @@ describe('create', () => { }); }); }); + + describe('Attributes', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw an error with an excess field exists', async () => { + await expect( + // @ts-expect-error foo is an invalid field + create({ ...theCase, foo: 'bar' }, clientArgs) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"foo\\""`); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 1d1a687ee9998..c417c89bfb600 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -6,20 +6,16 @@ */ import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import { SavedObjectsUtils } from '@kbn/core/server'; import type { Case, CasePostRequest } from '../../../common/api'; import { - throwErrors, CaseRt, ActionTypes, CasePostRequestRt, - excess, CaseSeverity, + decodeWithExcessOrThrow, } from '../../../common/api'; import { MAX_ASSIGNEES_PER_CASE, MAX_TITLE_LENGTH } from '../../../common/constants'; import { isInvalidTag, areTotalAssigneesInvalid } from '../../../common/utils/validators'; @@ -43,12 +39,7 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs) authorization: auth, } = clientArgs; - const query = pipe( - excess(CasePostRequestRt).decode({ - ...data, - }), - fold(throwErrors(Boom.badRequest), identity) - ); + const query = decodeWithExcessOrThrow(CasePostRequestRt)(data); if (query.title.length > MAX_TITLE_LENGTH) { throw Boom.badRequest( diff --git a/x-pack/plugins/cases/server/client/cases/find.test.ts b/x-pack/plugins/cases/server/client/cases/find.test.ts index ad791dfb9d21a..1e5afd2c87b36 100644 --- a/x-pack/plugins/cases/server/client/cases/find.test.ts +++ b/x-pack/plugins/cases/server/client/cases/find.test.ts @@ -62,6 +62,17 @@ describe('find', () => { expect(call.caseOptions.search).toBe(search); expect(call.caseOptions).not.toHaveProperty('rootSearchFields'); }); + + it('should not have foo:bar attribute in request payload', async () => { + const search = 'sample_text'; + const findRequest = createCasesClientMockFindRequest({ search }); + await expect( + // @ts-expect-error foo is an invalid field + find({ ...findRequest, foo: 'bar' }, clientArgs) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find cases: {\\"search\\":\\"sample_text\\",\\"searchFields\\":[\\"title\\",\\"description\\"],\\"severity\\":\\"low\\",\\"assignees\\":[],\\"reporters\\":[],\\"status\\":\\"open\\",\\"tags\\":[],\\"owner\\":[],\\"sortField\\":\\"createdAt\\",\\"sortOrder\\":\\"desc\\",\\"foo\\":\\"bar\\"}: Error: invalid keys \\"foo\\""` + ); + }); }); describe('searchFields errors', () => { diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index 42b14dcaa7cf8..96d9c3dc9f49b 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -7,12 +7,13 @@ import { isEmpty } from 'lodash'; import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import type { CasesFindResponse, CasesFindRequest } from '../../../common/api'; -import { CasesFindRequestRt, throwErrors, CasesFindResponseRt, excess } from '../../../common/api'; +import { + CasesFindRequestRt, + decodeWithExcessOrThrow, + CasesFindResponseRt, +} from '../../../common/api'; import { createCaseError } from '../../common/error'; import { asArray, transformCases } from '../../common/utils'; @@ -40,10 +41,7 @@ export const find = async ( } = clientArgs; try { - const queryParams = pipe( - excess(CasesFindRequestRt).decode({ ...params }), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(CasesFindRequestRt)(params); const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter(Operations.findCases); diff --git a/x-pack/plugins/cases/server/client/cases/get.test.ts b/x-pack/plugins/cases/server/client/cases/get.test.ts new file mode 100644 index 0000000000000..c95315667e10e --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/get.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { createCasesClientMockArgs } from '../mocks'; +import { getCasesByAlertID, getTags, getReporters } from './get'; + +describe('get', () => { + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getCasesByAlertID', () => { + it('throws with excess fields', async () => { + await expect( + getCasesByAlertID( + // @ts-expect-error: excess attribute + { options: { owner: 'cases', foo: 'bar' }, alertID: 'test-alert' }, + clientArgs + ) + ).rejects.toThrow('invalid keys "foo"'); + }); + }); + + describe('getTags', () => { + it('throws with excess fields', async () => { + // @ts-expect-error: excess attribute + await expect(getTags({ owner: 'cases', foo: 'bar' }, clientArgs)).rejects.toThrow( + 'invalid keys "foo"' + ); + }); + }); + + describe('getReporters', () => { + it('throws with excess fields', async () => { + // @ts-expect-error: excess attribute + await expect(getReporters({ owner: 'cases', foo: 'bar' }, clientArgs)).rejects.toThrow( + 'invalid keys "foo"' + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 88668f04f7f36..d198a6e0a5ce3 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -4,10 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import type { SavedObjectsResolveResponse } from '@kbn/core/server'; import type { @@ -25,8 +21,7 @@ import { CaseRt, CaseResolveResponseRt, AllTagsFindRequestRt, - excess, - throwErrors, + decodeWithExcessOrThrow, AllReportersFindRequestRt, CasesByAlertIDRequestRt, CasesByAlertIdRt, @@ -70,10 +65,7 @@ export const getCasesByAlertID = async ( } = clientArgs; try { - const queryParams = pipe( - excess(CasesByAlertIDRequestRt).decode(options), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(CasesByAlertIDRequestRt)(options); const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter(Operations.getCaseIDsByAlertID); @@ -299,10 +291,7 @@ export async function getTags( } = clientArgs; try { - const queryParams = pipe( - excess(AllTagsFindRequestRt).decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(AllTagsFindRequestRt)(params); const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( Operations.findCases @@ -336,10 +325,7 @@ export async function getReporters( } = clientArgs; try { - const queryParams = pipe( - excess(AllReportersFindRequestRt).decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(AllReportersFindRequestRt)(params); const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( Operations.getReporters diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/update.test.ts index ab80618903fa0..db463b6f22e2b 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/update.test.ts @@ -249,5 +249,24 @@ describe('update', () => { expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([]); expect(clientArgs.services.notificationService.notifyAssignees).not.toHaveBeenCalled(); }); + + it('should throw an error when an invalid field is included in the request payload', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + assignees: [{ uid: '1' }], + // @ts-expect-error invalid field + foo: 'bar', + }, + ], + }, + clientArgs + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"foo\\""`); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index 07ed76ba441c6..992ed1629521d 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -6,9 +6,6 @@ */ import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import type { SavedObject, @@ -36,8 +33,7 @@ import { CasesRt, CaseStatuses, CommentType, - excess, - throwErrors, + decodeWithExcessOrThrow, } from '../../../common/api'; import { CASE_COMMENT_SAVED_OBJECT, @@ -317,10 +313,7 @@ export const update = async ( authorization, } = clientArgs; - const query = pipe( - excess(CasesPatchRequestRt).decode(cases), - fold(throwErrors(Boom.badRequest), identity) - ); + const query = decodeWithExcessOrThrow(CasesPatchRequestRt)(cases); try { const myCases = await caseService.getCases({ diff --git a/x-pack/plugins/cases/server/client/configure/client.test.ts b/x-pack/plugins/cases/server/client/configure/client.test.ts index 7ee4bddf1d5a5..e344273b51baa 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -8,9 +8,17 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; import type { CasesClientArgs } from '../types'; -import { getConnectors } from './client'; +import { getConnectors, get, update } from './client'; +import { createCasesClientInternalMock, createCasesClientMockArgs } from '../mocks'; describe('client', () => { + const clientArgs = createCasesClientMockArgs(); + const casesClientInternal = createCasesClientInternalMock(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('getConnectors', () => { const logger = loggingSystemMock.createLogger(); const actionsClient = actionsClientMock.create(); @@ -199,4 +207,22 @@ describe('client', () => { ]); }); }); + + describe('get', () => { + it('throws with excess fields', async () => { + await expect( + // @ts-expect-error: excess attribute + get({ owner: 'cases', foo: 'bar' }, clientArgs, casesClientInternal) + ).rejects.toThrow('invalid keys "foo"'); + }); + }); + + describe('update', () => { + it('throws with excess fields', async () => { + await expect( + // @ts-expect-error: excess attribute + update('test-id', { version: 'test-version', foo: 'bar' }, clientArgs, casesClientInternal) + ).rejects.toThrow('invalid keys "foo"'); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index a3645563882a5..d53e8a2c91535 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -7,9 +7,6 @@ import pMap from 'p-map'; import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import type { SavedObject, SavedObjectsFindResponse } from '@kbn/core/server'; import { SavedObjectsUtils } from '@kbn/core/server'; @@ -30,9 +27,8 @@ import { ConfigurationsRt, ConfigurationRt, ConfigurationPatchRequestRt, - excess, GetConfigurationFindRequestRt, - throwErrors, + decodeWithExcessOrThrow, } from '../../../common/api'; import { MAX_CONCURRENT_SEARCHES } from '../../../common/constants'; import { createCaseError } from '../../common/error'; @@ -134,7 +130,7 @@ export const createConfigurationSubClient = ( }); }; -async function get( +export async function get( params: GetConfigurationFindRequest, clientArgs: CasesClientArgs, casesClientInternal: CasesClientInternal @@ -146,10 +142,7 @@ async function get( authorization, } = clientArgs; try { - const queryParams = pipe( - excess(GetConfigurationFindRequestRt).decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(GetConfigurationFindRequestRt)(params); const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter(Operations.findConfigurations); @@ -240,7 +233,7 @@ function isConnectorSupported( ); } -async function update( +export async function update( configurationId: string, req: ConfigurationPatchRequest, clientArgs: CasesClientArgs, @@ -255,25 +248,10 @@ async function update( } = clientArgs; try { - const request = pipe( - ConfigurationPatchRequestRt.decode(req), - fold(throwErrors(Boom.badRequest), identity) - ); + const request = decodeWithExcessOrThrow(ConfigurationPatchRequestRt)(req); const { version, ...queryWithoutVersion } = request; - /** - * Excess function does not supports union or intersection types. - * For that reason we need to check manually for excess properties - * in the partial attributes. - * - * The owner attribute should not be allowed. - */ - pipe( - excess(ConfigurationPatchRequestRt.types[0]).decode(queryWithoutVersion), - fold(throwErrors(Boom.badRequest), identity) - ); - const configuration = await caseConfigureService.get({ unsecuredSavedObjectsClient, configurationId, diff --git a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.test.ts b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.test.ts index 2e043a8fedf5f..0aca8ad914144 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.test.ts @@ -32,6 +32,13 @@ describe('getCasesMetrics', () => { }); }); + it('throws with excess fields', async () => { + await expect( + // @ts-expect-error: excess attribute + getCasesMetrics({ features: ['mttr'], foo: 'bar' }, client, clientArgs) + ).rejects.toThrow('invalid keys "foo"'); + }); + it('returns the mttr metric', async () => { const metrics = await getCasesMetrics({ features: ['mttr'] }, client, clientArgs); expect(metrics).toEqual({ mttr: 5 }); diff --git a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts index 49a88bfbdb6e5..360ca5dacea75 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts @@ -6,13 +6,13 @@ */ import { merge } from 'lodash'; -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import type { CasesMetricsRequest, CasesMetricsResponse } from '../../../common/api'; -import { CasesMetricsRequestRt, CasesMetricsResponseRt, throwErrors } from '../../../common/api'; +import { + CasesMetricsRequestRt, + CasesMetricsResponseRt, + decodeWithExcessOrThrow, +} from '../../../common/api'; import { createCaseError } from '../../common/error'; import type { CasesClient } from '../client'; import type { CasesClientArgs } from '../types'; @@ -25,10 +25,7 @@ export const getCasesMetrics = async ( ): Promise => { const { logger } = clientArgs; - const queryParams = pipe( - CasesMetricsRequestRt.decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(CasesMetricsRequestRt)(params); try { const handlers = buildHandlers(queryParams, casesClient, clientArgs); diff --git a/x-pack/plugins/cases/server/client/metrics/get_status_totals.test.ts b/x-pack/plugins/cases/server/client/metrics/get_status_totals.test.ts index 957c4fdc3bbe5..a3cce4413edde 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_status_totals.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_status_totals.test.ts @@ -31,6 +31,13 @@ describe('getStatusTotalsByType', () => { }); }); + it('throws with excess fields', async () => { + await expect( + // @ts-expect-error: excess attribute + getStatusTotalsByType({ foo: 'bar' }, clientArgs) + ).rejects.toThrow('invalid keys "foo"'); + }); + it('returns the status correctly', async () => { const metrics = await getStatusTotalsByType({}, clientArgs); expect(metrics).toEqual({ diff --git a/x-pack/plugins/cases/server/client/metrics/get_status_totals.ts b/x-pack/plugins/cases/server/client/metrics/get_status_totals.ts index c04f31cf9ac94..07d4c640695b3 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_status_totals.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_status_totals.ts @@ -5,16 +5,10 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - import type { CasesStatusRequest, CasesStatusResponse } from '../../../common/api'; import { - excess, CasesStatusRequestRt, - throwErrors, + decodeWithExcessOrThrow, CasesStatusResponseRt, } from '../../../common/api'; import type { CasesClientArgs } from '../types'; @@ -33,10 +27,7 @@ export async function getStatusTotalsByType( } = clientArgs; try { - const queryParams = pipe( - excess(CasesStatusRequestRt).decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(CasesStatusRequestRt)(params); const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( Operations.getCaseStatuses diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 91ebbab569fd1..115cd2decbaef 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -16,10 +16,10 @@ import { makeLensEmbeddableFactory } from '@kbn/lens-plugin/server/embeddable/ma import { serializerMock } from '@kbn/core-saved-objects-base-server-mocks'; import type { CasesFindRequest } from '../../common/api'; -import type { CasesClient } from '.'; +import type { CasesClient, CasesClientInternal } from '.'; import type { AttachmentsSubClient } from './attachments/client'; import type { CasesSubClient } from './cases/client'; -import type { ConfigureSubClient } from './configure/client'; +import type { ConfigureSubClient, InternalConfigureSubClient } from './configure/client'; import type { CasesClientFactory } from './factory'; import type { MetricsSubClient } from './metrics/client'; import type { UserActionsSubClient } from './user_actions/client'; @@ -112,6 +112,16 @@ const createConfigureSubClientMock = (): ConfigureSubClientMock => { }; }; +type InternalConfigureSubClientMock = jest.Mocked; + +const createInternalConfigureSubClientMock = (): InternalConfigureSubClientMock => { + return { + getMappings: jest.fn(), + createMappings: jest.fn(), + updateMappings: jest.fn(), + }; +}; + export interface CasesClientMock extends CasesClient { cases: CasesSubClientMock; attachments: AttachmentsSubClientMock; @@ -129,6 +139,16 @@ export const createCasesClientMock = (): CasesClientMock => { return client as unknown as CasesClientMock; }; +type CasesClientInternalMock = jest.Mocked; + +export const createCasesClientInternalMock = (): CasesClientInternalMock => { + const client: PublicContract = { + configuration: createInternalConfigureSubClientMock(), + }; + + return client as unknown as CasesClientInternalMock; +}; + export type CasesClientFactoryMock = jest.Mocked; export const createCasesClientFactory = (): CasesClientFactoryMock => { diff --git a/x-pack/plugins/cases/server/client/user_actions/find.test.ts b/x-pack/plugins/cases/server/client/user_actions/find.test.ts new file mode 100644 index 0000000000000..a8df2782117ce --- /dev/null +++ b/x-pack/plugins/cases/server/client/user_actions/find.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMockClient } from '../metrics/test_utils/client'; +import { createCasesClientMockArgs } from '../mocks'; +import { find } from './find'; + +describe('addComment', () => { + const client = createMockClient(); + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws with excess fields', async () => { + await expect( + // @ts-expect-error: excess attribute + find({ caseId: 'test-case', params: { foo: 'bar' } }, client, clientArgs) + ).rejects.toThrow('invalid keys "foo"'); + }); +}); diff --git a/x-pack/plugins/cases/server/client/user_actions/find.ts b/x-pack/plugins/cases/server/client/user_actions/find.ts index f52c821aa9367..788595238bb17 100644 --- a/x-pack/plugins/cases/server/client/user_actions/find.ts +++ b/x-pack/plugins/cases/server/client/user_actions/find.ts @@ -5,16 +5,10 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - import type { UserActionFindResponse } from '../../../common/api'; import { UserActionFindRequestRt, - throwErrors, - excess, + decodeWithExcessOrThrow, UserActionFindResponseRt, } from '../../../common/api'; import type { CasesClientArgs } from '../types'; @@ -40,10 +34,7 @@ export const find = async ( // supertest and query-string encode a single entry in an array as just a string so make sure we have an array const types = asArray(params.types); - const queryParams = pipe( - excess(UserActionFindRequestRt).decode({ ...params, types }), - fold(throwErrors(Boom.badRequest), identity) - ); + const queryParams = decodeWithExcessOrThrow(UserActionFindRequestRt)({ ...params, types }); const [authorizationFilterRes] = await Promise.all([ authorization.getAuthorizationFilter(Operations.findUserActions), diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index 82be4c9225930..ace2fce5fa479 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -8,9 +8,6 @@ import { badRequest } from '@hapi/boom'; import { get, isPlainObject, differenceWith, isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; import { validate as uuidValidate } from 'uuid'; import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server'; @@ -34,12 +31,11 @@ import { AlertCommentRequestRt, ActionsCommentRequestRt, ContextTypeUserRt, - excess, - throwErrors, ExternalReferenceStorageType, ExternalReferenceSORt, ExternalReferenceNoSORt, PersistableStateAttachmentRt, + decodeWithExcessOrThrow, } from '../../common/api'; import { CASE_SAVED_OBJECT, NO_ASSIGNEES_FILTERING_KEYWORD } from '../../common/constants'; import { @@ -57,16 +53,18 @@ import { } from '../common/utils'; import type { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; +// TODO: I think we can remove most of this function since we're using a different excess export const decodeCommentRequest = ( comment: CommentRequest, externalRefRegistry: ExternalReferenceAttachmentTypeRegistry ) => { if (isCommentRequestTypeUser(comment)) { - pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); + decodeWithExcessOrThrow(ContextTypeUserRt)(comment); } else if (isCommentRequestTypeActions(comment)) { - pipe(excess(ActionsCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); + decodeWithExcessOrThrow(ActionsCommentRequestRt)(comment); } else if (isCommentRequestTypeAlert(comment)) { - pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); + decodeWithExcessOrThrow(AlertCommentRequestRt)(comment); + const { ids, indices } = getIDsAndIndicesAsArrays(comment); /** @@ -112,10 +110,7 @@ export const decodeCommentRequest = ( } else if (isCommentRequestTypeExternalReference(comment)) { decodeExternalReferenceAttachment(comment, externalRefRegistry); } else if (isCommentRequestTypePersistableState(comment)) { - pipe( - excess(PersistableStateAttachmentRt).decode(comment), - fold(throwErrors(badRequest), identity) - ); + decodeWithExcessOrThrow(PersistableStateAttachmentRt)(comment); } else { /** * This assertion ensures that TS will show an error @@ -131,12 +126,9 @@ const decodeExternalReferenceAttachment = ( externalRefRegistry: ExternalReferenceAttachmentTypeRegistry ) => { if (attachment.externalReferenceStorage.type === ExternalReferenceStorageType.savedObject) { - pipe(excess(ExternalReferenceSORt).decode(attachment), fold(throwErrors(badRequest), identity)); + decodeWithExcessOrThrow(ExternalReferenceSORt)(attachment); } else { - pipe( - excess(ExternalReferenceNoSORt).decode(attachment), - fold(throwErrors(badRequest), identity) - ); + decodeWithExcessOrThrow(ExternalReferenceNoSORt)(attachment); } const metadata = attachment.externalReferenceMetadata; diff --git a/x-pack/plugins/cases/server/common/models/case_with_comments.test.ts b/x-pack/plugins/cases/server/common/models/case_with_comments.test.ts index 801f7bb9494e4..94b3ac3e3be3d 100644 --- a/x-pack/plugins/cases/server/common/models/case_with_comments.test.ts +++ b/x-pack/plugins/cases/server/common/models/case_with_comments.test.ts @@ -7,37 +7,14 @@ import type { AttributesTypeAlerts } from '../../../common/api'; import type { SavedObject } from '@kbn/core-saved-objects-api-server'; -import { CommentType, SECURITY_SOLUTION_OWNER } from '../../../common'; import { createCasesClientMockArgs } from '../../client/mocks'; -import { mockCaseComments, mockCases } from '../../mocks'; +import { alertComment, comment, mockCaseComments, mockCases, multipleAlert } from '../../mocks'; import { CaseCommentModel } from './case_with_comments'; describe('CaseCommentModel', () => { const theCase = mockCases[0]; const clientArgs = createCasesClientMockArgs(); const createdDate = '2023-04-07T12:18:36.941Z'; - const userComment = { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user as const, - owner: SECURITY_SOLUTION_OWNER, - }; - - const singleAlert = { - type: CommentType.alert as const, - owner: SECURITY_SOLUTION_OWNER, - alertId: 'test-id-1', - index: 'test-index-1', - rule: { - id: 'rule-id-1', - name: 'rule-name-1', - }, - }; - - const multipleAlert = { - ...singleAlert, - alertId: ['test-id-3', 'test-id-4', 'test-id-5'], - index: ['test-index-3', 'test-index-4', 'test-index-5'], - }; clientArgs.services.caseService.getCase.mockResolvedValue(theCase); clientArgs.services.caseService.patchCase.mockResolvedValue(theCase); @@ -65,7 +42,7 @@ describe('CaseCommentModel', () => { it('does not remove comments when filtering out duplicate alerts', async () => { await model.createComment({ id: 'comment-1', - commentReq: userComment, + commentReq: comment, createdDate, }); @@ -74,7 +51,7 @@ describe('CaseCommentModel', () => { Array [ Object { "attributes": Object { - "comment": "Wow, good luck catching that bad meanie!", + "comment": "a comment", "created_at": "2023-04-07T12:18:36.941Z", "created_by": Object { "email": "damaged_raccoon@elastic.co", @@ -107,7 +84,7 @@ describe('CaseCommentModel', () => { it('does not remove alerts not attached to the case', async () => { await model.createComment({ id: 'comment-1', - commentReq: singleAlert, + commentReq: alertComment, createdDate, }); @@ -117,7 +94,7 @@ describe('CaseCommentModel', () => { Object { "attributes": Object { "alertId": Array [ - "test-id-1", + "alert-id-1", ], "created_at": "2023-04-07T12:18:36.941Z", "created_by": Object { @@ -127,7 +104,7 @@ describe('CaseCommentModel', () => { "username": "damaged_raccoon", }, "index": Array [ - "test-index-1", + "alert-index-1", ], "owner": "securitySolution", "pushed_at": null, @@ -279,12 +256,12 @@ describe('CaseCommentModel', () => { it('does not create attachments if the alert is attached to the case', async () => { clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( - new Set(['test-id-1']) + new Set(['alert-id-1']) ); await model.createComment({ id: 'comment-1', - commentReq: singleAlert, + commentReq: alertComment, createdDate, }); @@ -298,11 +275,11 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...userComment, + ...comment, }, { id: 'comment-2', - ...singleAlert, + ...alertComment, }, { id: 'comment-3', @@ -322,9 +299,10 @@ describe('CaseCommentModel', () => { expect(attachments[1].attributes.type).toBe('alert'); expect(attachments[2].attributes.type).toBe('alert'); - expect(singleAlertCall.attributes.alertId).toEqual(['test-id-1']); - expect(singleAlertCall.attributes.index).toEqual(['test-index-1']); + expect(singleAlertCall.attributes.alertId).toEqual(['alert-id-1']); + expect(singleAlertCall.attributes.index).toEqual(['alert-index-1']); + // test-id-4 is omitted because it is returned by getAllAlertIds, see the top of this file expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); }); @@ -334,7 +312,7 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...singleAlert, + ...alertComment, }, ], }); @@ -344,8 +322,8 @@ describe('CaseCommentModel', () => { expect(attachments.length).toBe(1); expect(attachments[0].attributes.type).toBe('alert'); - expect(attachments[0].attributes.alertId).toEqual(['test-id-1']); - expect(attachments[0].attributes.index).toEqual(['test-index-1']); + expect(attachments[0].attributes.alertId).toEqual(['alert-id-1']); + expect(attachments[0].attributes.index).toEqual(['alert-index-1']); }); it('remove alerts attached to the case', async () => { @@ -414,7 +392,7 @@ describe('CaseCommentModel', () => { await model.createComment({ id: 'comment-1', - commentReq: singleAlert, + commentReq: alertComment, createdDate, }); @@ -426,15 +404,15 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...userComment, + ...comment, }, { id: 'comment-2', - ...singleAlert, + ...alertComment, }, { id: 'comment-3', - ...singleAlert, + ...alertComment, }, { id: 'comment-4', @@ -458,8 +436,8 @@ describe('CaseCommentModel', () => { expect(attachments[1].attributes.type).toBe('alert'); expect(attachments[2].attributes.type).toBe('alert'); - expect(singleAlertCall.attributes.alertId).toEqual(['test-id-1']); - expect(singleAlertCall.attributes.index).toEqual(['test-index-1']); + expect(singleAlertCall.attributes.alertId).toEqual(['alert-id-1']); + expect(singleAlertCall.attributes.index).toEqual(['alert-index-1']); expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); @@ -470,17 +448,17 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...userComment, + ...comment, }, { id: 'comment-2', - ...singleAlert, + ...alertComment, }, { id: 'comment-3', ...multipleAlert, - alertId: ['test-id-1', 'test-id-2'], - index: ['test-index-1', 'test-index-2'], + alertId: ['alert-id-1', 'test-id-2'], + index: ['alert-index-1', 'test-index-2'], }, { id: 'comment-4', @@ -504,29 +482,30 @@ describe('CaseCommentModel', () => { expect(attachments[2].attributes.type).toBe('alert'); expect(attachments[3].attributes.type).toBe('alert'); - expect(alertOne.attributes.alertId).toEqual(['test-id-1']); - expect(alertOne.attributes.index).toEqual(['test-index-1']); + expect(alertOne.attributes.alertId).toEqual(['alert-id-1']); + expect(alertOne.attributes.index).toEqual(['alert-index-1']); expect(alertTwo.attributes.alertId).toEqual(['test-id-2']); expect(alertTwo.attributes.index).toEqual(['test-index-2']); + // test-id-4 is omitted because it is returned by getAllAlertIds, see the top of this file expect(alertThree.attributes.alertId).toEqual(['test-id-5']); expect(alertThree.attributes.index).toEqual(['test-index-5']); }); it('remove alerts from multiple attachments with multiple alerts attached to the case', async () => { clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( - new Set(['test-id-1', 'test-id-4']) + new Set(['alert-id-1', 'test-id-4']) ); await model.bulkCreate({ attachments: [ { id: 'comment-1', - ...userComment, + ...comment, }, { id: 'comment-2', - ...singleAlert, + ...alertComment, }, { id: 'comment-3', diff --git a/x-pack/plugins/cases/server/common/types/case.test.ts b/x-pack/plugins/cases/server/common/types/case.test.ts index 521be071016ac..2216b9515425b 100644 --- a/x-pack/plugins/cases/server/common/types/case.test.ts +++ b/x-pack/plugins/cases/server/common/types/case.test.ts @@ -46,7 +46,7 @@ describe('getPartialCaseTransformedAttributesRt', () => { assignees: [], }; const caseTransformedAttributesProps = CaseTransformedAttributesRt.types.reduce( - (acc, type) => ({ ...acc, ...type.props }), + (acc, type) => ({ ...acc, ...type.type.props }), {} ); diff --git a/x-pack/plugins/cases/server/common/types/case.ts b/x-pack/plugins/cases/server/common/types/case.ts index c830abe733a87..7de3b51576fd4 100644 --- a/x-pack/plugins/cases/server/common/types/case.ts +++ b/x-pack/plugins/cases/server/common/types/case.ts @@ -55,7 +55,7 @@ export const CaseTransformedAttributesRt = CaseAttributesRt; export const getPartialCaseTransformedAttributesRt = (): Type> => { const caseTransformedAttributesProps = CaseAttributesRt.types.reduce( - (acc, type) => Object.assign(acc, type.props), + (acc, type) => Object.assign(acc, type.type.props), {} ); diff --git a/x-pack/plugins/cases/server/common/types/configure.test.ts b/x-pack/plugins/cases/server/common/types/configure.test.ts index 8737a32c7d924..dcc05fc42ad78 100644 --- a/x-pack/plugins/cases/server/common/types/configure.test.ts +++ b/x-pack/plugins/cases/server/common/types/configure.test.ts @@ -16,7 +16,7 @@ describe('Configuration', () => { created_at: '123', }); - expect(res).toMatchObject({ + expect(res).toStrictEqual({ created_at: '123', }); }); diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 75ed4f4bc301f..ae1b5fc0764f3 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -31,8 +31,8 @@ export type ConfigurationTransformedAttributes = ConfigurationAttributes; export type ConfigurationSavedObjectTransformed = SavedObject; export const ConfigurationPartialAttributesRt = rt.intersection([ - rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.props)), - rt.exact(rt.partial(ConfigurationActivityFieldsRt.props)), + rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.type.props)), + rt.exact(rt.partial(ConfigurationActivityFieldsRt.type.props)), rt.exact( rt.partial({ owner: rt.string, diff --git a/x-pack/plugins/cases/server/common/types/connector_mappings.test.ts b/x-pack/plugins/cases/server/common/types/connector_mappings.test.ts index 3af1bfe26f00a..2563b6742eba2 100644 --- a/x-pack/plugins/cases/server/common/types/connector_mappings.test.ts +++ b/x-pack/plugins/cases/server/common/types/connector_mappings.test.ts @@ -12,7 +12,7 @@ describe('mappings', () => { describe('ConnectorMappingsPartialRt', () => { it('strips excess fields from the object', () => { const res = decodeOrThrow(ConnectorMappingsPartialRt)({ bananas: 'yes', owner: 'hi' }); - expect(res).toMatchObject({ + expect(res).toStrictEqual({ owner: 'hi', }); }); diff --git a/x-pack/plugins/cases/server/common/types/connector_mappings.ts b/x-pack/plugins/cases/server/common/types/connector_mappings.ts index 88792c779ba93..c1fd7deeb1429 100644 --- a/x-pack/plugins/cases/server/common/types/connector_mappings.ts +++ b/x-pack/plugins/cases/server/common/types/connector_mappings.ts @@ -23,6 +23,6 @@ export interface ConnectorMappingsPersistedAttributes { export type ConnectorMappingsTransformed = ConnectorMappings; export type ConnectorMappingsSavedObjectTransformed = SavedObject; -export const ConnectorMappingsPartialRt = rt.exact(rt.partial(ConnectorMappingsRt.props)); +export const ConnectorMappingsPartialRt = rt.exact(rt.partial(ConnectorMappingsRt.type.props)); export const ConnectorMappingsTransformedRt = ConnectorMappingsRt; diff --git a/x-pack/plugins/cases/server/internal_attachments/index.ts b/x-pack/plugins/cases/server/internal_attachments/index.ts index 4ba9f8892861a..0a3eab9953cd7 100644 --- a/x-pack/plugins/cases/server/internal_attachments/index.ts +++ b/x-pack/plugins/cases/server/internal_attachments/index.ts @@ -6,14 +6,11 @@ */ import { badRequest } from '@hapi/boom'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; + import { - excess, FileAttachmentMetadataRt, FILE_ATTACHMENT_TYPE, - throwErrors, + decodeWithExcessOrThrow, } from '../../common/api'; import type { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; @@ -24,10 +21,7 @@ export const registerInternalAttachments = ( }; const schemaValidator = (data: unknown): void => { - const fileMetadata = pipe( - excess(FileAttachmentMetadataRt).decode(data), - fold(throwErrors(badRequest), identity) - ); + const fileMetadata = decodeWithExcessOrThrow(FileAttachmentMetadataRt)(data); if (fileMetadata.files.length > 1) { throw badRequest('Only a single file can be stored in an attachment'); diff --git a/x-pack/plugins/cases/server/mocks.ts b/x-pack/plugins/cases/server/mocks.ts index 8cef503691bcb..bf135cc6a732c 100644 --- a/x-pack/plugins/cases/server/mocks.ts +++ b/x-pack/plugins/cases/server/mocks.ts @@ -6,7 +6,12 @@ */ import type { SavedObject } from '@kbn/core/server'; -import type { CasePostRequest, CommentAttributes } from '../common/api'; +import type { + CasePostRequest, + CommentAttributes, + CommentRequestAlertType, + CommentRequestUserType, +} from '../common/api'; import { CaseSeverity, CaseStatuses, CommentType, ConnectorTypes } from '../common/api'; import { SECURITY_SOLUTION_OWNER } from '../common/constants'; import type { CasesStart } from './types'; @@ -420,6 +425,29 @@ export const newCase: CasePostRequest = { owner: SECURITY_SOLUTION_OWNER, }; +export const comment: CommentRequestUserType = { + comment: 'a comment', + type: CommentType.user as const, + owner: SECURITY_SOLUTION_OWNER, +}; + +export const alertComment: CommentRequestAlertType = { + alertId: 'alert-id-1', + index: 'alert-index-1', + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, + type: CommentType.alert as const, + owner: SECURITY_SOLUTION_OWNER, +}; + +export const multipleAlert: CommentRequestAlertType = { + ...alertComment, + alertId: ['test-id-3', 'test-id-4', 'test-id-5'], + index: ['test-index-3', 'test-index-4', 'test-index-5'], +}; + const casesClientMock = createCasesClientMock(); export const mockCasesContract = (): CasesStart => ({ diff --git a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts index a103d8847b20b..f4d96fd910b5e 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts @@ -5,12 +5,7 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; +import { decodeWithExcessOrThrow, CasePushRequestParamsRt } from '../../../../common/api'; import { CASE_PUSH_URL } from '../../../../common/constants'; import type { CaseRoute } from '../types'; import { createCaseError } from '../../../common/error'; @@ -24,10 +19,7 @@ export const pushCaseRoute: CaseRoute = createCasesRoute({ const caseContext = await context.cases; const casesClient = await caseContext.getCasesClient(); - const params = pipe( - CasePushRequestParamsRt.decode(request.params), - fold(throwErrors(Boom.badRequest), identity) - ); + const params = decodeWithExcessOrThrow(CasePushRequestParamsRt)(request.params); return response.ok({ body: await casesClient.cases.push({ diff --git a/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts b/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts index 207fb1a574e16..d732b0af71e37 100644 --- a/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts @@ -6,13 +6,8 @@ */ import { schema } from '@kbn/config-schema'; -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { FindCommentsQueryParamsRt, throwErrors, excess } from '../../../../common/api'; +import { FindCommentsQueryParamsRt, decodeWithExcessOrThrow } from '../../../../common/api'; import { CASE_FIND_ATTACHMENTS_URL } from '../../../../common/constants'; import { createCasesRoute } from '../create_cases_route'; import { createCaseError } from '../../../common/error'; @@ -27,10 +22,7 @@ export const findCommentsRoute = createCasesRoute({ }, handler: async ({ context, request, response }) => { try { - const query = pipe( - excess(FindCommentsQueryParamsRt).decode(request.query), - fold(throwErrors(Boom.badRequest), identity) - ); + const query = decodeWithExcessOrThrow(FindCommentsQueryParamsRt)(request.query); const caseContext = await context.cases; const client = await caseContext.getCasesClient(); diff --git a/x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts index 4bb6493475a9c..2e6cec35cc667 100644 --- a/x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts @@ -5,13 +5,9 @@ * 2.0. */ -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; -import Boom from '@hapi/boom'; -import { CommentPatchRequestRt, throwErrors } from '../../../../common/api'; +import { CommentPatchRequestRt, decodeWithExcessOrThrow } from '../../../../common/api'; import { CASE_COMMENTS_URL } from '../../../../common/constants'; import { createCaseError } from '../../../common/error'; import { createCasesRoute } from '../create_cases_route'; @@ -26,10 +22,7 @@ export const patchCommentRoute = createCasesRoute({ }, handler: async ({ context, request, response }) => { try { - const query = pipe( - CommentPatchRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); + const query = decodeWithExcessOrThrow(CommentPatchRequestRt)(request.body); const caseContext = await context.cases; const client = await caseContext.getCasesClient(); diff --git a/x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts b/x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts index e3626847df458..55e7ede2abd06 100644 --- a/x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts @@ -5,13 +5,8 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - import type { ConfigurationPatchRequest } from '../../../../common/api'; -import { CaseConfigureRequestParamsRt, throwErrors, excess } from '../../../../common/api'; +import { CaseConfigureRequestParamsRt, decodeWithExcessOrThrow } from '../../../../common/api'; import { CASE_CONFIGURE_DETAILS_URL } from '../../../../common/constants'; import { createCaseError } from '../../../common/error'; import { createCasesRoute } from '../create_cases_route'; @@ -21,10 +16,7 @@ export const patchCaseConfigureRoute = createCasesRoute({ path: CASE_CONFIGURE_DETAILS_URL, handler: async ({ context, request, response }) => { try { - const params = pipe( - excess(CaseConfigureRequestParamsRt).decode(request.params), - fold(throwErrors(Boom.badRequest), identity) - ); + const params = decodeWithExcessOrThrow(CaseConfigureRequestParamsRt)(request.params); const caseContext = await context.cases; const client = await caseContext.getCasesClient(); diff --git a/x-pack/plugins/cases/server/routes/api/configure/post_configure.ts b/x-pack/plugins/cases/server/routes/api/configure/post_configure.ts index fb9401cd7fb03..bb15059f541b1 100644 --- a/x-pack/plugins/cases/server/routes/api/configure/post_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/configure/post_configure.ts @@ -5,12 +5,7 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { ConfigurationRequestRt, throwErrors } from '../../../../common/api'; +import { ConfigurationRequestRt, decodeWithExcessOrThrow } from '../../../../common/api'; import { CASE_CONFIGURE_URL } from '../../../../common/constants'; import { createCaseError } from '../../../common/error'; import { createCasesRoute } from '../create_cases_route'; @@ -20,10 +15,7 @@ export const postCaseConfigureRoute = createCasesRoute({ path: CASE_CONFIGURE_URL, handler: async ({ context, request, response }) => { try { - const query = pipe( - ConfigurationRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); + const query = decodeWithExcessOrThrow(ConfigurationRequestRt)(request.body); const caseContext = await context.cases; const client = await caseContext.getCasesClient(); diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 4c8f889dd6261..5679c532502d7 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -1865,7 +1865,7 @@ describe('CasesService', () => { describe('Decoding responses', () => { const caseTransformedAttributesProps = CaseTransformedAttributesRt.types.reduce( - (acc, type) => ({ ...acc, ...type.props }), + (acc, type) => ({ ...acc, ...type.type.props }), {} ); diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts index fcd1dd0c21119..e9b1d155f5234 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts @@ -28,7 +28,6 @@ import { externalService, originalCases, updatedCases, - comment, attachments, updatedAssigneesCases, originalCasesWithAssignee, @@ -43,6 +42,7 @@ import { createUserActionSO, pushConnectorUserAction, } from './test_utils'; +import { comment } from '../../mocks'; describe('CaseUserActionService', () => { const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock(); diff --git a/x-pack/plugins/cases/server/services/user_actions/mocks.ts b/x-pack/plugins/cases/server/services/user_actions/mocks.ts index b7453d02f9524..60ea2a5f4a6fd 100644 --- a/x-pack/plugins/cases/server/services/user_actions/mocks.ts +++ b/x-pack/plugins/cases/server/services/user_actions/mocks.ts @@ -8,9 +8,10 @@ import { CASE_SAVED_OBJECT } from '../../../common/constants'; import { SECURITY_SOLUTION_OWNER } from '../../../common'; import type { CasePostRequest } from '../../../common/api'; -import { CaseSeverity, CaseStatuses, CommentType, ConnectorTypes } from '../../../common/api'; +import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/api'; import { createCaseSavedObjectResponse } from '../test_utils'; import { transformSavedObjectToExternalModel } from '../cases/transform'; +import { alertComment, comment } from '../../mocks'; export const casePayload: CasePostRequest = { title: 'Case SIR', @@ -102,23 +103,6 @@ export const updatedTagsCases = [ }, ]; -export const comment = { - comment: 'a comment', - type: CommentType.user as const, - owner: SECURITY_SOLUTION_OWNER, -}; - -const alertComment = { - alertId: 'alert-id-1', - index: 'alert-index-1', - rule: { - id: 'rule-id-1', - name: 'rule-name-1', - }, - type: CommentType.alert as const, - owner: SECURITY_SOLUTION_OWNER, -}; - export const attachments = [ { id: '1', attachment: { ...comment }, owner: SECURITY_SOLUTION_OWNER }, { id: '2', attachment: { ...alertComment }, owner: SECURITY_SOLUTION_OWNER }, diff --git a/x-pack/plugins/cases/server/services/user_profiles/index.ts b/x-pack/plugins/cases/server/services/user_profiles/index.ts index 2113c116e2787..46400e906d1f6 100644 --- a/x-pack/plugins/cases/server/services/user_profiles/index.ts +++ b/x-pack/plugins/cases/server/services/user_profiles/index.ts @@ -6,9 +6,6 @@ */ import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; import type { KibanaRequest, Logger } from '@kbn/core/server'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; @@ -17,7 +14,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; -import { excess, SuggestUserProfilesRequestRt, throwErrors } from '../../../common/api'; +import { SuggestUserProfilesRequestRt, decodeWithExcessOrThrow } from '../../../common/api'; import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; import { LicensingService } from '../licensing'; @@ -73,10 +70,7 @@ export class UserProfileService { } public async suggest(request: KibanaRequest): Promise { - const params = pipe( - excess(SuggestUserProfilesRequestRt).decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); + const params = decodeWithExcessOrThrow(SuggestUserProfilesRequestRt)(request.body); const { name, size, owners } = params; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts index a8a53cd687e13..bda59eb8b785c 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts @@ -391,7 +391,7 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('406s when excess data sent', async () => { + it('400s when excess data sent', async () => { const postedCase = await createCase(supertest, postCaseReq); await updateCase({ supertest, @@ -405,7 +405,7 @@ export default ({ getService }: FtrProviderContext): void => { }, ], }, - expectedHttpCode: 406, + expectedHttpCode: 400, }); }); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_connectors.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_connectors.ts index e42d2019033f1..2717a1968b4e2 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_connectors.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_connectors.ts @@ -125,7 +125,10 @@ export default ({ getService }: FtrProviderContext): void => { { id: theCase.id, version: patchedCases[0].version, - connector: { ...jiraConnector, fields: { ...jiraConnector.fields, urgency: '1' } }, + connector: { + ...jiraConnector, + fields: { ...jiraConnector.fields, issueType: 'Bug' }, + }, }, ], }, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index 744e70d3809a6..4580d361fa527 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -11,6 +11,7 @@ import { Case, CaseSeverity, CaseStatuses, + CommentRequestUserType, CommentType, ConnectorTypes, getCaseUserActionUrl, @@ -332,11 +333,20 @@ export default ({ getService }: FtrProviderContext): void => { const commentUserAction = userActions[2]; const { id, version: _, ...restComment } = caseWithComments.comments![0]; + const castedUserComment = restComment as CommentRequestUserType; + expect(userActions.length).to.eql(3); expect(commentUserAction.type).to.eql('comment'); expect(commentUserAction.action).to.eql('delete'); expect(commentUserAction.comment_id).to.eql(id); - expect(commentUserAction.payload).to.eql({ comment: restComment }); + + expect(commentUserAction.payload).to.eql({ + comment: { + comment: castedUserComment.comment, + type: castedUserComment.type, + owner: castedUserComment.owner, + }, + }); }); describe('rbac', () => { From 435a880e4d4d490b1e658213fa9b905a2d2a8755 Mon Sep 17 00:00:00 2001 From: Lola Date: Wed, 24 May 2023 15:47:03 -0400 Subject: [PATCH 10/20] [Cloud Posture] add correct eui theme colors for Vector score text (#158387) ## Summary Summarize your PR. If it involves visual changes include a screenshot or gif. Use the correct euiThemeColors so that Vector text is legible in light or dark themes. This ticket is part of [Quick Wins](https://github.com/elastic/security-team/issues/6646) Screen Shot 2023-05-24 at 10 54 13 AM Screen Shot 2023-05-24 at 10 52 05 AM --- .../vulnerability_overview_tab.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx index 113ca99878494..de85b964a5838 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx @@ -109,7 +109,7 @@ const VectorScore = ({ {vector}{' '} From 66ba2e8c525c5a9d76f2620710875c35df857d5d Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Wed, 24 May 2023 21:56:10 +0200 Subject: [PATCH 11/20] Convert hosts flyout contents to asset detail embeddable (#158209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #156687 ## Summary This PR adds asset details embeddable. The idea of extracting the existing host details content to an embeddable is to make it reusable in different places. It can be used as a full-page view or as a flyout controlled by the `showInFlyout` prop. ## Usage ### `AssetDetails` embeddable The embeddable can be customized - the user of the embeddable can decide if it should be shown in a flyout or as a full-page view, which tabs and links should be displayed, and if the state should be persisted (passing the state and the setter). These configurations are supported as props: - node: the node whose details should be shown - closeFlyout: function to determine when to close the flyout - onTabClick: function to decide which tab content should be visible - currentTimeRange- the time range `from` and `to` used to get the content - [Optional] If the state should be persisted in the URL like in the host details flyout - hostFlyoutOpen - setHostFlyoutOpen - [Optional] showActionsColumn: should the actions column inside the metadata table be visible (not displayed by default) - [Optional] showInFlyout: should the content be shown in a flyout (full-page by default) - [Optional] renderedTabsSet: function to handle which tab should be shown externally - tabs: array of the tabs that should be displayed - [Optional] links: array of the link names that should be included (Supported link currently: ['apmServices', 'uptime']) - no links are displayed by default - nodeType: Currently only `host` is supported ### `Metadata` embeddable The metadata tab content can be used separately from the other content as embeddable. I can be configured to show actions and to use an external search state. Props supported: currentTimeRange: MetricsTimeInput; node: the node whose details should be shown nodeType: Currently only `host` is supported - [Optional] showActionsColumn: should the actions column inside the metadata table be visible (not displayed by default); - [Optional] persistMetadataSearchToUrlState If the state of the metadata search should be persisted in the URL - metadataSearchUrlState: this string is used to get the search term and prefill it - setMetadataSearchUrlState: function to set the search term ## 🐾 Next steps We are currently still using the old inventory component and that leads to some duplicated code - I tried to move as much as possible but I will suggest removing the components from the inventory node details once the new embeddable is used there. Ideally, all the tabs will be removed from the inventory components (+ translations) and added to the embeddable (in a separate issue) The telemetry should be also implemented - once we implement the other tabs and use it in different apps/pages we can implement custom test ids based on where the embeddable is used - to keep the test working without changing the ids there I am keeping the host test ids but we need to decide how we want to build the test ids (for example if we use it in APM should the id be `apm-flyout-metadata-remove-filter` instead of `hostsView-flyout-metadata-remove-filter` or we want to pass totally different ids - which is more complicated. ## Testing 1. Storybook - run ```yarn storybook infra``` - The Host Details View folder should be visible - **renamed to Asset Details View** image - Check the content: A quick guide - Screenshot 2023-05-23 at 15 47 48 2. Hosts view - Open hosts view and open a host details flyout - It should work as before - all tabs and links should be functional and the state should be persisted in the URL if it's opened. - The filter button should be centered inside the actions column image --- .../asset_details/asset_details.stories.tsx | 152 +++++++++ .../asset_details.story_decorators.tsx} | 3 +- .../asset_details/asset_details.tsx | 195 +++++++++++ .../asset_details_embeddable.tsx | 93 ++++++ .../asset_details_embeddable_factory.ts | 56 ++++ .../asset_details}/hooks/use_metadata.ts | 12 +- .../lazy_asset_details_wrapper.tsx | 18 + .../links/link_to_apm_services.stories.tsx | 4 +- .../links/link_to_apm_services.tsx | 2 +- .../links/link_to_uptime.stories.tsx | 4 +- .../asset_details}/links/link_to_uptime.tsx | 6 +- .../metadata/add_metadata_filter_button.tsx | 51 +-- .../metadata/build_metadata_filter.ts | 0 .../metadata/lazy_metadata_wrapper.tsx | 18 + .../asset_details}/metadata/metadata.test.tsx | 13 +- .../asset_details}/metadata/metadata.tsx | 48 ++- .../metadata/metadata_embeddable.tsx | 86 +++++ .../metadata/metadata_embeddable_factory.ts | 56 ++++ .../asset_details}/metadata/table.stories.tsx | 4 +- .../asset_details}/metadata/table.tsx | 116 ++++--- .../asset_details}/metadata/utils.ts | 2 +- .../processes/parse_search_string.ts | 39 +++ .../processes/processes.stories.tsx | 6 +- .../processes/processes.story_decorators.tsx | 2 +- .../asset_details}/processes/processes.tsx | 65 ++-- .../processes/processes_table.tsx | 311 ++++++++++++++++++ .../asset_details/processes/state_badge.tsx | 29 ++ .../asset_details/processes/states.ts | 34 ++ .../asset_details/processes/summary_table.tsx | 93 ++++++ .../asset_details/processes/types.ts | 23 ++ .../tabs_content/tabs_content.tsx | 70 ++++ .../public/components/asset_details/types.ts | 23 ++ .../host_details_flyout/flyout.stories.tsx | 90 ----- .../components/host_details_flyout/flyout.tsx | 116 ------- .../host_details_flyout/flyout_wrapper.tsx | 36 +- .../host_details_flyout/metadata/index.ts | 17 - .../{processes/index.ts => tabs.ts} | 13 +- .../hooks/use_host_flyout_open_url_state.ts | 2 +- .../node_details/tabs/osquery/index.tsx | 2 +- .../node_details/tabs/properties/index.tsx | 2 +- .../pages/metrics/metric_detail/index.tsx | 2 +- .../translations/translations/fr-FR.json | 28 +- .../translations/translations/ja-JP.json | 28 +- .../translations/translations/zh-CN.json | 28 +- 44 files changed, 1560 insertions(+), 438 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/asset_details/asset_details.stories.tsx rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout/flyout.story_decorators.tsx => components/asset_details/asset_details.story_decorators.tsx} (98%) create mode 100644 x-pack/plugins/infra/public/components/asset_details/asset_details.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable_factory.ts rename x-pack/plugins/infra/public/{pages/metrics/metric_detail => components/asset_details}/hooks/use_metadata.ts (74%) create mode 100644 x-pack/plugins/infra/public/components/asset_details/lazy_asset_details_wrapper.tsx rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/links/link_to_apm_services.stories.tsx (93%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/links/link_to_apm_services.tsx (95%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/links/link_to_uptime.stories.tsx (94%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/links/link_to_uptime.tsx (84%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/metadata/add_metadata_filter_button.tsx (67%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/metadata/build_metadata_filter.ts (100%) create mode 100644 x-pack/plugins/infra/public/components/asset_details/metadata/lazy_metadata_wrapper.tsx rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/metadata/metadata.test.tsx (88%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/metadata/metadata.tsx (55%) create mode 100644 x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable_factory.ts rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/metadata/table.stories.tsx (96%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/metadata/table.tsx (69%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/metadata/utils.ts (97%) create mode 100644 x-pack/plugins/infra/public/components/asset_details/processes/parse_search_string.ts rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/processes/processes.stories.tsx (92%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/processes/processes.story_decorators.tsx (99%) rename x-pack/plugins/infra/public/{pages/metrics/hosts/components/host_details_flyout => components/asset_details}/processes/processes.tsx (72%) create mode 100644 x-pack/plugins/infra/public/components/asset_details/processes/processes_table.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/processes/state_badge.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/processes/states.ts create mode 100644 x-pack/plugins/infra/public/components/asset_details/processes/summary_table.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/processes/types.ts create mode 100644 x-pack/plugins/infra/public/components/asset_details/tabs_content/tabs_content.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/types.ts delete mode 100644 x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.stories.tsx delete mode 100644 x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.tsx delete mode 100644 x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/index.ts rename x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/{processes/index.ts => tabs.ts} (53%) diff --git a/x-pack/plugins/infra/public/components/asset_details/asset_details.stories.tsx b/x-pack/plugins/infra/public/components/asset_details/asset_details.stories.tsx new file mode 100644 index 0000000000000..32cc6e56d861f --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/asset_details.stories.tsx @@ -0,0 +1,152 @@ +/* + * 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 { EuiButton, EuiCard } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { DecorateWithKibanaContext } from './asset_details.story_decorators'; + +import { AssetDetails, FlyoutTabIds, type AssetDetailsProps } from './asset_details'; +import { decorateWithGlobalStorybookThemeProviders } from '../../test_utils/use_global_storybook_theme'; + +export default { + title: 'infra/Asset Details View/Asset Details Embeddable', + decorators: [ + (wrappedStory) => {wrappedStory()}, + (wrappedStory) => {wrappedStory()}, + decorateWithGlobalStorybookThemeProviders, + DecorateWithKibanaContext, + ], + component: AssetDetails, + args: { + node: { + name: 'host1', + id: 'host1-macOS', + title: { + name: 'host1', + cloudProvider: null, + }, + os: 'macOS', + ip: '192.168.0.1', + rx: 123179.18222222221, + tx: 123030.54555555557, + memory: 0.9044444444444445, + cpu: 0.3979674157303371, + diskLatency: 0.15291777273162221, + memoryTotal: 34359738368, + }, + nodeType: 'host', + closeFlyout: () => {}, + onTabClick: () => {}, + renderedTabsSet: { current: new Set(['metadata']) }, + currentTimeRange: { + interval: '1s', + from: 1683630468, + to: 1683630469, + }, + hostFlyoutOpen: { + clickedItemId: 'host1-macos', + selectedTabId: 'metadata', + searchFilter: '', + metadataSearch: '', + }, + tabs: [ + { + id: FlyoutTabIds.METADATA, + name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.metadata', { + defaultMessage: 'Metadata', + }), + 'data-test-subj': 'hostsView-flyout-tabs-metadata', + }, + { + id: FlyoutTabIds.PROCESSES, + name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', { + defaultMessage: 'Processes', + }), + 'data-test-subj': 'hostsView-flyout-tabs-processes', + }, + ], + links: ['apmServices', 'uptime'], + }, +} as Meta; + +const Template: Story = (args) => { + return ; +}; + +const FlyoutTemplate: Story = (args) => { + const [isOpen, setIsOpen] = React.useState(false); + const closeFlyout = () => setIsOpen(false); + return ( +
+ setIsOpen(true)} + > + Open flyout + + +
+ ); +}; + +export const DefaultAssetDetailsWithMetadataTabSelected = Template.bind({}); +DefaultAssetDetailsWithMetadataTabSelected.args = { + showActionsColumn: true, +}; + +export const AssetDetailsWithMetadataTabSelectedWithPersistedSearch = Template.bind({}); +AssetDetailsWithMetadataTabSelectedWithPersistedSearch.args = { + showActionsColumn: true, + hostFlyoutOpen: { + clickedItemId: 'host1-macos', + selectedTabId: 'metadata', + searchFilter: '', + metadataSearch: 'ip', + }, + setHostFlyoutState: () => {}, +}; + +export const AssetDetailsWithMetadataWithoutActions = Template.bind({}); +AssetDetailsWithMetadataWithoutActions.args = {}; + +export const AssetDetailsWithMetadataWithoutLinks = Template.bind({}); +AssetDetailsWithMetadataWithoutLinks.args = { links: [] }; + +export const AssetDetailsAsFlyout = FlyoutTemplate.bind({}); +AssetDetailsAsFlyout.args = { showInFlyout: true }; + +export const AssetDetailsWithProcessesTabSelected = Template.bind({}); +AssetDetailsWithProcessesTabSelected.args = { + renderedTabsSet: { current: new Set(['processes']) }, + currentTimeRange: { + interval: '1s', + from: 1683630468, + to: 1683630469, + }, + hostFlyoutOpen: { + clickedItemId: 'host1-macos', + selectedTabId: 'processes', + searchFilter: '', + metadataSearch: '', + }, +}; + +export const AssetDetailsWithMetadataTabOnly = Template.bind({}); +AssetDetailsWithMetadataTabOnly.args = { + tabs: [ + { + id: FlyoutTabIds.METADATA, + name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.metadata', { + defaultMessage: 'Metadata', + }), + 'data-test-subj': 'hostsView-flyout-tabs-metadata', + }, + ], +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.story_decorators.tsx b/x-pack/plugins/infra/public/components/asset_details/asset_details.story_decorators.tsx similarity index 98% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.story_decorators.tsx rename to x-pack/plugins/infra/public/components/asset_details/asset_details.story_decorators.tsx index d09eab30ee9e9..ba75f8ed6329f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.story_decorators.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/asset_details.story_decorators.tsx @@ -9,7 +9,7 @@ import type { StoryContext } from '@storybook/react'; import React from 'react'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { SourceProvider } from '../../../../../containers/metrics_source'; +import { SourceProvider } from '../../containers/metrics_source'; export const DecorateWithKibanaContext = ( wrappedStory: () => StoryFnReactReturnType, @@ -175,6 +175,7 @@ export const DecorateWithKibanaContext = {} }, navigateToUrl: () => {}, }, + dataViews: { create: () => {} }, data: { query: { filterManager: { filterManagerService: { addFilters: () => {}, removeFilter: () => {} } }, diff --git a/x-pack/plugins/infra/public/components/asset_details/asset_details.tsx b/x-pack/plugins/infra/public/components/asset_details/asset_details.tsx new file mode 100644 index 0000000000000..2037b536d0f69 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/asset_details.tsx @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTabs, + EuiTab, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { LinkToUptime } from './links/link_to_uptime'; +import { LinkToApmServices } from './links/link_to_apm_services'; +import type { HostNodeRow } from './types'; +import type { InventoryItemType } from '../../../common/inventory_models/types'; +import type { SetNewHostFlyoutOpen } from '../../pages/metrics/hosts/hooks/use_host_flyout_open_url_state'; +import { AssetDetailsTabContent } from './tabs_content/tabs_content'; + +export enum FlyoutTabIds { + METADATA = 'metadata', + PROCESSES = 'processes', +} + +export type TabIds = `${FlyoutTabIds}`; + +export interface Tab { + id: FlyoutTabIds; + name: string; + 'data-test-subj': string; +} + +export interface AssetDetailsProps { + node: HostNodeRow; + nodeType: InventoryItemType; + closeFlyout: () => void; + renderedTabsSet: React.MutableRefObject>; + currentTimeRange: { + interval: string; + from: number; + to: number; + }; + tabs: Tab[]; + hostFlyoutOpen?: { + clickedItemId: string; + selectedTabId: TabIds; + searchFilter: string; + metadataSearch: string; + }; + setHostFlyoutState?: SetNewHostFlyoutOpen; + onTabClick?: (tab: Tab) => void; + links?: Array<'uptime' | 'apmServices'>; + showInFlyout?: boolean; + showActionsColumn?: boolean; +} + +// Setting host as default as it will be the only supported type for now +const NODE_TYPE = 'host' as InventoryItemType; + +export const AssetDetails = ({ + node, + closeFlyout, + onTabClick, + renderedTabsSet, + currentTimeRange, + hostFlyoutOpen, + setHostFlyoutState, + tabs, + showInFlyout, + links, + showActionsColumn, + nodeType = NODE_TYPE, +}: AssetDetailsProps) => { + const { euiTheme } = useEuiTheme(); + const [selectedTabId, setSelectedTabId] = useState('metadata'); + + const onTabSelectClick = (tab: Tab) => { + renderedTabsSet.current.add(tab.id); // On a tab click, mark the tab content as allowed to be rendered + setSelectedTabId(tab.id); + }; + + const tabEntries = tabs.map((tab) => ( + (onTabClick ? onTabClick(tab) : onTabSelectClick(tab))} + isSelected={tab.id === hostFlyoutOpen?.selectedTabId ?? selectedTabId} + > + {tab.name} + + )); + + const linksMapping = { + apmServices: ( + + + + ), + uptime: ( + + + + ), + }; + + const headerLinks = links?.map((link) => linksMapping[link]); + + if (!showInFlyout) { + return ( + <> + + + +

{node.name}

+
+
+ {links && headerLinks} +
+ + + {tabEntries} + + + + ); + } + + return ( + + + + + +

{node.name}

+
+
+ {links && headerLinks} +
+ + + {tabEntries} + +
+ + + +
+ ); +}; + +// Allow for lazy loading +// eslint-disable-next-line import/no-default-export +export default AssetDetails; diff --git a/x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable.tsx b/x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable.tsx new file mode 100644 index 0000000000000..5549668665c13 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable.tsx @@ -0,0 +1,93 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Subscription } from 'rxjs'; +import { Embeddable, EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import { CoreProviders } from '../../apps/common_providers'; +import { InfraClientStartDeps, InfraClientStartExports } from '../../types'; +import { LazyAssetDetailsWrapper } from './lazy_asset_details_wrapper'; +import type { AssetDetailsProps } from './asset_details'; + +export const ASSET_DETAILS_EMBEDDABLE = 'ASSET_DETAILS_EMBEDDABLE'; + +export interface AssetDetailsEmbeddableInput extends EmbeddableInput, AssetDetailsProps {} + +export class AssetDetailsEmbeddable extends Embeddable { + public readonly type = ASSET_DETAILS_EMBEDDABLE; + private node?: HTMLElement; + private subscription: Subscription; + + constructor( + private core: CoreStart, + private pluginDeps: InfraClientStartDeps, + private pluginStart: InfraClientStartExports, + initialInput: AssetDetailsEmbeddableInput, + parent?: IContainer + ) { + super(initialInput, {}, parent); + + this.subscription = this.getInput$().subscribe(() => this.renderComponent()); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + + this.renderComponent(); + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public async reload() {} + + private renderComponent() { + if (!this.node) { + return; + } + + ReactDOM.render( + + +
+ +
+
+
, + this.node + ); + } +} diff --git a/x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable_factory.ts b/x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable_factory.ts new file mode 100644 index 0000000000000..e50494e5b47b9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/asset_details_embeddable_factory.ts @@ -0,0 +1,56 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; +import { InfraClientStartServicesAccessor } from '../../types'; +import { + AssetDetailsEmbeddable, + AssetDetailsEmbeddableInput, + ASSET_DETAILS_EMBEDDABLE, +} from './asset_details_embeddable'; + +export class AssetDetailsEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition +{ + public readonly type = ASSET_DETAILS_EMBEDDABLE; + + constructor(private getStartServices: InfraClientStartServicesAccessor) {} + + public async isEditable() { + return false; + } + + public async create(initialInput: AssetDetailsEmbeddableInput, parent?: IContainer) { + const [core, plugins, pluginStart] = await this.getStartServices(); + return new AssetDetailsEmbeddable(core, plugins, pluginStart, initialInput, parent); + } + + public getDisplayName() { + return i18n.translate('xpack.infra.assetDetailsEmbeddable.displayName', { + defaultMessage: 'Asset Details', + }); + } + + public getDescription() { + return i18n.translate('xpack.infra.assetDetailsEmbeddable.description', { + defaultMessage: 'Add an asset details view.', + }); + } + + public getIconType() { + return 'metricsApp'; + } + + public async getExplicitInput() { + return { + title: i18n.translate('xpack.infra.assetDetailsEmbeddable.title', { + defaultMessage: 'Asset Details', + }), + }; + } +} diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/hooks/use_metadata.ts b/x-pack/plugins/infra/public/components/asset_details/hooks/use_metadata.ts similarity index 74% rename from x-pack/plugins/infra/public/pages/metrics/metric_detail/hooks/use_metadata.ts rename to x-pack/plugins/infra/public/components/asset_details/hooks/use_metadata.ts index 56590abfd7d33..992bdc2809f17 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/hooks/use_metadata.ts +++ b/x-pack/plugins/infra/public/components/asset_details/hooks/use_metadata.ts @@ -9,12 +9,12 @@ import { useEffect } from 'react'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { InfraMetadata, InfraMetadataRT } from '../../../../../common/http_api/metadata_api'; -import { useHTTPRequest } from '../../../../hooks/use_http_request'; -import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; -import { InventoryMetric, InventoryItemType } from '../../../../../common/inventory_models/types'; -import { getFilteredMetrics } from '../lib/get_filtered_metrics'; -import { MetricsTimeInput } from './use_metrics_time'; +import { useHTTPRequest } from '../../../hooks/use_http_request'; +import { type InfraMetadata, InfraMetadataRT } from '../../../../common/http_api/metadata_api'; +import { throwErrors, createPlainError } from '../../../../common/runtime_types'; +import type { MetricsTimeInput } from '../../../pages/metrics/metric_detail/hooks/use_metrics_time'; +import { getFilteredMetrics } from '../../../pages/metrics/metric_detail/lib/get_filtered_metrics'; +import type { InventoryItemType, InventoryMetric } from '../../../../common/inventory_models/types'; export function useMetadata( nodeId: string, diff --git a/x-pack/plugins/infra/public/components/asset_details/lazy_asset_details_wrapper.tsx b/x-pack/plugins/infra/public/components/asset_details/lazy_asset_details_wrapper.tsx new file mode 100644 index 0000000000000..e6eec56ddfe87 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/lazy_asset_details_wrapper.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { AssetDetailsProps } from './asset_details'; + +const AssetDetails = React.lazy(() => import('./asset_details')); + +export const LazyAssetDetailsWrapper = (props: AssetDetailsProps) => ( + }> + + +); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_apm_services.stories.tsx b/x-pack/plugins/infra/public/components/asset_details/links/link_to_apm_services.stories.tsx similarity index 93% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_apm_services.stories.tsx rename to x-pack/plugins/infra/public/components/asset_details/links/link_to_apm_services.stories.tsx index d28fbdc8374ec..323369a06f4e0 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_apm_services.stories.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/links/link_to_apm_services.stories.tsx @@ -10,7 +10,7 @@ import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { Meta, Story } from '@storybook/react/types-6-0'; import React from 'react'; -import { decorateWithGlobalStorybookThemeProviders } from '../../../../../../test_utils/use_global_storybook_theme'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; import { LinkToApmServices, type LinkToApmServicesProps } from './link_to_apm_services'; const mockServices = { @@ -27,7 +27,7 @@ const mockServices = { }; export default { - title: 'infra/Host Details View/Components/Links', + title: 'infra/Asset Details View/Components/Links', decorators: [ (wrappedStory) => {wrappedStory()}, (wrappedStory) => ( diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_apm_services.tsx b/x-pack/plugins/infra/public/components/asset_details/links/link_to_apm_services.tsx similarity index 95% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_apm_services.tsx rename to x-pack/plugins/infra/public/components/asset_details/links/link_to_apm_services.tsx index 3de37e284f539..d7adb8065bdba 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_apm_services.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/links/link_to_apm_services.tsx @@ -11,7 +11,7 @@ import { css } from '@emotion/react'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { EuiIcon, EuiLink, useEuiTheme } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; export interface LinkToApmServicesProps { hostName: string; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_uptime.stories.tsx b/x-pack/plugins/infra/public/components/asset_details/links/link_to_uptime.stories.tsx similarity index 94% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_uptime.stories.tsx rename to x-pack/plugins/infra/public/components/asset_details/links/link_to_uptime.stories.tsx index efa19fe18b5a9..b4f0ca2011dc3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_uptime.stories.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/links/link_to_uptime.stories.tsx @@ -10,7 +10,7 @@ import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { Meta, Story } from '@storybook/react/types-6-0'; import React from 'react'; -import { decorateWithGlobalStorybookThemeProviders } from '../../../../../../test_utils/use_global_storybook_theme'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; import { LinkToUptime, type LinkToUptimeProps } from './link_to_uptime'; const mockServices = { @@ -27,7 +27,7 @@ const mockServices = { }; export default { - title: 'infra/Host Details View/Components/Links', + title: 'infra/Asset Details View/Components/Links', decorators: [ (wrappedStory) => {wrappedStory()}, (wrappedStory) => ( diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_uptime.tsx b/x-pack/plugins/infra/public/components/asset_details/links/link_to_uptime.tsx similarity index 84% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_uptime.tsx rename to x-pack/plugins/infra/public/components/asset_details/links/link_to_uptime.tsx index 821dc3531bf2f..0c5dca8abcd31 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/links/link_to_uptime.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/links/link_to_uptime.tsx @@ -10,9 +10,9 @@ import { EuiLink, EuiIcon, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import { uptimeOverviewLocatorID } from '@kbn/observability-plugin/public'; -import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; -import type { InventoryItemType } from '../../../../../../../common/inventory_models/types'; -import type { HostNodeRow } from '../../../hooks/use_hosts_table'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import type { InventoryItemType } from '../../../../common/inventory_models/types'; +import type { HostNodeRow } from '../types'; export interface LinkToUptimeProps { nodeType: InventoryItemType; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx b/x-pack/plugins/infra/public/components/asset_details/metadata/add_metadata_filter_button.tsx similarity index 67% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx rename to x-pack/plugins/infra/public/components/asset_details/metadata/add_metadata_filter_button.tsx index fb2bc941d528a..8a89e8faecb77 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/add_metadata_filter_button.tsx @@ -8,10 +8,10 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { useMetricsDataViewContext } from '../../../pages/metrics/hosts/hooks/use_data_view'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { useUnifiedSearchContext } from '../../../pages/metrics/hosts/hooks/use_unified_search'; import { buildMetadataFilter } from './build_metadata_filter'; -import { useMetricsDataViewContext } from '../../../hooks/use_data_view'; -import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; -import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; interface AddMetadataFilterButtonProps { item: { @@ -20,12 +20,9 @@ interface AddMetadataFilterButtonProps { }; } -const filterAddedToastTitle = i18n.translate( - 'xpack.infra.hostsViewPage.flyout.metadata.filterAdded', - { - defaultMessage: 'Filter was added', - } -); +const filterAddedToastTitle = i18n.translate('xpack.infra.metadataEmbeddable.filterAdded', { + defaultMessage: 'Filter was added', +}); export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) => { const { dataView } = useMetricsDataViewContext(); @@ -69,12 +66,9 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) return ( { telemetry.reportHostFlyoutFilterRemoved({ field_name: existingFilter.meta.key!, @@ -103,24 +94,18 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) return ( diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/build_metadata_filter.ts b/x-pack/plugins/infra/public/components/asset_details/metadata/build_metadata_filter.ts similarity index 100% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/build_metadata_filter.ts rename to x-pack/plugins/infra/public/components/asset_details/metadata/build_metadata_filter.ts diff --git a/x-pack/plugins/infra/public/components/asset_details/metadata/lazy_metadata_wrapper.tsx b/x-pack/plugins/infra/public/components/asset_details/metadata/lazy_metadata_wrapper.tsx new file mode 100644 index 0000000000000..9dacf55b7c80b --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/lazy_metadata_wrapper.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { MetadataProps } from './metadata'; + +const Metadata = React.lazy(() => import('./metadata')); + +export const LazyMetadataWrapper = (props: MetadataProps) => ( + }> + + +); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/metadata.test.tsx b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata.test.tsx similarity index 88% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/metadata.test.tsx rename to x-pack/plugins/infra/public/components/asset_details/metadata/metadata.test.tsx index 46392fa8609d1..df2242dda3bb0 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/metadata.test.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata.test.tsx @@ -6,18 +6,18 @@ */ import React from 'react'; -import { Metadata, TabProps } from './metadata'; +import { Metadata, type MetadataProps } from './metadata'; -import { useMetadata } from '../../../../metric_detail/hooks/use_metadata'; -import { useSourceContext } from '../../../../../../containers/metrics_source'; +import { useMetadata } from '../hooks/use_metadata'; +import { useSourceContext } from '../../../containers/metrics_source'; import { render } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n-react'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -jest.mock('../../../../../../containers/metrics_source'); -jest.mock('../../../../metric_detail/hooks/use_metadata'); +jest.mock('../../../containers/metrics_source'); +jest.mock('../hooks/use_metadata'); -const metadataProps: TabProps = { +const metadataProps: MetadataProps = { currentTimeRange: { from: 1679316685686, to: 1679585836087, @@ -39,6 +39,7 @@ const metadataProps: TabProps = { diskLatency: 0, memoryTotal: 16777216, }, + showActionsColumn: true, }; const renderHostMetadata = () => diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/metadata.tsx b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata.tsx similarity index 55% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/metadata.tsx rename to x-pack/plugins/infra/public/components/asset_details/metadata/metadata.tsx index 00a10dbbba325..2cf7fce4c9ce2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/metadata.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata.tsx @@ -9,22 +9,35 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useSourceContext } from '../../../../../../containers/metrics_source'; -import { findInventoryModel } from '../../../../../../../common/inventory_models'; -import type { InventoryItemType } from '../../../../../../../common/inventory_models/types'; -import { useMetadata } from '../../../../metric_detail/hooks/use_metadata'; +import type { InventoryItemType } from '../../../../common/inventory_models/types'; +import { findInventoryModel } from '../../../../common/inventory_models'; +import type { MetricsTimeInput } from '../../../pages/metrics/metric_detail/hooks/use_metrics_time'; +import { useMetadata } from '../hooks/use_metadata'; +import { useSourceContext } from '../../../containers/metrics_source'; import { Table } from './table'; import { getAllFields } from './utils'; -import type { HostNodeRow } from '../../../hooks/use_hosts_table'; -import type { MetricsTimeInput } from '../../../../metric_detail/hooks/use_metrics_time'; +import type { HostNodeRow } from '../types'; -export interface TabProps { +export interface MetadataSearchUrlState { + metadataSearchUrlState: string; + setMetadataSearchUrlState: (metadataSearch: { metadataSearch?: string }) => void; +} + +export interface MetadataProps { currentTimeRange: MetricsTimeInput; node: HostNodeRow; nodeType: InventoryItemType; + showActionsColumn?: boolean; + persistMetadataSearchToUrlState?: MetadataSearchUrlState; } -export const Metadata = ({ node, currentTimeRange, nodeType }: TabProps) => { +export const Metadata = ({ + node, + currentTimeRange, + nodeType, + showActionsColumn, + persistMetadataSearchToUrlState, +}: MetadataProps) => { const nodeId = node.name; const inventoryModel = findInventoryModel(nodeType); const { sourceId } = useSourceContext(); @@ -39,7 +52,7 @@ export const Metadata = ({ node, currentTimeRange, nodeType }: TabProps) => { if (fetchMetadataError) { return ( { data-test-subj="infraMetadataErrorCallout" > { data-test-subj="infraMetadataReloadPageLink" onClick={() => window.location.reload()} > - {i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.errorAction', { + {i18n.translate('xpack.infra.metadataEmbeddable.errorAction', { defaultMessage: 'reload the page', })} @@ -66,5 +79,16 @@ export const Metadata = ({ node, currentTimeRange, nodeType }: TabProps) => { ); } - return ; + return ( +
+ ); }; + +// Allow for lazy loading +// eslint-disable-next-line import/no-default-export +export default Metadata; diff --git a/x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable.tsx b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable.tsx new file mode 100644 index 0000000000000..c23005591fda9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable.tsx @@ -0,0 +1,86 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Subscription } from 'rxjs'; +import { Embeddable, EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; +import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import { CoreProviders } from '../../../apps/common_providers'; +import { InfraClientStartDeps, InfraClientStartExports } from '../../../types'; +import { LazyMetadataWrapper } from './lazy_metadata_wrapper'; +import type { MetadataProps } from './metadata'; + +export const METADATA_EMBEDDABLE = 'METADATA_EMBEDDABLE'; + +export interface MetadataEmbeddableInput extends EmbeddableInput, MetadataProps {} + +export class MetadataEmbeddable extends Embeddable { + public readonly type = METADATA_EMBEDDABLE; + private node?: HTMLElement; + private subscription: Subscription; + + constructor( + private core: CoreStart, + private pluginDeps: InfraClientStartDeps, + private pluginStart: InfraClientStartExports, + initialInput: MetadataEmbeddableInput, + parent?: IContainer + ) { + super(initialInput, {}, parent); + + this.subscription = this.getInput$().subscribe(() => this.renderComponent()); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + + this.renderComponent(); + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public async reload() {} + + private renderComponent() { + if (!this.node) { + return; + } + + ReactDOM.render( + + +
+ +
+
+
, + this.node + ); + } +} diff --git a/x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable_factory.ts b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable_factory.ts new file mode 100644 index 0000000000000..e50d59ac51b0e --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/metadata_embeddable_factory.ts @@ -0,0 +1,56 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; +import type { InfraClientStartServicesAccessor } from '../../../types'; +import { + MetadataEmbeddable, + MetadataEmbeddableInput, + METADATA_EMBEDDABLE, +} from './metadata_embeddable'; + +export class MetadataEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition +{ + public readonly type = METADATA_EMBEDDABLE; + + constructor(private getStartServices: InfraClientStartServicesAccessor) {} + + public async isEditable() { + return false; + } + + public async create(initialInput: MetadataEmbeddableInput, parent?: IContainer) { + const [core, plugins, pluginStart] = await this.getStartServices(); + return new MetadataEmbeddable(core, plugins, pluginStart, initialInput, parent); + } + + public getDisplayName() { + return i18n.translate('xpack.infra.metadataEmbeddable.displayName', { + defaultMessage: 'Metadata', + }); + } + + public getDescription() { + return i18n.translate('xpack.infra.metadataEmbeddable.description', { + defaultMessage: 'Add a table of asset metadata.', + }); + } + + public getIconType() { + return 'metricsApp'; + } + + public async getExplicitInput() { + return { + title: i18n.translate('xpack.infra.metadataEmbeddable.title', { + defaultMessage: 'Metadata', + }), + }; + } +} diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/table.stories.tsx b/x-pack/plugins/infra/public/components/asset_details/metadata/table.stories.tsx similarity index 96% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/table.stories.tsx rename to x-pack/plugins/infra/public/components/asset_details/metadata/table.stories.tsx index b0349adac378a..c3d4c00d2cbda 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/table.stories.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/table.stories.tsx @@ -10,7 +10,7 @@ import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { Meta, Story } from '@storybook/react/types-6-0'; import React from 'react'; -import { decorateWithGlobalStorybookThemeProviders } from '../../../../../../test_utils/use_global_storybook_theme'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; import { Table, Props } from './table'; const mockServices = { @@ -24,7 +24,7 @@ const mockServices = { }; export default { - title: 'infra/Host Details View/Components/Metadata Table', + title: 'infra/Asset Details View/Components/Metadata Table', decorators: [ (wrappedStory) => {wrappedStory()}, (wrappedStory) => ( diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/table.tsx b/x-pack/plugins/infra/public/components/asset_details/metadata/table.tsx similarity index 69% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/table.tsx rename to x-pack/plugins/infra/public/components/asset_details/metadata/table.tsx index f6accc4bd75d6..8497552d6ddd9 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/table.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/table.tsx @@ -12,6 +12,7 @@ import { EuiLink, EuiInMemoryTable, EuiSearchBarProps, + type HorizontalAlignment, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; @@ -19,8 +20,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import useToggle from 'react-use/lib/useToggle'; import { debounce } from 'lodash'; import { Query } from '@elastic/eui'; -import { useHostFlyoutOpen } from '../../../hooks/use_host_flyout_open_url_state'; import { AddMetadataFilterButton } from './add_metadata_filter_button'; +import { MetadataSearchUrlState } from './metadata'; interface Row { name: string; @@ -30,6 +31,8 @@ interface Row { export interface Props { rows: Row[]; loading: boolean; + showActionsColumn?: boolean; + persistMetadataSearchToUrlState?: MetadataSearchUrlState; } interface SearchErrorType { @@ -39,46 +42,64 @@ interface SearchErrorType { /** * Columns translations */ -const FIELD_LABEL = i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.field', { +const FIELD_LABEL = i18n.translate('xpack.infra.metadataEmbeddable.field', { defaultMessage: 'Field', }); -const VALUE_LABEL = i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.value', { +const VALUE_LABEL = i18n.translate('xpack.infra.metadataEmbeddable.value', { defaultMessage: 'Value', }); /** * Component translations */ -const SEARCH_PLACEHOLDER = i18n.translate( - 'xpack.infra.hostsViewPage.hostDetail.metadata.searchForMetadata', - { - defaultMessage: 'Search for metadata…', - } -); - -const NO_METADATA_FOUND = i18n.translate( - 'xpack.infra.hostsViewPage.hostDetail.metadata.noMetadataFound', - { - defaultMessage: 'No metadata found.', - } -); - -const LOADING = i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.loading', { +const SEARCH_PLACEHOLDER = i18n.translate('xpack.infra.metadataEmbeddable.searchForMetadata', { + defaultMessage: 'Search for metadata…', +}); + +const NO_METADATA_FOUND = i18n.translate('xpack.infra.metadataEmbeddable.noMetadataFound', { + defaultMessage: 'No metadata found.', +}); + +const LOADING = i18n.translate('xpack.infra.metadataEmbeddable.loading', { defaultMessage: 'Loading...', }); export const Table = (props: Props) => { - const { rows, loading } = props; + const { rows, loading, showActionsColumn } = props; const [searchError, setSearchError] = useState(null); - const [hostFlyoutOpen, setHostFlyoutOpen] = useHostFlyoutOpen(); + const [metadataSearch, setMetadataSearch] = useState(''); + + const defaultColumns = useMemo( + () => [ + { + field: 'name', + name: FIELD_LABEL, + width: '35%', + sortable: false, + render: (name: string) => {name}, + }, + { + field: 'value', + name: VALUE_LABEL, + width: '55%', + sortable: false, + render: (_name: string, item: Row) => , + }, + ], + [] + ); const debouncedSearchOnChange = useMemo( () => debounce<(queryText: string) => void>((queryText) => { - setHostFlyoutOpen({ metadataSearch: String(queryText) ?? '' }); + return props.persistMetadataSearchToUrlState + ? props.persistMetadataSearchToUrlState.setMetadataSearchUrlState({ + metadataSearch: String(queryText) ?? '', + }) + : setMetadataSearch(String(queryText) ?? ''); }, 500), - [setHostFlyoutOpen] + [props.persistMetadataSearchToUrlState] ); const searchBarOnChange = useCallback( @@ -101,38 +122,33 @@ export const Table = (props: Props) => { schema: true, placeholder: SEARCH_PLACEHOLDER, }, - query: hostFlyoutOpen.metadataSearch - ? Query.parse(hostFlyoutOpen.metadataSearch) + query: props.persistMetadataSearchToUrlState + ? props.persistMetadataSearchToUrlState.metadataSearchUrlState + ? Query.parse(props.persistMetadataSearchToUrlState.metadataSearchUrlState) + : Query.MATCH_ALL + : metadataSearch + ? Query.parse(metadataSearch) : Query.MATCH_ALL, }; const columns = useMemo( - () => [ - { - field: 'name', - name: FIELD_LABEL, - width: '35%', - sortable: false, - render: (name: string) => {name}, - }, - { - field: 'value', - name: VALUE_LABEL, - width: '55%', - sortable: false, - render: (_name: string, item: Row) => , - }, - { - field: 'value', - name: 'Actions', - sortable: false, - showOnHover: true, - render: (_name: string, item: Row) => { - return ; - }, - }, - ], - [] + () => + showActionsColumn + ? [ + ...defaultColumns, + { + field: 'value', + name: 'Actions', + sortable: false, + showOnHover: true, + align: 'center' as HorizontalAlignment, + render: (_name: string, item: Row) => { + return ; + }, + }, + ] + : defaultColumns, + [defaultColumns, showActionsColumn] ); return ( diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/utils.ts b/x-pack/plugins/infra/public/components/asset_details/metadata/utils.ts similarity index 97% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/utils.ts rename to x-pack/plugins/infra/public/components/asset_details/metadata/utils.ts index 06ca4e85cc5e6..e8dd94df751e8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/utils.ts +++ b/x-pack/plugins/infra/public/components/asset_details/metadata/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { InfraMetadata } from '../../../../../../../common/http_api'; +import type { InfraMetadata } from '../../../../common/http_api'; export const getAllFields = (metadata: InfraMetadata | null) => { if (!metadata?.info) return []; diff --git a/x-pack/plugins/infra/public/components/asset_details/processes/parse_search_string.ts b/x-pack/plugins/infra/public/components/asset_details/processes/parse_search_string.ts new file mode 100644 index 0000000000000..7112dbed917a6 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/processes/parse_search_string.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const parseSearchString = (query: string) => { + if (query.trim() === '') { + return [ + { + match_all: {}, + }, + ]; + } + const elements = query + .split(' ') + .map((s) => s.trim()) + .filter(Boolean); + const stateFilter = elements.filter((s) => s.startsWith('state=')); + const cmdlineFilters = elements.filter((s) => !s.startsWith('state=')); + return [ + ...cmdlineFilters.map((clause) => ({ + query_string: { + fields: ['system.process.cmdline'], + query: `*${escapeReservedCharacters(clause)}*`, + minimum_should_match: 1, + }, + })), + ...stateFilter.map((state) => ({ + match: { + 'system.process.state': state.replace('state=', ''), + }, + })), + ]; +}; + +const escapeReservedCharacters = (clause: string) => + clause.replace(/([+\-=!\(\)\{\}\[\]^"~*?:\\/!]|&&|\|\|)/g, '\\$1'); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.stories.tsx b/x-pack/plugins/infra/public/components/asset_details/processes/processes.stories.tsx similarity index 92% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.stories.tsx rename to x-pack/plugins/infra/public/components/asset_details/processes/processes.stories.tsx index 19c483b41deaa..42e04737509d6 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.stories.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/processes/processes.stories.tsx @@ -9,13 +9,13 @@ import { EuiCard } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n-react'; import type { Meta, Story } from '@storybook/react/types-6-0'; import React from 'react'; -import { decorateWithGlobalStorybookThemeProviders } from '../../../../../../test_utils/use_global_storybook_theme'; import { DecorateWithKibanaContext } from './processes.story_decorators'; -import { Processes, ProcessesProps } from './processes'; +import { Processes, type ProcessesProps } from './processes'; +import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme'; export default { - title: 'infra/Host Details View/Components/Processes', + title: 'infra/Asset Details View/Components/Processes', decorators: [ (wrappedStory) => {wrappedStory()}, (wrappedStory) => {wrappedStory()}, diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.story_decorators.tsx b/x-pack/plugins/infra/public/components/asset_details/processes/processes.story_decorators.tsx similarity index 99% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.story_decorators.tsx rename to x-pack/plugins/infra/public/components/asset_details/processes/processes.story_decorators.tsx index 96ea08a046a01..29e4b4b79faff 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.story_decorators.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/processes/processes.story_decorators.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { useParameter } from '@storybook/addons'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { SourceProvider } from '../../../../../../containers/metrics_source'; +import { SourceProvider } from '../../../containers/metrics_source'; export const DecorateWithKibanaContext = ( wrappedStory: () => StoryFnReactReturnType, diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.tsx b/x-pack/plugins/infra/public/components/asset_details/processes/processes.tsx similarity index 72% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.tsx rename to x-pack/plugins/infra/public/components/asset_details/processes/processes.tsx index 1d9959f120d69..c014e9a3c457a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/processes.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/processes/processes.tsx @@ -17,25 +17,26 @@ import { EuiIconTip, Query, } from '@elastic/eui'; -import type { InventoryItemType } from '../../../../../../../common/inventory_models/types'; -import { getFieldByType } from '../../../../../../../common/inventory_models'; -import { parseSearchString } from '../../../../inventory_view/components/node_details/tabs/processes/parse_search_string'; -import { ProcessesTable } from '../../../../inventory_view/components/node_details/tabs/processes/processes_table'; -import { STATE_NAMES } from '../../../../inventory_view/components/node_details/tabs/processes/states'; -import { SummaryTable } from '../../../../inventory_view/components/node_details/tabs/processes/summary_table'; -import { TabContent } from '../../../../inventory_view/components/node_details/tabs/shared'; +import { parseSearchString } from './parse_search_string'; +import { ProcessesTable } from './processes_table'; +import { STATE_NAMES } from './states'; +import { SummaryTable } from './summary_table'; +import { TabContent } from '../../../pages/metrics/inventory_view/components/node_details/tabs/shared'; import { SortBy, useProcessList, ProcessListContextProvider, -} from '../../../../inventory_view/hooks/use_process_list'; -import type { HostNodeRow } from '../../../hooks/use_hosts_table'; -import { useHostFlyoutOpen } from '../../../hooks/use_host_flyout_open_url_state'; +} from '../../../pages/metrics/inventory_view/hooks/use_process_list'; +import { getFieldByType } from '../../../../common/inventory_models'; +import type { HostNodeRow } from '../types'; +import type { InventoryItemType } from '../../../../common/inventory_models/types'; export interface ProcessesProps { node: HostNodeRow; nodeType: InventoryItemType; currentTime: number; + searchFilter?: string; + setSearchFilter?: (searchFilter: { searchFilter: string }) => void; } const options = Object.entries(STATE_NAMES).map(([value, view]: [string, string]) => ({ @@ -43,10 +44,16 @@ const options = Object.entries(STATE_NAMES).map(([value, view]: [string, string] view, })); -export const Processes = ({ currentTime, node, nodeType }: ProcessesProps) => { - const [hostFlyoutOpen, setHostFlyoutOpen] = useHostFlyoutOpen(); +export const Processes = ({ + currentTime, + node, + nodeType, + searchFilter, + setSearchFilter, +}: ProcessesProps) => { + const [searchText, setSearchText] = useState(searchFilter ?? ''); const [searchBarState, setSearchBarState] = useState(() => - hostFlyoutOpen.searchFilter ? Query.parse(hostFlyoutOpen.searchFilter) : Query.MATCH_ALL + searchText ? Query.parse(searchText) : Query.MATCH_ALL ); const [sortBy, setSortBy] = useState({ @@ -64,34 +71,32 @@ export const Processes = ({ currentTime, node, nodeType }: ProcessesProps) => { error, response, makeRequest: reload, - } = useProcessList( - hostTerm, - currentTime, - sortBy, - parseSearchString(hostFlyoutOpen.searchFilter ?? '') - ); + } = useProcessList(hostTerm, currentTime, sortBy, parseSearchString(searchText)); - const debouncedSearchOnChange = useMemo( - () => - debounce<(queryText: string) => void>( - (queryText) => setHostFlyoutOpen({ searchFilter: queryText }), - 500 - ), - [setHostFlyoutOpen] - ); + const debouncedSearchOnChange = useMemo(() => { + return debounce<(queryText: string) => void>((queryText) => { + if (setSearchFilter) { + setSearchFilter({ searchFilter: queryText }); + } + setSearchText(queryText); + }, 500); + }, [setSearchFilter]); const searchBarOnChange = useCallback( ({ query, queryText }) => { setSearchBarState(query); debouncedSearchOnChange(queryText); }, - [setSearchBarState, debouncedSearchOnChange] + [debouncedSearchOnChange] ); const clearSearchBar = useCallback(() => { setSearchBarState(Query.MATCH_ALL); - setHostFlyoutOpen({ searchFilter: '' }); - }, [setHostFlyoutOpen]); + if (setSearchFilter) { + setSearchFilter({ searchFilter: '' }); + } + setSearchText(''); + }, [setSearchFilter]); return ( diff --git a/x-pack/plugins/infra/public/components/asset_details/processes/processes_table.tsx b/x-pack/plugins/infra/public/components/asset_details/processes/processes_table.tsx new file mode 100644 index 0000000000000..d885cac7cf255 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/processes/processes_table.tsx @@ -0,0 +1,311 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState, useCallback } from 'react'; +import { omit } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiTable, + EuiTableHeader, + EuiTableBody, + EuiTableHeaderCell, + EuiTableRowCell, + EuiLoadingChart, + EuiEmptyPrompt, + EuiText, + EuiLink, + EuiButton, + SortableProperties, + LEFT_ALIGNMENT, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { FORMATTERS } from '../../../../common/formatters'; +import type { SortBy } from '../../../pages/metrics/inventory_view/hooks/use_process_list'; +import type { Process } from './types'; +import { ProcessRow } from '../../../pages/metrics/inventory_view/components/node_details/tabs/processes/process_row'; +import { StateBadge } from './state_badge'; +import { STATE_ORDER } from './states'; +import type { ProcessListAPIResponse } from '../../../../common/http_api'; + +interface TableProps { + processList: ProcessListAPIResponse['processList']; + currentTime: number; + isLoading: boolean; + sortBy: SortBy; + setSortBy: (s: SortBy) => void; + clearSearchBar: () => void; +} + +function useSortableProperties( + sortablePropertyItems: Array<{ + name: string; + getValue: (obj: T) => any; + isAscending: boolean; + }>, + defaultSortProperty: string, + callback: (s: SortBy) => void +) { + const [sortableProperties] = useState>( + new SortableProperties(sortablePropertyItems, defaultSortProperty) + ); + + return { + updateSortableProperties: useCallback( + (property) => { + sortableProperties.sortOn(property); + callback(omit(sortableProperties.getSortedProperty(), 'getValue')); + }, + [sortableProperties, callback] + ), + }; +} + +export const ProcessesTable = ({ + processList, + currentTime, + isLoading, + sortBy, + setSortBy, + clearSearchBar, +}: TableProps) => { + const { updateSortableProperties } = useSortableProperties( + [ + { + name: 'startTime', + getValue: (item: any) => Date.parse(item.startTime), + isAscending: true, + }, + { + name: 'cpu', + getValue: (item: any) => item.cpu, + isAscending: false, + }, + { + name: 'memory', + getValue: (item: any) => item.memory, + isAscending: false, + }, + ], + 'cpu', + setSortBy + ); + + const currentItems = useMemo( + () => + processList.sort( + (a, b) => STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state) + ) as Process[], + [processList] + ); + + if (isLoading) return ; + + if (currentItems.length === 0) + return ( + + {i18n.translate('xpack.infra.metrics.nodeDetails.noProcesses', { + defaultMessage: 'No processes found', + })} + + } + body={ + + + + + ), + }} + /> + + } + actions={ + + {i18n.translate('xpack.infra.metrics.nodeDetails.noProcessesClearFilters', { + defaultMessage: 'Clear filters', + })} + + } + /> + ); + + return ( + <> + + + + {columns.map((column) => ( + updateSortableProperties(column.field) : undefined} + isSorted={sortBy.name === column.field} + isSortAscending={sortBy.name === column.field && sortBy.isAscending} + > + {column.name} + + ))} + + + + + + + ); +}; + +const LoadingPlaceholder = () => { + return ( +
+ +
+ ); +}; + +interface TableBodyProps { + items: Process[]; + currentTime: number; +} +const ProcessesTableBody = ({ items, currentTime }: TableBodyProps) => ( + <> + {items.map((item, i) => { + const cells = columns.map((column) => ( + + {column.render ? column.render(item[column.field], currentTime) : item[column.field]} + + )); + return ; + })} + +); + +const StyledTableBody = euiStyled(EuiTableBody)` + & .euiTableCellContent { + padding-top: 0; + padding-bottom: 0; + + } +`; + +const ONE_MINUTE = 60 * 1000; +const ONE_HOUR = ONE_MINUTE * 60; +const RuntimeCell = ({ startTime, currentTime }: { startTime: number; currentTime: number }) => { + const runtimeLength = currentTime - startTime; + let remainingRuntimeMS = runtimeLength; + const runtimeHours = Math.floor(remainingRuntimeMS / ONE_HOUR); + remainingRuntimeMS -= runtimeHours * ONE_HOUR; + const runtimeMinutes = Math.floor(remainingRuntimeMS / ONE_MINUTE); + remainingRuntimeMS -= runtimeMinutes * ONE_MINUTE; + const runtimeSeconds = Math.floor(remainingRuntimeMS / 1000); + remainingRuntimeMS -= runtimeSeconds * 1000; + + const runtimeDisplayHours = runtimeHours ? `${runtimeHours}:` : ''; + const runtimeDisplayMinutes = runtimeMinutes < 10 ? `0${runtimeMinutes}:` : `${runtimeMinutes}:`; + const runtimeDisplaySeconds = runtimeSeconds < 10 ? `0${runtimeSeconds}` : runtimeSeconds; + + return <>{`${runtimeDisplayHours}${runtimeDisplayMinutes}${runtimeDisplaySeconds}`}; +}; + +const columns: Array<{ + field: keyof Process; + name: string; + sortable: boolean; + render?: Function; + width?: string | number; + textOnly?: boolean; + align?: typeof RIGHT_ALIGNMENT | typeof LEFT_ALIGNMENT; +}> = [ + { + field: 'state', + name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelState', { + defaultMessage: 'State', + }), + sortable: false, + render: (state: string) => , + width: 84, + textOnly: false, + }, + { + field: 'command', + name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCommand', { + defaultMessage: 'Command', + }), + sortable: false, + width: '40%', + render: (command: string) => {command}, + }, + { + field: 'startTime', + name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelTime', { + defaultMessage: 'Time', + }), + align: RIGHT_ALIGNMENT, + sortable: true, + render: (startTime: number, currentTime: number) => ( + + ), + }, + { + field: 'cpu', + name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCPU', { + defaultMessage: 'CPU', + }), + sortable: true, + render: (value: number) => FORMATTERS.percent(value), + }, + { + field: 'memory', + name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelMemory', { + defaultMessage: 'Mem.', + }), + sortable: true, + render: (value: number) => FORMATTERS.percent(value), + }, +]; + +const CodeLine = euiStyled.div` + font-family: ${(props) => props.theme.eui.euiCodeFontFamily}; + font-size: ${(props) => props.theme.eui.euiFontSizeS}; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/x-pack/plugins/infra/public/components/asset_details/processes/state_badge.tsx b/x-pack/plugins/infra/public/components/asset_details/processes/state_badge.tsx new file mode 100644 index 0000000000000..47049c7d9c893 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/processes/state_badge.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { STATE_NAMES } from './states'; + +export const StateBadge = ({ state }: { state: string }) => { + switch (state) { + case 'running': + return {STATE_NAMES.running}; + case 'sleeping': + return {STATE_NAMES.sleeping}; + case 'dead': + return {STATE_NAMES.dead}; + case 'stopped': + return {STATE_NAMES.stopped}; + case 'idle': + return {STATE_NAMES.idle}; + case 'zombie': + return {STATE_NAMES.zombie}; + default: + return {STATE_NAMES.unknown}; + } +}; diff --git a/x-pack/plugins/infra/public/components/asset_details/processes/states.ts b/x-pack/plugins/infra/public/components/asset_details/processes/states.ts new file mode 100644 index 0000000000000..ea944cd8bb8c0 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/processes/states.ts @@ -0,0 +1,34 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const STATE_NAMES = { + running: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateRunning', { + defaultMessage: 'Running', + }), + sleeping: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateSleeping', { + defaultMessage: 'Sleeping', + }), + dead: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateDead', { + defaultMessage: 'Dead', + }), + stopped: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateStopped', { + defaultMessage: 'Stopped', + }), + idle: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateIdle', { + defaultMessage: 'Idle', + }), + zombie: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateZombie', { + defaultMessage: 'Zombie', + }), + unknown: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateUnknown', { + defaultMessage: 'Unknown', + }), +}; + +export const STATE_ORDER = ['running', 'sleeping', 'stopped', 'idle', 'dead', 'zombie', 'unknown']; diff --git a/x-pack/plugins/infra/public/components/asset_details/processes/summary_table.tsx b/x-pack/plugins/infra/public/components/asset_details/processes/summary_table.tsx new file mode 100644 index 0000000000000..92007d769a4eb --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/processes/summary_table.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { mapValues } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiHorizontalRule, +} from '@elastic/eui'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import type { ProcessListAPIResponse } from '../../../../common/http_api'; +import { STATE_NAMES } from './states'; + +interface Props { + processSummary: ProcessListAPIResponse['summary']; + isLoading: boolean; +} + +type SummaryRecord = { + total: number; +} & Record; + +const NOT_AVAILABLE_LABEL = i18n.translate('xpack.infra.notAvailableLabel', { + defaultMessage: 'N/A', +}); + +const processSummaryNotAvailable = { + total: NOT_AVAILABLE_LABEL, + running: NOT_AVAILABLE_LABEL, + sleeping: NOT_AVAILABLE_LABEL, + dead: NOT_AVAILABLE_LABEL, + stopped: NOT_AVAILABLE_LABEL, + idle: NOT_AVAILABLE_LABEL, + zombie: NOT_AVAILABLE_LABEL, + unknown: NOT_AVAILABLE_LABEL, +}; + +export const SummaryTable = ({ processSummary, isLoading }: Props) => { + const summary = !processSummary?.total ? processSummaryNotAvailable : processSummary; + + const processCount = useMemo( + () => + ({ + total: isLoading ? -1 : summary.total, + ...mapValues(STATE_NAMES, () => (isLoading ? -1 : 0)), + ...(isLoading ? {} : summary), + } as SummaryRecord), + [summary, isLoading] + ); + return ( + <> + + {Object.entries(processCount).map(([field, value]) => ( + + + {columnTitles[field as keyof SummaryRecord]} + + {value === -1 ? : value} + + + + ))} + + + + ); +}; + +const columnTitles = { + total: i18n.translate('xpack.infra.metrics.nodeDetails.processes.headingTotalProcesses', { + defaultMessage: 'Total processes', + }), + ...STATE_NAMES, +}; + +const LoadingSpinner = euiStyled(EuiLoadingSpinner).attrs({ size: 'm' })` + margin-top: 2px; + margin-bottom: 3px; +`; + +const ColumnTitle = euiStyled(EuiDescriptionListTitle)` + white-space: nowrap; +`; diff --git a/x-pack/plugins/infra/public/components/asset_details/processes/types.ts b/x-pack/plugins/infra/public/components/asset_details/processes/types.ts new file mode 100644 index 0000000000000..ef4b177ecae4b --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/processes/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { MetricsExplorerSeries } from '../../../../common/http_api'; +import { STATE_NAMES } from './states'; + +export interface Process { + command: string; + cpu: number; + memory: number; + startTime: number; + state: keyof typeof STATE_NAMES; + pid: number; + user: string; + timeseries: { + [x: string]: MetricsExplorerSeries; + }; + apmTrace?: string; // Placeholder +} diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs_content/tabs_content.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs_content/tabs_content.tsx new file mode 100644 index 0000000000000..e4c2d028defdf --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/tabs_content/tabs_content.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { type AssetDetailsProps, type TabIds, FlyoutTabIds } from '../asset_details'; +import Metadata from '../metadata/metadata'; +import { Processes } from '../processes/processes'; + +export const AssetDetailsTabContent = ({ + renderedTabsSet, + hostFlyoutOpen, + currentTimeRange, + node, + nodeType, + showActionsColumn, + setHostFlyoutState, + selectedTabId, +}: Pick< + AssetDetailsProps, + | 'renderedTabsSet' + | 'hostFlyoutOpen' + | 'currentTimeRange' + | 'node' + | 'nodeType' + | 'showActionsColumn' + | 'setHostFlyoutState' +> & { selectedTabId: TabIds }) => { + const persistMetadataSearchToUrlState = + setHostFlyoutState && hostFlyoutOpen + ? { + metadataSearchUrlState: hostFlyoutOpen.metadataSearch, + setMetadataSearchUrlState: setHostFlyoutState, + } + : undefined; + + const isTabSelected = (flyoutTabId: FlyoutTabIds) => { + return selectedTabId === flyoutTabId; + }; + + return ( + <> + {renderedTabsSet.current.has(FlyoutTabIds.METADATA) && ( + + )} + {renderedTabsSet.current.has(FlyoutTabIds.PROCESSES) && ( + + )} + + ); +}; diff --git a/x-pack/plugins/infra/public/components/asset_details/types.ts b/x-pack/plugins/infra/public/components/asset_details/types.ts new file mode 100644 index 0000000000000..8432d6f7085ef --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/types.ts @@ -0,0 +1,23 @@ +/* + * 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 { InfraAssetMetricType } from '../../../common/http_api'; + +export type CloudProvider = 'gcp' | 'aws' | 'azure' | 'unknownProvider'; +type HostMetrics = Record; + +interface HostMetadata { + os?: string | null; + ip?: string | null; + servicesOnHost?: number | null; + title: { name: string; cloudProvider?: CloudProvider | null }; + id: string; +} +export type HostNodeRow = HostMetadata & + HostMetrics & { + name: string; + }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.stories.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.stories.tsx deleted file mode 100644 index b48d57747c616..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.stories.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable @kbn/telemetry/event_generating_elements_should_be_instrumented */ - -import { EuiButton, EuiCard } from '@elastic/eui'; -import { I18nProvider } from '@kbn/i18n-react'; -import type { Meta, Story } from '@storybook/react/types-6-0'; -import React from 'react'; -import { decorateWithGlobalStorybookThemeProviders } from '../../../../../test_utils/use_global_storybook_theme'; -import { DecorateWithKibanaContext } from './flyout.story_decorators'; - -import { Flyout, type FlyoutProps } from './flyout'; - -export default { - title: 'infra/Host Details View/Flyout', - decorators: [ - (wrappedStory) => {wrappedStory()}, - (wrappedStory) => {wrappedStory()}, - decorateWithGlobalStorybookThemeProviders, - DecorateWithKibanaContext, - ], - component: Flyout, - args: { - node: { - name: 'host1', - id: 'host1-macOS', - title: { - name: 'host1', - cloudProvider: null, - }, - os: 'macOS', - ip: '192.168.0.1', - rx: 123179.18222222221, - tx: 123030.54555555557, - memory: 0.9044444444444445, - cpu: 0.3979674157303371, - diskLatency: 0.15291777273162221, - memoryTotal: 34359738368, - }, - closeFlyout: () => {}, - onTabClick: () => {}, - renderedTabsSet: { current: new Set(['metadata']) }, - currentTimeRange: { - interval: '1s', - from: 1683630468, - to: 1683630469, - }, - hostFlyoutOpen: { - clickedItemId: 'host1-macos', - selectedTabId: 'metadata', - searchFilter: '', - metadataSearch: '', - }, - }, -} as Meta; - -const Template: Story = (args) => { - const [isOpen, setIsOpen] = React.useState(false); - const closeFlyout = () => setIsOpen(false); - return ( -
- setIsOpen(true)}>Open flyout - {isOpen && } -
- ); -}; - -export const DefaultFlyoutMetadata = Template.bind({}); -DefaultFlyoutMetadata.args = {}; - -export const FlyoutWithProcesses = Template.bind({}); -FlyoutWithProcesses.args = { - renderedTabsSet: { current: new Set(['processes']) }, - currentTimeRange: { - interval: '1s', - from: 1683630468, - to: 1683630469, - }, - hostFlyoutOpen: { - clickedItemId: 'host1-macos', - selectedTabId: 'processes', - searchFilter: '', - metadataSearch: '', - }, -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.tsx deleted file mode 100644 index 89c16b9121ad4..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiTabs, - EuiTab, - useEuiTheme, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { LinkToUptime } from './links/link_to_uptime'; -import { LinkToApmServices } from './links/link_to_apm_services'; -import type { InventoryItemType } from '../../../../../../common/inventory_models/types'; -import type { HostNodeRow } from '../../hooks/use_hosts_table'; -import { Metadata } from './metadata/metadata'; -import { Processes } from './processes/processes'; -import { FlyoutTabIds } from '../../hooks/use_host_flyout_open_url_state'; -import type { Tab } from './flyout_wrapper'; -import { metadataTab } from './metadata'; -import { processesTab } from './processes'; - -type FLYOUT_TABS = 'metadata' | 'processes'; -export interface FlyoutProps { - node: HostNodeRow; - closeFlyout: () => void; - renderedTabsSet: React.MutableRefObject>; - currentTimeRange: { - interval: string; - from: number; - to: number; - }; - hostFlyoutOpen: { - clickedItemId: string; - selectedTabId: FLYOUT_TABS; - searchFilter: string; - metadataSearch: string; - }; - onTabClick: (tab: Tab) => void; -} - -const NODE_TYPE = 'host' as InventoryItemType; -const flyoutTabs: Tab[] = [metadataTab, processesTab]; - -export const Flyout = ({ - node, - closeFlyout, - onTabClick, - renderedTabsSet, - currentTimeRange, - hostFlyoutOpen, -}: FlyoutProps) => { - const { euiTheme } = useEuiTheme(); - - const tabEntries = flyoutTabs.map((tab) => ( - onTabClick(tab)} - isSelected={tab.id === hostFlyoutOpen.selectedTabId} - > - {tab.name} - - )); - - return ( - - - - - -

{node.name}

-
-
- - - - - - -
- - - {tabEntries} - -
- - {renderedTabsSet.current.has(FlyoutTabIds.METADATA) && ( - - )} - {renderedTabsSet.current.has(FlyoutTabIds.PROCESSES) && ( - - )} - -
- ); -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx index 24728f485cf6e..fda3185b4c363 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/flyout_wrapper.tsx @@ -5,30 +5,32 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; +import type { InventoryItemType } from '../../../../../../common/inventory_models/types'; import { useUnifiedSearchContext } from '../../hooks/use_unified_search'; import { useLazyRef } from '../../../../../hooks/use_lazy_ref'; import type { HostNodeRow } from '../../hooks/use_hosts_table'; -import { FlyoutTabIds, useHostFlyoutOpen } from '../../hooks/use_host_flyout_open_url_state'; -import { Flyout } from './flyout'; +import type { Tab } from '../../../../../components/asset_details/asset_details'; +import { useHostFlyoutOpen } from '../../hooks/use_host_flyout_open_url_state'; +import { AssetDetails } from '../../../../../components/asset_details/asset_details'; +import { metadataTab, processesTab } from './tabs'; export interface Props { node: HostNodeRow; closeFlyout: () => void; } -export interface Tab { - id: FlyoutTabIds.METADATA | FlyoutTabIds.PROCESSES; - name: any; - 'data-test-subj': string; -} +const NODE_TYPE = 'host' as InventoryItemType; export const FlyoutWrapper = ({ node, closeFlyout }: Props) => { const { getDateRangeAsTimestamp } = useUnifiedSearchContext(); - const currentTimeRange = { - ...getDateRangeAsTimestamp(), - interval: '1m', - }; + const currentTimeRange = useMemo( + () => ({ + ...getDateRangeAsTimestamp(), + interval: '1m', + }), + [getDateRangeAsTimestamp] + ); const [hostFlyoutOpen, setHostFlyoutOpen] = useHostFlyoutOpen(); @@ -42,13 +44,19 @@ export const FlyoutWrapper = ({ node, closeFlyout }: Props) => { }; return ( - ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/index.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/index.ts deleted file mode 100644 index 95d33daf2f57b..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { FlyoutTabIds } from '../../../hooks/use_host_flyout_open_url_state'; - -export const metadataTab = { - id: FlyoutTabIds.METADATA, - name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', { - defaultMessage: 'Metadata', - }), - 'data-test-subj': 'hostsView-flyout-tabs-metadata', -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/index.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/tabs.ts similarity index 53% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/index.ts rename to x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/tabs.ts index ec6bd62ba25ca..8485664a4cd6a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/processes/index.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/tabs.ts @@ -6,12 +6,21 @@ */ import { i18n } from '@kbn/i18n'; -import { FlyoutTabIds } from '../../../hooks/use_host_flyout_open_url_state'; +import type { Tab } from '../../../../../components/asset_details/asset_details'; +import { FlyoutTabIds } from '../../hooks/use_host_flyout_open_url_state'; -export const processesTab = { +export const processesTab: Tab = { id: FlyoutTabIds.PROCESSES, name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', { defaultMessage: 'Processes', }), 'data-test-subj': 'hostsView-flyout-tabs-processes', }; + +export const metadataTab: Tab = { + id: FlyoutTabIds.METADATA, + name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', { + defaultMessage: 'Metadata', + }), + 'data-test-subj': 'hostsView-flyout-tabs-metadata', +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_flyout_open_url_state.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_flyout_open_url_state.ts index 663ecf3a92643..1db5787719a74 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_flyout_open_url_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_flyout_open_url_state.ts @@ -25,7 +25,7 @@ export const GET_DEFAULT_TABLE_PROPERTIES = { const HOST_TABLE_PROPERTIES_URL_STATE_KEY = 'hostFlyoutOpen'; type Action = rt.TypeOf; -type SetNewHostFlyoutOpen = (newProp: Action) => void; +export type SetNewHostFlyoutOpen = (newProp: Action) => void; type SetNewHostFlyoutClose = () => void; export const useHostFlyoutOpen = (): [ diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx index 26f261bb1b3c4..2c6da541fd874 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx @@ -13,7 +13,7 @@ import { TabContent, TabProps } from '../shared'; import { useSourceContext } from '../../../../../../../containers/metrics_source'; import { findInventoryModel } from '../../../../../../../../common/inventory_models'; import { InventoryItemType } from '../../../../../../../../common/inventory_models/types'; -import { useMetadata } from '../../../../../metric_detail/hooks/use_metadata'; +import { useMetadata } from '../../../../../../../components/asset_details/hooks/use_metadata'; import { useWaffleTimeContext } from '../../../../hooks/use_waffle_time'; const TabComponent = (props: TabProps) => { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx index 65fa3bcfd0164..bc0ee279b2cbd 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx @@ -13,7 +13,7 @@ import { TabContent, TabProps } from '../shared'; import { useSourceContext } from '../../../../../../../containers/metrics_source'; import { findInventoryModel } from '../../../../../../../../common/inventory_models'; import { InventoryItemType } from '../../../../../../../../common/inventory_models/types'; -import { useMetadata } from '../../../../../metric_detail/hooks/use_metadata'; +import { useMetadata } from '../../../../../../../components/asset_details/hooks/use_metadata'; import { getFields } from './build_fields'; import { useWaffleTimeContext } from '../../../../hooks/use_waffle_time'; import { Table } from './table'; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx index fe2c90b77b38e..e4819242c011d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/index.tsx @@ -10,7 +10,7 @@ import React, { useState } from 'react'; import { EuiTheme, withTheme } from '@kbn/kibana-react-plugin/common'; import { useLinkProps } from '@kbn/observability-shared-plugin/public'; import { withMetricPageProviders } from './page_providers'; -import { useMetadata } from './hooks/use_metadata'; +import { useMetadata } from '../../../components/asset_details/hooks/use_metadata'; import { useMetricsBreadcrumbs } from '../../../hooks/use_metrics_breadcrumbs'; import { useSourceContext } from '../../../containers/metrics_source'; import { InfraLoadingPanel } from '../../../components/loading'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 1768271cd6fbf..5d252a92a217f 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -18293,7 +18293,6 @@ "xpack.infra.deprecations.timestampAdjustIndexing": "Ajustez votre indexation pour utiliser \"{field}\" comme horodatage.", "xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "Dernières {duration} de données pour l'heure sélectionnée", "xpack.infra.hostsViewPage.errorOnCreateOrLoadDataview": "Une erreur s'est produite lors de la création d'une vue de données : {metricAlias}", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorMessage": "Une erreur s'est produite lors du chargement des données. Essayez de {reload} et d'ouvrir à nouveau les détails de l'hôte.", "xpack.infra.hostsViewPage.landing.calloutRoleClarificationWithDocsLink": "Un rôle avec accès aux paramètres avancés dans Kibana sera nécessaire. {docsLink}", "xpack.infra.hostsViewPage.metricTrend.subtitle.average.limit": "Moyenne (de {limit} hôtes)", "xpack.infra.hostsViewPage.metricTrend.subtitle.hostCount.limit": "Limité à {limit}", @@ -18522,19 +18521,7 @@ "xpack.infra.hostsViewPage.experimentalBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.", "xpack.infra.hostsViewPage.experimentalBadgeLabel": "Version d'évaluation technique", "xpack.infra.hostsViewPage.flyout.apmServicesLinkLabel": "Service APM", - "xpack.infra.hostsViewPage.flyout.metadata.AddFilterAriaLabel": "Ajouter un filtre", - "xpack.infra.hostsViewPage.flyout.metadata.filterAdded": "Le filtre a été ajouté.", - "xpack.infra.hostsViewPage.flyout.metadata.filterAriaLabel": "Filtre", - "xpack.infra.hostsViewPage.flyout.metadata.setFilterByValueTooltip": "Filtrer par valeur", - "xpack.infra.hostsViewPage.flyout.metadata.setRemoveFilterTooltip": "Supprimer le filtre", "xpack.infra.hostsViewPage.flyout.uptimeLinkLabel": "Uptime", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorAction": "recharger la page", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorTitle": "Désolé, une erreur est survenue.", - "xpack.infra.hostsViewPage.hostDetail.metadata.field": "Champ", - "xpack.infra.hostsViewPage.hostDetail.metadata.loading": "Chargement...", - "xpack.infra.hostsViewPage.hostDetail.metadata.noMetadataFound": "Métadonnées introuvables.", - "xpack.infra.hostsViewPage.hostDetail.metadata.searchForMetadata": "Rechercher des métadonnées…", - "xpack.infra.hostsViewPage.hostDetail.metadata.value": "Valeur", "xpack.infra.hostsViewPage.hostLimit": "Limite de l'hôte", "xpack.infra.hostsViewPage.hostLimit.tooltip": "Pour garantir des performances de recherche plus rapides, le nombre d'hôtes retournés est limité.", "xpack.infra.hostsViewPage.landing.calloutReachOutToYourKibanaAdministrator": "Votre rôle d'utilisateur ne dispose pas des privilèges suffisants pour activer cette fonctionnalité - veuillez \n contacter votre administrateur Kibana et lui demander de visiter cette page pour activer la fonctionnalité.", @@ -18847,6 +18834,19 @@ "xpack.infra.logStreamEmbeddable.description": "Ajoutez un tableau de logs de diffusion en direct.", "xpack.infra.logStreamEmbeddable.displayName": "Flux de log", "xpack.infra.logStreamEmbeddable.title": "Flux de log", + "xpack.infra.metadataEmbeddable.errorMessage": "Une erreur s'est produite lors du chargement des données. Essayez de {reload} et d'ouvrir à nouveau les détails de l'hôte.", + "xpack.infra.metadataEmbeddable.AddFilterAriaLabel": "Ajouter un filtre", + "xpack.infra.metadataEmbeddable.filterAdded": "Le filtre a été ajouté.", + "xpack.infra.metadataEmbeddable.filterAriaLabel": "Filtre", + "xpack.infra.metadataEmbeddable.setFilterByValueTooltip": "Filtrer par valeur", + "xpack.infra.metadataEmbeddable.setRemoveFilterTooltip": "Supprimer le filtre", + "xpack.infra.metadataEmbeddable.errorAction": "recharger la page", + "xpack.infra.metadataEmbeddable.errorTitle": "Désolé, une erreur est survenue.", + "xpack.infra.metadataEmbeddable.field": "Champ", + "xpack.infra.metadataEmbeddable.loading": "Chargement...", + "xpack.infra.metadataEmbeddable.noMetadataFound": "Métadonnées introuvables.", + "xpack.infra.metadataEmbeddable.searchForMetadata": "Rechercher des métadonnées…", + "xpack.infra.metadataEmbeddable.value": "Valeur", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "pourcent", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "Utilisation CPU", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "lit", @@ -39777,4 +39777,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f2cba718cfc37..56faf3a995aa8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18292,7 +18292,6 @@ "xpack.infra.deprecations.timestampAdjustIndexing": "インデックスを調整し、\"{field}\"をタイムスタンプとして使用します。", "xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "指定期間のデータの最後の{duration}", "xpack.infra.hostsViewPage.errorOnCreateOrLoadDataview": "データビューの作成中にエラーが発生しました:{metricAlias}", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorMessage": "データの読み込みエラーが発生しました。{reload}し、ホスト詳細をもう一度開いてください。", "xpack.infra.hostsViewPage.landing.calloutRoleClarificationWithDocsLink": "Kibanaの高度な設定にアクセスできるロールが必要です。{docsLink}", "xpack.infra.hostsViewPage.metricTrend.subtitle.average.limit": "({limit}ホストの)平均", "xpack.infra.hostsViewPage.metricTrend.subtitle.hostCount.limit": "{limit}に制限", @@ -18521,19 +18520,7 @@ "xpack.infra.hostsViewPage.experimentalBadgeDescription": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。", "xpack.infra.hostsViewPage.experimentalBadgeLabel": "テクニカルプレビュー", "xpack.infra.hostsViewPage.flyout.apmServicesLinkLabel": "APMサービス", - "xpack.infra.hostsViewPage.flyout.metadata.AddFilterAriaLabel": "フィルターを追加", - "xpack.infra.hostsViewPage.flyout.metadata.filterAdded": "フィルターが追加されました", - "xpack.infra.hostsViewPage.flyout.metadata.filterAriaLabel": "フィルター", - "xpack.infra.hostsViewPage.flyout.metadata.setFilterByValueTooltip": "値でフィルタリング", - "xpack.infra.hostsViewPage.flyout.metadata.setRemoveFilterTooltip": "フィルターを削除", "xpack.infra.hostsViewPage.flyout.uptimeLinkLabel": "アップタイム", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorAction": "ページを再読み込み", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorTitle": "申し訳ございません、エラーが発生しました", - "xpack.infra.hostsViewPage.hostDetail.metadata.field": "フィールド", - "xpack.infra.hostsViewPage.hostDetail.metadata.loading": "読み込み中...", - "xpack.infra.hostsViewPage.hostDetail.metadata.noMetadataFound": "メタデータが見つかりません。", - "xpack.infra.hostsViewPage.hostDetail.metadata.searchForMetadata": "メタデータを検索...", - "xpack.infra.hostsViewPage.hostDetail.metadata.value": "値", "xpack.infra.hostsViewPage.hostLimit": "ホスト制限", "xpack.infra.hostsViewPage.hostLimit.tooltip": "クエリパフォーマンスを確実に高めるために、返されるホスト数には制限があります", "xpack.infra.hostsViewPage.landing.calloutReachOutToYourKibanaAdministrator": "ユーザーロールには、この機能を有効にするための十分な権限がありません。 \n この機能を有効にするために、Kibana管理者に連絡して、このページにアクセスするように依頼してください。", @@ -18846,6 +18833,19 @@ "xpack.infra.logStreamEmbeddable.description": "ライブストリーミングログのテーブルを追加します。", "xpack.infra.logStreamEmbeddable.displayName": "ログストリーム", "xpack.infra.logStreamEmbeddable.title": "ログストリーム", + "xpack.infra.metadataEmbeddable.errorMessage": "データの読み込みエラーが発生しました。{reload}し、ホスト詳細をもう一度開いてください。", + "xpack.infra.metadataEmbeddable.AddFilterAriaLabel": "フィルターを追加", + "xpack.infra.metadataEmbeddable.filterAdded": "フィルターが追加されました", + "xpack.infra.metadataEmbeddable.filterAriaLabel": "フィルター", + "xpack.infra.metadataEmbeddable.setFilterByValueTooltip": "値でフィルタリング", + "xpack.infra.metadataEmbeddable.setRemoveFilterTooltip": "フィルターを削除", + "xpack.infra.metadataEmbeddable.errorAction": "ページを再読み込み", + "xpack.infra.metadataEmbeddable.errorTitle": "申し訳ございません、エラーが発生しました", + "xpack.infra.metadataEmbeddable.field": "フィールド", + "xpack.infra.metadataEmbeddable.loading": "読み込み中...", + "xpack.infra.metadataEmbeddable.noMetadataFound": "メタデータが見つかりません。", + "xpack.infra.metadataEmbeddable.searchForMetadata": "メタデータを検索...", + "xpack.infra.metadataEmbeddable.value": "値", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用状況", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "読み取り", @@ -39747,4 +39747,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cb804bf53c729..c1e2edca82715 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18292,7 +18292,6 @@ "xpack.infra.deprecations.timestampAdjustIndexing": "调整索引以将“{field}”用作时间戳。", "xpack.infra.homePage.toolbar.showingLastOneMinuteDataText": "选定时间过去 {duration}的数据", "xpack.infra.hostsViewPage.errorOnCreateOrLoadDataview": "尝试创建以下数据视图时出错:{metricAlias}", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorMessage": "加载数据时出错。尝试{reload}并再次打开主机详情。", "xpack.infra.hostsViewPage.landing.calloutRoleClarificationWithDocsLink": "他们将需要有权访问 Kibana 中的高级设置的角色。{docsLink}", "xpack.infra.hostsViewPage.metricTrend.subtitle.average.limit": "平均值(属于 {limit} 台主机)", "xpack.infra.hostsViewPage.metricTrend.subtitle.hostCount.limit": "限定为 {limit}", @@ -18521,19 +18520,7 @@ "xpack.infra.hostsViewPage.experimentalBadgeDescription": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。", "xpack.infra.hostsViewPage.experimentalBadgeLabel": "技术预览", "xpack.infra.hostsViewPage.flyout.apmServicesLinkLabel": "APM 服务", - "xpack.infra.hostsViewPage.flyout.metadata.AddFilterAriaLabel": "添加筛选", - "xpack.infra.hostsViewPage.flyout.metadata.filterAdded": "已添加筛选", - "xpack.infra.hostsViewPage.flyout.metadata.filterAriaLabel": "筛选", - "xpack.infra.hostsViewPage.flyout.metadata.setFilterByValueTooltip": "按值筛选", - "xpack.infra.hostsViewPage.flyout.metadata.setRemoveFilterTooltip": "移除筛选", "xpack.infra.hostsViewPage.flyout.uptimeLinkLabel": "运行时间", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorAction": "重新加载页面", - "xpack.infra.hostsViewPage.hostDetail.metadata.errorTitle": "抱歉,有错误", - "xpack.infra.hostsViewPage.hostDetail.metadata.field": "字段", - "xpack.infra.hostsViewPage.hostDetail.metadata.loading": "正在加载……", - "xpack.infra.hostsViewPage.hostDetail.metadata.noMetadataFound": "找不到元数据。", - "xpack.infra.hostsViewPage.hostDetail.metadata.searchForMetadata": "搜索元数据……", - "xpack.infra.hostsViewPage.hostDetail.metadata.value": "值", "xpack.infra.hostsViewPage.hostLimit": "主机限制", "xpack.infra.hostsViewPage.hostLimit.tooltip": "为确保更快的查询性能,对返回的主机数量实施了限制", "xpack.infra.hostsViewPage.landing.calloutReachOutToYourKibanaAdministrator": "您的用户角色权限不足,无法启用此功能 - 请 \n 联系您的 Kibana 管理员,要求他们访问此页面以启用该功能。", @@ -18846,6 +18833,19 @@ "xpack.infra.logStreamEmbeddable.description": "添加实时流式传输日志的表。", "xpack.infra.logStreamEmbeddable.displayName": "日志流", "xpack.infra.logStreamEmbeddable.title": "日志流", + "xpack.infra.metadataEmbeddable.errorMessage": "加载数据时出错。尝试{reload}并再次打开主机详情。", + "xpack.infra.metadataEmbeddable.AddFilterAriaLabel": "添加筛选", + "xpack.infra.metadataEmbeddable.filterAdded": "已添加筛选", + "xpack.infra.metadataEmbeddable.filterAriaLabel": "筛选", + "xpack.infra.metadataEmbeddable.setFilterByValueTooltip": "按值筛选", + "xpack.infra.metadataEmbeddable.setRemoveFilterTooltip": "移除筛选", + "xpack.infra.metadataEmbeddable.errorAction": "重新加载页面", + "xpack.infra.metadataEmbeddable.errorTitle": "抱歉,有错误", + "xpack.infra.metadataEmbeddable.field": "字段", + "xpack.infra.metadataEmbeddable.loading": "正在加载……", + "xpack.infra.metadataEmbeddable.noMetadataFound": "找不到元数据。", + "xpack.infra.metadataEmbeddable.searchForMetadata": "搜索元数据……", + "xpack.infra.metadataEmbeddable.value": "值", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用率", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "读取数", @@ -39741,4 +39741,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} +} \ No newline at end of file From 2f80cb38af57bc4ec27e28351f138ed1102582e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Wed, 24 May 2023 22:06:13 +0200 Subject: [PATCH 12/20] Bump dpdm (#158153) ## Summary Bump dpdm --- package.json | 2 +- yarn.lock | 144 +++++++++++++-------------------------------------- 2 files changed, 37 insertions(+), 109 deletions(-) diff --git a/package.json b/package.json index 8e53ef137d9e5..13eb7b8374c80 100644 --- a/package.json +++ b/package.json @@ -1367,7 +1367,7 @@ "delete-empty": "^2.0.0", "dependency-check": "^4.1.0", "diff": "^4.0.1", - "dpdm": "3.5.0", + "dpdm": "3.9.0", "ejs": "^3.1.8", "enzyme": "^3.11.0", "enzyme-to-json": "^3.6.2", diff --git a/yarn.lock b/yarn.lock index ed8aa3bc746d1..bb08f35a14e8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8285,10 +8285,10 @@ resolved "https://registry.yarnpkg.com/@types/fnv-plus/-/fnv-plus-1.3.0.tgz#0f43f0b7e7b4b24de3a1cab69bfa009508f4c084" integrity sha512-ijls8MsO6Q9JUSd5w1v4y2ijM6S4D/nmOyI/FwcepvrZfym0wZhLdYGFD5TJID7tga0O3I7SmtK69RzpSJ1Fcw== -"@types/fs-extra@^8.0.0": - version "8.1.1" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-8.1.1.tgz#1e49f22d09aa46e19b51c0b013cb63d0d923a068" - integrity sha512-TcUlBem321DFQzBNuz8p0CLLKp0VvF/XH9E4KHNmgwyp4E3AfgI5cjiIVZWlbfThBop2qxFIh4+LeY6hVWWZ2w== +"@types/fs-extra@^9.0.13": + version "9.0.13" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" + integrity sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== dependencies: "@types/node" "*" @@ -8310,10 +8310,10 @@ "@types/glob" "*" "@types/node" "*" -"@types/glob@*", "@types/glob@^7.1.1", "@types/glob@^7.1.3": - version "7.1.3" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183" - integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w== +"@types/glob@*", "@types/glob@^7.1.1", "@types/glob@^7.1.3", "@types/glob@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" + integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== dependencies: "@types/minimatch" "*" "@types/node" "*" @@ -9461,13 +9461,6 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.0.0.tgz#453743c5bbf9f1bed61d959baab5b06be029b2d0" integrity sha512-wBlsw+8n21e6eTd4yVv8YD/E3xq0O6nNnJIquutAsFGE7EyMKz7W6RNT6BRu1SmdgmlCZ9tb0X+j+D6HGr8pZw== -"@types/yargs@^13.0.0": - version "13.0.2" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-13.0.2.tgz#a64674fc0149574ecd90ba746e932b5a5f7b3653" - integrity sha512-lwwgizwk/bIIU+3ELORkyuOgDjCh7zuWDFqRtPPhhVgq9N1F7CvLNKg1TX4f2duwtKQ0p044Au9r1PLIXHrIzQ== - dependencies: - "@types/yargs-parser" "*" - "@types/yargs@^15.0.0": version "15.0.3" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.3.tgz#41453a0bc7ab393e995d1f5451455638edbd2baf" @@ -9482,10 +9475,10 @@ dependencies: "@types/yargs-parser" "*" -"@types/yargs@^17.0.8": - version "17.0.10" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.10.tgz#591522fce85d8739bca7b8bb90d048e4478d186a" - integrity sha512-gmEaFwpj/7f/ROdtIlci1R1VYU1J4j95m8T+Tj3iBgiBFKg1foE/PSl93bBd5T9LDXNPo8UlNN6W0qwD8O5OaA== +"@types/yargs@^17.0.8", "@types/yargs@^17.0.10": + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== dependencies: "@types/yargs-parser" "*" @@ -10282,7 +10275,7 @@ ansi-styles@^2.0.1, ansi-styles@^2.2.1: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= -ansi-styles@^3.2.0, ansi-styles@^3.2.1: +ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== @@ -12182,15 +12175,6 @@ cliui@^3.0.3: strip-ansi "^3.0.1" wrap-ansi "^2.0.0" -cliui@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" - integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== - dependencies: - string-width "^3.1.0" - strip-ansi "^5.2.0" - wrap-ansi "^5.1.0" - cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" @@ -14259,21 +14243,21 @@ downshift@^3.2.10: prop-types "^15.7.2" react-is "^16.9.0" -dpdm@3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/dpdm/-/dpdm-3.5.0.tgz#414402f21928694bc86cfe8e3583dc8fc97d013e" - integrity sha512-bff2gDpYyzmIOMwRp0Bsk0T4e/qgLRCeuGHZYEsJV0LRzuTUkXirCiLcme7Ebu/LVoQ8yAKiody5/1e51tsmFw== +dpdm@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/dpdm/-/dpdm-3.9.0.tgz#48d8236d7a054ee84cf13423ecf300f64da1393b" + integrity sha512-k6VpCyjVUMFVBa6w+TO9bYQdbYkAx6oivUC857757EUtri9CFsj2VpLPkOLrbbLggwRFqLbNHHL3XGPYRovULg== dependencies: - "@types/fs-extra" "^8.0.0" - "@types/glob" "^7.1.1" - "@types/yargs" "^13.0.0" - chalk "^2.4.2" - fs-extra "^8.1.0" - glob "^7.1.4" - ora "^4.0.3" - tslib "^1.10.0" - typescript "^3.5.3" - yargs "^13.3.0" + "@types/fs-extra" "^9.0.13" + "@types/glob" "^7.2.0" + "@types/yargs" "^17.0.10" + chalk "^4.1.2" + fs-extra "^10.0.1" + glob "^7.2.0" + ora "^5.4.1" + tslib "^2.3.1" + typescript "^4.6.3" + yargs "^17.4.0" duplexer2@^0.1.2, duplexer2@~0.1.4: version "0.1.4" @@ -14508,11 +14492,6 @@ emittery@^0.13.1: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4= -emoji-regex@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -16297,7 +16276,7 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^10.0.0, fs-extra@^10.1.0: +fs-extra@^10.0.0, fs-extra@^10.0.1, fs-extra@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== @@ -16315,15 +16294,6 @@ fs-extra@^7.0.1: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@^9.0.0, fs-extra@^9.0.1, fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -22332,7 +22302,7 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -ora@^4.0.3, ora@^4.0.4: +ora@^4.0.4: version "4.1.1" resolved "https://registry.yarnpkg.com/ora/-/ora-4.1.1.tgz#566cc0348a15c36f5f0e979612842e02ba9dddbc" integrity sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A== @@ -26709,15 +26679,6 @@ string-width@^2.1.1: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" -string-width@^3.0.0, string-width@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - "string.prototype.matchall@^4.0.0 || ^3.0.1", string.prototype.matchall@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.5.tgz#59370644e1db7e4c0c045277690cf7b01203c4da" @@ -26798,7 +26759,7 @@ stringify-entities@^3.0.0, stringify-entities@^3.0.1: is-decimal "^1.0.2" is-hexadecimal "^1.0.0" -strip-ansi@5.2.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: +strip-ansi@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== @@ -27924,7 +27885,7 @@ typescript-tuple@^2.2.1: dependencies: typescript-compare "^0.0.2" -typescript@4.6.3, typescript@^3.3.3333, typescript@^3.5.3, typescript@^4.8.4: +typescript@4.6.3, typescript@^3.3.3333, typescript@^4.6.3, typescript@^4.8.4: version "4.6.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw== @@ -29570,15 +29531,6 @@ wrap-ansi@^3.0.1: string-width "^2.1.1" strip-ansi "^4.0.0" -wrap-ansi@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" - integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== - dependencies: - ansi-styles "^3.2.0" - string-width "^3.0.0" - strip-ansi "^5.0.0" - wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" @@ -29782,14 +29734,6 @@ yargs-parser@20.2.4, yargs-parser@^20.2.2, yargs-parser@^20.2.3: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== -yargs-parser@^13.1.2: - version "13.1.2" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" - integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" @@ -29839,22 +29783,6 @@ yargs@17.7.1: y18n "^5.0.5" yargs-parser "^21.1.1" -yargs@^13.3.0: - version "13.3.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" - integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== - dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.2" - yargs@^15.0.2, yargs@^15.3.1, yargs@^15.4.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" @@ -29872,10 +29800,10 @@ yargs@^15.0.2, yargs@^15.3.1, yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^17.2.1, yargs@^17.3.1, yargs@^17.6.0: - version "17.6.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.6.0.tgz#e134900fc1f218bc230192bdec06a0a5f973e46c" - integrity sha512-8H/wTDqlSwoSnScvV2N/JHfLWOKuh5MVla9hqLjK3nsfyy6Y4kDSYSvkU5YCUEPOSnRXfIyx3Sq+B/IWudTo4g== +yargs@^17.2.1, yargs@^17.3.1, yargs@^17.4.0, yargs@^17.6.0: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== dependencies: cliui "^8.0.1" escalade "^3.1.1" @@ -29883,7 +29811,7 @@ yargs@^17.2.1, yargs@^17.3.1, yargs@^17.6.0: require-directory "^2.1.1" string-width "^4.2.3" y18n "^5.0.5" - yargs-parser "^21.0.0" + yargs-parser "^21.1.1" yargs@^3.15.0: version "3.32.0" From ba17d3a7ec5d819a667f82d97e1501c071fb09a2 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 24 May 2023 21:25:04 +0100 Subject: [PATCH 13/20] skip flaky suite (#158408) --- .../api_integration/apis/synthetics/enable_default_alerting.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts b/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts index 70aff5ce209de..9c98a7c816b73 100644 --- a/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts +++ b/x-pack/test/api_integration/apis/synthetics/enable_default_alerting.ts @@ -17,7 +17,8 @@ import { Spaces } from '../../../alerting_api_integration/spaces_only/scenarios' import { ObjectRemover } from '../../../alerting_api_integration/common/lib'; export default function ({ getService }: FtrProviderContext) { - describe('EnableDefaultAlerting', function () { + // FLAKY: https://github.com/elastic/kibana/issues/158408 + describe.skip('EnableDefaultAlerting', function () { this.tags('skipCloud'); const supertest = getService('supertest'); From ab7b2f7f79978a8c1a24859dffbb16c013d82e3a Mon Sep 17 00:00:00 2001 From: Jeramy Soucy Date: Wed, 24 May 2023 16:33:29 -0400 Subject: [PATCH 14/20] Fixes documentation of response codes for copy saved objects (#158416) ## Summary Moves the response codes documented for the copy saved object API to the correct document file. Documentation was incorrectly located in https://github.com/elastic/kibana/pull/158036 --- docs/api/spaces-management/copy_saved_objects.asciidoc | 9 +++++++++ .../api/spaces-management/update_objects_spaces.asciidoc | 9 --------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/api/spaces-management/copy_saved_objects.asciidoc b/docs/api/spaces-management/copy_saved_objects.asciidoc index b402e73dadd19..20c1cdd567e94 100644 --- a/docs/api/spaces-management/copy_saved_objects.asciidoc +++ b/docs/api/spaces-management/copy_saved_objects.asciidoc @@ -69,6 +69,15 @@ NOTE: This option cannot be used with the `createNewCopies` option. + NOTE: This option cannot be used with the `createNewCopies` option. +[[spaces-api-copy-saved-objects-response-codes]] +==== Response codes + +`200`:: + Indicates a successful call. + +`404`:: + Indicates that the request failed because one or more of the objects specified could not be found. A list of the unresolved objects are included in the 404 response attributes. + [role="child_attributes"] [[spaces-api-copy-saved-objects-response-body]] ==== {api-response-body-title} diff --git a/docs/api/spaces-management/update_objects_spaces.asciidoc b/docs/api/spaces-management/update_objects_spaces.asciidoc index 5938ddb4e4315..dec846fd6fee0 100644 --- a/docs/api/spaces-management/update_objects_spaces.asciidoc +++ b/docs/api/spaces-management/update_objects_spaces.asciidoc @@ -36,15 +36,6 @@ Updates one or more saved objects to add and/or remove them from specified space `spacesToRemove`:: (Required, string array) The IDs of the spaces the specified objects should be removed from. -[[spaces-api-update-objects-spaces-response-codes]] -==== Response codes - -`200`:: - Indicates a successful call. - -`404`:: - Indicates that the request failed because one or more of the objects specified could not be found. A list of the unresolved objects are included in the 404 response attributes. - [role="child_attributes"] [[spaces-api-update-objects-spaces-response-body]] ==== {api-response-body-title} From fcc0e0c153e7041c2dde34c36e9b4a7d62d76143 Mon Sep 17 00:00:00 2001 From: Lola Date: Wed, 24 May 2023 16:33:56 -0400 Subject: [PATCH 15/20] [Cloud Posture] fix end of line for json details (#158393) ## Summary Summarize your PR. If it involves visual changes include a screenshot or gif. Added an offset bottom to json detail tab so pagination doesn't cut off json details Screen Shot 2023-05-24 at 11 44 15 AM --- .../vulnerability_json_tab.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_json_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_json_tab.tsx index 7aa78a5b8b726..93d83be8b7632 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_json_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_json_tab.tsx @@ -14,10 +14,11 @@ interface VulnerabilityJsonTabProps { vulnerabilityRecord: VulnerabilityRecord; } export const VulnerabilityJsonTab = ({ vulnerabilityRecord }: VulnerabilityJsonTabProps) => { - const offsetHeight = 188; + const offsetTopHeight = 188; + const offsetBottomHeight = 60; return (
Date: Wed, 24 May 2023 13:40:27 -0700 Subject: [PATCH 16/20] [DOCS] Clarify domain for ServiceNow connectors (#158336) --- docs/management/connectors/action-types/servicenow-sir.asciidoc | 2 +- docs/management/connectors/action-types/servicenow.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 56f1ccef67ac7..0fc96f9baa85c 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -49,7 +49,7 @@ A CORS rule is required for communication between Elastic and {sn}. To create a . Configure the rule as follows: * *Name*: Name the rule. * *REST API*: Set the rule to use the Elastic SecOps API by choosing `Elastic SIR API [x_elas2_sir_int/elastic_api]`. -* *Domain*: Enter the Kibana URL. +* *Domain*: Enter the Kibana URL, including the port number. . Go to the *HTTP methods* tab and select *GET*. . Click *Submit* to create the rule. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index b1ad3be504af4..2151100451232 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -54,7 +54,7 @@ A CORS rule is required for communication between Elastic and {sn}. To create a . Configure the rule as follows: * *Name*: Name the rule. * *REST API*: Set the rule to use the Elastic ITSM API by choosing `Elastic ITSM API [x_elas2_inc_int/elastic_api]`. -* *Domain*: Enter the Kibana URL. +* *Domain*: Enter the Kibana URL, including the port number. . Go to the *HTTP methods* tab and select *GET*. . Click *Submit* to create the rule. From 123e5357545be95ff8b7060319559086938c0e61 Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Wed, 24 May 2023 16:02:20 -0500 Subject: [PATCH 17/20] [Security Solution] Entities details tab in expandable flyout (#155809) ## Summary This PR adds content to the 'Entities' tab under ' Insights', in the left section of the expandable flyout. - User info contains an user overview and related hosts. Related hosts are hosts this user has successfully authenticated after alert time - Host info contains a host overview and related users. Related users are users who are successfully authenticated to this host after alert time - User and host risk scores are displayed if kibana user has platinum license ![image](https://user-images.githubusercontent.com/18648970/234703183-a3fa7809-cc1f-4b9a-8bd0-aa2a991047cb.png) ### How to test - Enable feature flag `securityFlyoutEnabled` - Navigation: - Generate some alerts data and go to Alerts page - Select the expand icon for an alert - Click `Expand alert details` - Go to Insights tab, Entities tab - To see risk score, apply platinum or enterprise license, then go to dashboard -> entity analytics, and click Enable (both user and host). - See comments below on generating test data (if needed) ### Run tests and storybook - `node scripts/storybook security_solution` to run Storybook - `npm run test:jest --config ./x-pack/plugins/security_solution/public/flyout` to run the unit tests - `yarn cypress:open-as-ci` but note that the integration/e2e tests have been written but are now skipped because the feature is protected behind a feature flag, disabled by default. To check them, add `'securityFlyoutEnabled'` [here](https://github.com/elastic/kibana/blob/main/x-pack/test/security_solution_cypress/config.ts#L50) ### Checklist - [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 --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/index.ts | 19 ++ .../related_entities/index.ts | 14 + .../related_entities/related_hosts/index.tsx | 42 +++ .../related_entities/related_users/index.tsx | 42 +++ .../alert_details_left_panel.cy.ts | 4 +- ...lert_details_left_panel_entities_tab.cy.ts | 52 ++++ .../screens/document_expandable_flyout.ts | 7 + .../related_hosts/index.test.tsx | 80 +++++ .../related_entities/related_hosts/index.tsx | 81 +++++ .../related_hosts/translations.ts | 15 + .../related_users/index.test.tsx | 80 +++++ .../related_entities/related_users/index.tsx | 81 +++++ .../related_users/translations.ts | 15 + .../left/components/entities_details.test.tsx | 91 ++++++ .../left/components/entities_details.tsx | 26 +- .../left/components/host_details.test.tsx | 235 ++++++++++++++ .../flyout/left/components/host_details.tsx | 289 ++++++++++++++++++ .../public/flyout/left/components/test_ids.ts | 16 +- .../flyout/left/components/translations.ts | 68 ++++- .../left/components/user_details.test.tsx | 236 ++++++++++++++ .../flyout/left/components/user_details.tsx | 288 +++++++++++++++++ .../public/flyout/left/context.tsx | 14 +- .../public/flyout/left/mocks/mock_context.ts | 41 +++ .../components/entities_overview.test.tsx | 26 +- .../right/components/entities_overview.tsx | 14 +- .../right/components/entity_panel.stories.tsx | 31 +- .../right/components/entity_panel.test.tsx | 85 ++++-- .../flyout/right/components/entity_panel.tsx | 131 ++++---- .../flyout/right/components/test_ids.ts | 12 +- .../factory/hosts/all/index.ts | 2 +- .../security_solution/factory/index.ts | 2 + .../factory/related_entities/index.ts | 21 ++ .../related_hosts/__mocks__/index.ts | 158 ++++++++++ .../related_hosts/index.test.ts | 88 ++++++ .../related_entities/related_hosts/index.ts | 92 ++++++ .../query.related_hosts.dsl.test.ts | 15 + .../related_hosts/query.related_hosts.dsl.ts | 61 ++++ .../related_users/__mocks__/index.ts | 154 ++++++++++ .../related_users/index.test.ts | 88 ++++++ .../related_entities/related_users/index.ts | 94 ++++++ .../query.related_users.dsl.test.ts | 15 + .../related_users/query.related_users.dsl.ts | 61 ++++ .../factory/users/all/index.ts | 2 +- .../translations/translations/fr-FR.json | 4 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 46 files changed, 2882 insertions(+), 118 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx create mode 100644 x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/translations.ts create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index a283ea4cbade9..f15a5e38af5f8 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -111,6 +111,15 @@ import type { ManagedUserDetailsRequestOptions, ManagedUserDetailsStrategyResponse, } from './users/managed_details'; +import type { RelatedEntitiesQueries } from './related_entities'; +import type { + UsersRelatedHostsRequestOptions, + UsersRelatedHostsStrategyResponse, +} from './related_entities/related_hosts'; +import type { + HostsRelatedUsersRequestOptions, + HostsRelatedUsersStrategyResponse, +} from './related_entities/related_users'; export * from './cti'; export * from './hosts'; @@ -119,6 +128,7 @@ export * from './matrix_histogram'; export * from './network'; export * from './users'; export * from './first_last_seen'; +export * from './related_entities'; export type FactoryQueryTypes = | HostsQueries @@ -130,6 +140,7 @@ export type FactoryQueryTypes = | CtiQueries | typeof MatrixHistogramQuery | typeof FirstLastSeenQuery + | RelatedEntitiesQueries | ResponseActionsQueries; export interface RequestBasicOptions extends IEsSearchRequest { @@ -215,6 +226,10 @@ export type StrategyResponseType = T extends HostsQ ? UsersRiskScoreStrategyResponse : T extends RiskQueries.kpiRiskScore ? KpiRiskScoreStrategyResponse + : T extends RelatedEntitiesQueries.relatedUsers + ? HostsRelatedUsersStrategyResponse + : T extends RelatedEntitiesQueries.relatedHosts + ? UsersRelatedHostsStrategyResponse : T extends ResponseActionsQueries.actions ? ActionRequestStrategyResponse : T extends ResponseActionsQueries.results @@ -285,6 +300,10 @@ export type StrategyRequestType = T extends HostsQu ? RiskScoreRequestOptions : T extends RiskQueries.kpiRiskScore ? KpiRiskScoreRequestOptions + : T extends RelatedEntitiesQueries.relatedHosts + ? UsersRelatedHostsRequestOptions + : T extends RelatedEntitiesQueries.relatedUsers + ? HostsRelatedUsersRequestOptions : T extends ResponseActionsQueries.actions ? ActionRequestOptions : T extends ResponseActionsQueries.results diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts new file mode 100644 index 0000000000000..d4f4507c1d577 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './related_hosts'; +export * from './related_users'; + +export enum RelatedEntitiesQueries { + relatedHosts = 'relatedHosts', + relatedUsers = 'relatedUsers', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx new file mode 100644 index 0000000000000..bba4a3906f99d --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { RiskSeverity, Inspect, Maybe } from '../../..'; +import type { RequestBasicOptions } from '../..'; +import type { BucketItem } from '../../cti'; + +export interface RelatedHost { + host: string; + ip: string[]; + risk?: RiskSeverity; +} + +export interface RelatedHostBucket { + key: string; + doc_count: number; + ip?: IPItems; +} + +interface IPItems { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; +} + +export interface UsersRelatedHostsStrategyResponse extends IEsSearchResponse { + totalCount: number; + relatedHosts: RelatedHost[]; + inspect?: Maybe; +} + +export interface UsersRelatedHostsRequestOptions extends Partial { + userName: string; + skip?: boolean; + from: string; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx new file mode 100644 index 0000000000000..e2f7c0c4bd016 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { RiskSeverity, Inspect, Maybe } from '../../..'; +import type { RequestBasicOptions } from '../..'; +import type { BucketItem } from '../../cti'; + +export interface RelatedUser { + user: string; + ip: string[]; + risk?: RiskSeverity; +} + +export interface RelatedUserBucket { + key: string; + doc_count: number; + ip?: IPItems; +} + +interface IPItems { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; +} + +export interface HostsRelatedUsersStrategyResponse extends IEsSearchResponse { + totalCount: number; + relatedUsers: RelatedUser[]; + inspect?: Maybe; +} + +export interface HostsRelatedUsersRequestOptions extends Partial { + hostName: string; + skip?: boolean; + from: string; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts index 1832188159b3d..ded9f85ccac7d 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts @@ -127,9 +127,7 @@ describe.skip('Alert details expandable flyout left panel', { testIsolation: fal it('should display content when switching buttons', () => { openInsightsTab(); openEntities(); - cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT) - .should('be.visible') - .and('have.text', 'Entities'); + cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT).should('be.visible'); openThreatIntelligence(); cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_THREAT_INTELLIGENCE_CONTENT) diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts new file mode 100644 index 0000000000000..7c9d33b378baf --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_USER_DETAILS, + DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_HOST_DETAILS, +} from '../../../screens/document_expandable_flyout'; +import { + expandFirstAlertExpandableFlyout, + openInsightsTab, + openEntities, + expandDocumentDetailsExpandableFlyoutLeftSection, +} from '../../../tasks/document_expandable_flyout'; +import { cleanKibana } from '../../../tasks/common'; +import { login, visit } from '../../../tasks/login'; +import { createRule } from '../../../tasks/api_calls/rules'; +import { getNewRule } from '../../../objects/rule'; +import { ALERTS_URL } from '../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + +// Skipping these for now as the feature is protected behind a feature flag set to false by default +// To run the tests locally, add 'securityFlyoutEnabled' in the Cypress config.ts here https://github.com/elastic/kibana/blob/main/x-pack/test/security_solution_cypress/config.ts#L50 +describe.skip( + 'Alert details expandable flyout left panel entities', + { testIsolation: false }, + () => { + before(() => { + cleanKibana(); + login(); + createRule(getNewRule()); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + expandFirstAlertExpandableFlyout(); + expandDocumentDetailsExpandableFlyoutLeftSection(); + openInsightsTab(); + openEntities(); + }); + + it('should display analyzer graph and node list', () => { + cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_USER_DETAILS) + .scrollIntoView() + .should('be.visible'); + cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_HOST_DETAILS) + .scrollIntoView() + .should('be.visible'); + }); + } +); diff --git a/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts b/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts index 5028d38385aca..ed3a680a130f2 100644 --- a/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts +++ b/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts @@ -12,6 +12,8 @@ import { THREAT_INTELLIGENCE_DETAILS_TEST_ID, PREVALENCE_DETAILS_TEST_ID, CORRELATIONS_DETAILS_TEST_ID, + USER_DETAILS_TEST_ID, + HOST_DETAILS_TEST_ID, } from '../../public/flyout/left/components/test_ids'; import { HISTORY_TAB_CONTENT_TEST_ID, @@ -155,6 +157,11 @@ export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_BUTTON = getDataTestS ); export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT = getDataTestSubjectSelector(ENTITIES_DETAILS_TEST_ID); +export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_USER_DETAILS = + getDataTestSubjectSelector(USER_DETAILS_TEST_ID); +export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_HOST_DETAILS = + getDataTestSubjectSelector(HOST_DETAILS_TEST_ID); + export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_THREAT_INTELLIGENCE_BUTTON = getDataTestSubjectSelector(INSIGHTS_TAB_THREAT_INTELLIGENCE_BUTTON_TEST_ID); export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_THREAT_INTELLIGENCE_CONTENT = diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx new file mode 100644 index 0000000000000..e7ef62fc95b80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../mock'; +import { useUserRelatedHosts } from '.'; +import { useSearchStrategy } from '../../use_search_strategy'; + +jest.mock('../../use_search_strategy', () => ({ + useSearchStrategy: jest.fn(), +})); +const mockUseSearchStrategy = useSearchStrategy as jest.Mock; +const mockSearch = jest.fn(); + +const defaultProps = { + userName: 'user1', + indexNames: ['index-*'], + from: '2020-07-07T08:20:18.966Z', + skip: false, +}; + +const mockResult = { + inspect: {}, + totalCount: 1, + relatedHosts: [{ host: 'test host', ip: '100.000.XX' }], + loading: false, + refetch: jest.fn(), +}; + +describe('useUsersRelatedHosts', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSearchStrategy.mockReturnValue({ + loading: false, + result: { + totalCount: mockResult.totalCount, + relatedHosts: mockResult.relatedHosts, + }, + search: mockSearch, + refetch: jest.fn(), + inspect: {}, + }); + }); + + it('runs search', () => { + const { result } = renderHook(() => useUserRelatedHosts(defaultProps), { + wrapper: TestProviders, + }); + + expect(mockSearch).toHaveBeenCalled(); + expect(JSON.stringify(result.current)).toEqual(JSON.stringify(mockResult)); // serialize result for array comparison + }); + + it('does not run search when skip = true', () => { + const props = { + ...defaultProps, + skip: true, + }; + renderHook(() => useUserRelatedHosts(props), { + wrapper: TestProviders, + }); + + expect(mockSearch).not.toHaveBeenCalled(); + }); + it('skip = true will cancel any running request', () => { + const props = { + ...defaultProps, + }; + const { rerender } = renderHook(() => useUserRelatedHosts(props), { + wrapper: TestProviders, + }); + props.skip = true; + act(() => rerender()); + expect(mockUseSearchStrategy).toHaveBeenCalledTimes(2); + expect(mockUseSearchStrategy.mock.calls[1][0].abort).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx new file mode 100644 index 0000000000000..d43410ae86ae1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx @@ -0,0 +1,81 @@ +/* + * 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 { useEffect, useMemo } from 'react'; +import type { inputsModel } from '../../../store'; +import type { InspectResponse } from '../../../../types'; +import { RelatedEntitiesQueries } from '../../../../../common/search_strategy/security_solution/related_entities'; +import type { RelatedHost } from '../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import { useSearchStrategy } from '../../use_search_strategy'; +import { FAIL_RELATED_HOSTS } from './translations'; + +export interface UseUserRelatedHostsResult { + inspect: InspectResponse; + totalCount: number; + relatedHosts: RelatedHost[]; + refetch: inputsModel.Refetch; + loading: boolean; +} + +interface UseUserRelatedHostsParam { + userName: string; + indexNames: string[]; + from: string; + skip?: boolean; +} + +export const useUserRelatedHosts = ({ + userName, + indexNames, + from, + skip = false, +}: UseUserRelatedHostsParam): UseUserRelatedHostsResult => { + const { + loading, + result: response, + search, + refetch, + inspect, + } = useSearchStrategy({ + factoryQueryType: RelatedEntitiesQueries.relatedHosts, + initialResult: { + totalCount: 0, + relatedHosts: [], + }, + errorMessage: FAIL_RELATED_HOSTS, + abort: skip, + }); + + const userRelatedHostsResponse = useMemo( + () => ({ + inspect, + totalCount: response.totalCount, + relatedHosts: response.relatedHosts, + refetch, + loading, + }), + [inspect, refetch, response.totalCount, response.relatedHosts, loading] + ); + + const userRelatedHostsRequest = useMemo( + () => ({ + defaultIndex: indexNames, + factoryQueryType: RelatedEntitiesQueries.relatedHosts, + userName, + from, + }), + [indexNames, from, userName] + ); + + useEffect(() => { + if (!skip) { + search(userRelatedHostsRequest); + } + }, [userRelatedHostsRequest, search, skip]); + + return userRelatedHostsResponse; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/translations.ts b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/translations.ts new file mode 100644 index 0000000000000..89ca68b5e931f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FAIL_RELATED_HOSTS = i18n.translate( + 'xpack.securitySolution.flyout.entities.failRelatedHostsDescription', + { + defaultMessage: `Failed to run search on related hosts`, + } +); diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx new file mode 100644 index 0000000000000..904b0e6569e65 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../mock'; +import { useHostRelatedUsers } from '.'; +import { useSearchStrategy } from '../../use_search_strategy'; + +jest.mock('../../use_search_strategy', () => ({ + useSearchStrategy: jest.fn(), +})); +const mockUseSearchStrategy = useSearchStrategy as jest.Mock; +const mockSearch = jest.fn(); + +const defaultProps = { + hostName: 'host1', + indexNames: ['index-*'], + from: '2020-07-07T08:20:18.966Z', + skip: false, +}; + +const mockResult = { + inspect: {}, + totalCount: 1, + relatedUsers: [{ user: 'test user', ip: '100.000.XX' }], + refetch: jest.fn(), + loading: false, +}; + +describe('useUsersRelatedHosts', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSearchStrategy.mockReturnValue({ + loading: false, + result: { + totalCount: mockResult.totalCount, + relatedUsers: mockResult.relatedUsers, + }, + search: mockSearch, + refetch: jest.fn(), + inspect: {}, + }); + }); + + it('runs search', () => { + const { result } = renderHook(() => useHostRelatedUsers(defaultProps), { + wrapper: TestProviders, + }); + + expect(mockSearch).toHaveBeenCalled(); + expect(JSON.stringify(result.current)).toEqual(JSON.stringify(mockResult)); // serialize result for array comparison + }); + + it('does not run search when skip = true', () => { + const props = { + ...defaultProps, + skip: true, + }; + renderHook(() => useHostRelatedUsers(props), { + wrapper: TestProviders, + }); + + expect(mockSearch).not.toHaveBeenCalled(); + }); + it('skip = true will cancel any running request', () => { + const props = { + ...defaultProps, + }; + const { rerender } = renderHook(() => useHostRelatedUsers(props), { + wrapper: TestProviders, + }); + props.skip = true; + act(() => rerender()); + expect(mockUseSearchStrategy).toHaveBeenCalledTimes(2); + expect(mockUseSearchStrategy.mock.calls[1][0].abort).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx new file mode 100644 index 0000000000000..7369ca2d57024 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx @@ -0,0 +1,81 @@ +/* + * 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 { useEffect, useMemo } from 'react'; +import type { inputsModel } from '../../../store'; +import type { InspectResponse } from '../../../../types'; +import { RelatedEntitiesQueries } from '../../../../../common/search_strategy/security_solution/related_entities'; +import type { RelatedUser } from '../../../../../common/search_strategy/security_solution/related_entities/related_users'; +import { useSearchStrategy } from '../../use_search_strategy'; +import { FAIL_RELATED_USERS } from './translations'; + +export interface UseHostRelatedUsersResult { + inspect: InspectResponse; + totalCount: number; + relatedUsers: RelatedUser[]; + refetch: inputsModel.Refetch; + loading: boolean; +} + +interface UseHostRelatedUsersParam { + hostName: string; + indexNames: string[]; + from: string; + skip?: boolean; +} + +export const useHostRelatedUsers = ({ + hostName, + indexNames, + from, + skip = false, +}: UseHostRelatedUsersParam): UseHostRelatedUsersResult => { + const { + loading, + result: response, + search, + refetch, + inspect, + } = useSearchStrategy({ + factoryQueryType: RelatedEntitiesQueries.relatedUsers, + initialResult: { + totalCount: 0, + relatedUsers: [], + }, + errorMessage: FAIL_RELATED_USERS, + abort: skip, + }); + + const hostRelatedUsersResponse = useMemo( + () => ({ + inspect, + totalCount: response.totalCount, + relatedUsers: response.relatedUsers, + refetch, + loading, + }), + [inspect, refetch, response.totalCount, response.relatedUsers, loading] + ); + + const hostRelatedUsersRequest = useMemo( + () => ({ + defaultIndex: indexNames, + factoryQueryType: RelatedEntitiesQueries.relatedUsers, + hostName, + from, + }), + [indexNames, from, hostName] + ); + + useEffect(() => { + if (!skip) { + search(hostRelatedUsersRequest); + } + }, [hostRelatedUsersRequest, search, skip]); + + return hostRelatedUsersResponse; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/translations.ts b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/translations.ts new file mode 100644 index 0000000000000..241c9bdb5da98 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/translations.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FAIL_RELATED_USERS = i18n.translate( + 'xpack.securitySolution.flyout.entities.failRelatedUsersDescription', + { + defaultMessage: `Failed to run search on related users`, + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx new file mode 100644 index 0000000000000..2b83ae558f274 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { LeftFlyoutContext } from '../context'; +import { TestProviders } from '../../../common/mock'; +import { EntitiesDetails } from './entities_details'; +import { ENTITIES_DETAILS_TEST_ID, HOST_DETAILS_TEST_ID, USER_DETAILS_TEST_ID } from './test_ids'; +import { mockContextValue } from '../mocks/mock_context'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock('../../../resolver/view/use_resolver_query_params_cleaner'); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +describe('', () => { + it('renders entities details correctly', () => { + const { getByTestId } = render( + + + + + + ); + expect(getByTestId(ENTITIES_DETAILS_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(USER_DETAILS_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(HOST_DETAILS_TEST_ID)).toBeInTheDocument(); + }); + + it('does not render user and host details if user name and host name are not available', () => { + const { queryByTestId } = render( + + + fieldName === '@timestamp' ? ['2022-07-25T08:20:18.966Z'] : [], + }} + > + + + + ); + expect(queryByTestId(USER_DETAILS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_TEST_ID)).not.toBeInTheDocument(); + }); + + it('does not render user and host details if @timestamp is not available', () => { + const { queryByTestId } = render( + + { + switch (fieldName) { + case 'host.name': + return ['host1']; + case 'user.name': + return ['user1']; + default: + return []; + } + }, + }} + > + + + + ); + expect(queryByTestId(USER_DETAILS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_TEST_ID)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx index 2109eb145a9a8..40a3f1823f4d3 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx @@ -6,7 +6,11 @@ */ import React from 'react'; -import { EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useLeftPanelContext } from '../context'; +import { getField } from '../../shared/utils'; +import { UserDetails } from './user_details'; +import { HostDetails } from './host_details'; import { ENTITIES_DETAILS_TEST_ID } from './test_ids'; export const ENTITIES_TAB_ID = 'entities-details'; @@ -15,7 +19,25 @@ export const ENTITIES_TAB_ID = 'entities-details'; * Entities displayed in the document details expandable flyout left section under the Insights tab */ export const EntitiesDetails: React.FC = () => { - return {'Entities'}; + const { getFieldsData } = useLeftPanelContext(); + const hostName = getField(getFieldsData('host.name')); + const userName = getField(getFieldsData('user.name')); + const timestamp = getField(getFieldsData('@timestamp')); + + return ( + + {userName && timestamp && ( + + + + )} + {hostName && timestamp && ( + + + + )} + + ); }; EntitiesDetails.displayName = 'EntitiesDetails'; diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx new file mode 100644 index 0000000000000..f15c081c70d27 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import type { Anomalies } from '../../../common/components/ml/types'; +import { TestProviders } from '../../../common/mock'; +import { HostDetails } from './host_details'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { useRiskScore } from '../../../explore/containers/risk_score'; +import { mockAnomalies } from '../../../common/components/ml/mock'; +import { useHostDetails } from '../../../explore/hosts/containers/hosts/details'; +import { useHostRelatedUsers } from '../../../common/containers/related_entities/related_users'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { + HOST_DETAILS_TEST_ID, + HOST_DETAILS_INFO_TEST_ID, + HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID, +} from './test_ids'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const from = '2022-07-28T08:20:18.966Z'; +const to = '2022-07-28T08:20:18.966Z'; +jest.mock('../../../common/containers/use_global_time', () => { + const actual = jest.requireActual('../../../common/containers/use_global_time'); + return { + ...actual, + useGlobalTime: jest + .fn() + .mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() }), + }; +}); + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('uuid'), +})); + +jest.mock('../../../common/components/ml/hooks/use_ml_capabilities'); +const mockUseMlUserPermissions = useMlCapabilities as jest.Mock; + +jest.mock('../../../common/containers/sourcerer', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ selectedPatterns: ['index'] }), +})); + +jest.mock('../../../common/components/ml/anomaly/anomaly_table_provider', () => ({ + AnomalyTableProvider: ({ + children, + }: { + children: (args: { + anomaliesData: Anomalies; + isLoadingAnomaliesData: boolean; + jobNameById: Record; + }) => React.ReactNode; + }) => children({ anomaliesData: mockAnomalies, isLoadingAnomaliesData: false, jobNameById: {} }), +})); + +jest.mock('../../../explore/hosts/containers/hosts/details'); +const mockUseHostDetails = useHostDetails as jest.Mock; + +jest.mock('../../../common/containers/related_entities/related_users'); +const mockUseHostsRelatedUsers = useHostRelatedUsers as jest.Mock; + +jest.mock('../../../explore/containers/risk_score'); +const mockUseRiskScore = useRiskScore as jest.Mock; + +const timestamp = '2022-07-25T08:20:18.966Z'; + +const defaultProps = { + hostName: 'test host', + timestamp, +}; + +const mockHostDetailsResponse = [ + false, + { + inspect: jest.fn(), + refetch: jest.fn(), + hostDetails: { host: { name: ['test host'] } }, + }, +]; + +const mockRiskScoreResponse = { + data: [ + { + host: { + name: 'test host', + risk: { calculated_level: 'low', calculated_score_norm: 38 }, + }, + }, + ], + isLicenseValid: true, +}; + +const mockRelatedUsersResponse = { + inspect: jest.fn(), + refetch: jest.fn(), + relatedUsers: [{ user: 'test user', ip: ['100.XXX.XXX'], risk: RiskSeverity.low }], + loading: false, +}; +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMlUserPermissions.mockReturnValue({ isPlatinumOrTrialLicense: false, capabilities: {} }); + mockUseHostDetails.mockReturnValue(mockHostDetailsResponse); + mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); + mockUseHostsRelatedUsers.mockReturnValue(mockRelatedUsersResponse); + }); + + it('should render host details correctly', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(HOST_DETAILS_TEST_ID)).toBeInTheDocument(); + }); + + describe('Host overview', () => { + it('should render the HostOverview with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseHostDetails).toBeCalledWith({ + id: 'entities-hosts-details-uuid', + startDate: from, + endDate: to, + hostName: 'test host', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(HOST_DETAILS_INFO_TEST_ID)).toBeInTheDocument(); + }); + + it('should render host risk score when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { getByText } = render( + + + + ); + expect(getByText('Host risk score')).toBeInTheDocument(); + }); + + it('should not render host risk score when license is not valid', () => { + mockUseRiskScore.mockReturnValue({ data: [], isLicenseValid: false }); + const { queryByText } = render( + + + + ); + expect(queryByText('Host risk score')).not.toBeInTheDocument(); + }); + }); + + describe('Related users', () => { + it('should render the related user table with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseHostsRelatedUsers).toBeCalledWith({ + from: timestamp, + hostName: 'test host', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID)).toBeInTheDocument(); + }); + + it('should render user risk score column when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(3); + expect(queryAllByRole('row')[1].textContent).toContain('test user'); + expect(queryAllByRole('row')[1].textContent).toContain('100.XXX.XXX'); + expect(queryAllByRole('row')[1].textContent).toContain('Low'); + }); + + it('should not render host risk score column when license is not valid', () => { + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(2); + }); + + it('should render empty table if no related user is returned', () => { + mockUseHostsRelatedUsers.mockReturnValue({ + ...mockRelatedUsersResponse, + relatedUsers: [], + loading: false, + }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID).textContent).toContain( + 'No items found' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx new file mode 100644 index 0000000000000..2cd503caf5583 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx @@ -0,0 +1,289 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { v4 as uuid } from 'uuid'; +import { + EuiTitle, + EuiSpacer, + EuiInMemoryTable, + EuiHorizontalRule, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { RelatedUser } from '../../../../common/search_strategy/security_solution/related_entities/related_users'; +import type { RiskSeverity } from '../../../../common/search_strategy'; +import { EntityPanel } from '../../right/components/entity_panel'; +import { HostOverview } from '../../../overview/components/host_overview'; +import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { NetworkDetailsLink } from '../../../common/components/links'; +import { RiskScoreEntity } from '../../../../common/search_strategy'; +import { RiskScore } from '../../../explore/components/risk_score/severity/common'; +import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/field_renderers'; +import { InputsModelId } from '../../../common/store/inputs/constants'; +import { + SecurityCellActions, + CellActionsMode, + SecurityCellActionsTrigger, +} from '../../../common/components/cell_actions'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria'; +import { useHostDetails } from '../../../explore/hosts/containers/hosts/details'; +import { useHostRelatedUsers } from '../../../common/containers/related_entities/related_users'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { HOST_DETAILS_TEST_ID, HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID } from './test_ids'; +import { ENTITY_RISK_CLASSIFICATION } from '../../../explore/components/risk_score/translations'; +import { USER_RISK_TOOLTIP } from '../../../explore/users/components/all_users/translations'; +import * as i18n from './translations'; + +const HOST_DETAILS_ID = 'entities-hosts-details'; +const RELATED_USERS_ID = 'entities-hosts-related-users'; + +const HostOverviewManage = manageQuery(HostOverview); +const RelatedUsersManage = manageQuery(InspectButtonContainer); + +export interface HostDetailsProps { + /** + * Host name for the entities details + */ + hostName: string; + /** + * timestamp of alert or event + */ + timestamp: string; +} +/** + * Host details and related users, displayed in the document details expandable flyout left section under the Insights tab, Entities tab + */ +export const HostDetails: React.FC = ({ hostName, timestamp }) => { + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { selectedPatterns } = useSourcererDataView(); + const dispatch = useDispatch(); + // create a unique, but stable (across re-renders) query id + const hostDetailsQueryId = useMemo(() => `${HOST_DETAILS_ID}-${uuid()}`, []); + const relatedUsersQueryId = useMemo(() => `${RELATED_USERS_ID}-${uuid()}`, []); + const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; + const narrowDateRange = useCallback( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: InputsModelId.global, + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + + const [isHostLoading, { inspect, hostDetails, refetch }] = useHostDetails({ + id: hostDetailsQueryId, + startDate: from, + endDate: to, + hostName, + indexNames: selectedPatterns, + skip: selectedPatterns.length === 0, + }); + + const { + loading: isRelatedUsersLoading, + inspect: inspectRelatedUsers, + relatedUsers, + totalCount, + refetch: refetchRelatedUsers, + } = useHostRelatedUsers({ + hostName, + indexNames: selectedPatterns, + from: timestamp, // related users are users who were successfully authenticated onto this host AFTER alert time + skip: selectedPatterns.length === 0, + }); + + const relatedUsersColumns: Array> = useMemo( + () => [ + { + field: 'user', + name: i18n.RELATED_ENTITIES_NAME_COLUMN_TITLE, + render: (user: string) => ( + + + {user} + + + ), + }, + { + field: 'ip', + name: i18n.RELATED_ENTITIES_IP_COLUMN_TITLE, + render: (ips: string[]) => { + return ( + (ip != null ? : getEmptyTagValue())} + /> + ); + }, + }, + ...(isPlatinumOrTrialLicense + ? [ + { + field: 'risk', + name: ( + + <> + {ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.user)}{' '} + + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (riskScore: RiskSeverity) => { + if (riskScore != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + ] + : []), + ], + [isPlatinumOrTrialLicense] + ); + + const relatedUsersCount = useMemo( + () => ( + + + + + + + {`${i18n.RELATED_USERS_TITLE}: ${totalCount}`} + + + + ), + [totalCount] + ); + + const pagination: {} = { + pageSize: 4, + showPerPageOptions: false, + }; + + return ( + <> + +

{i18n.HOSTS_TITLE}

+
+ + + +
{i18n.HOSTS_INFO_TITLE}
+
+ + + {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( + + )} + + + + + +
{i18n.RELATED_USERS_TITLE}
+
+
+ + + + + +
+ + + + + +
+ + ); +}; + +HostDetails.displayName = 'HostDetails'; diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts index 61c0488bee2ee..7c9830b4602ce 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts @@ -5,13 +5,27 @@ * 2.0. */ +/* Visualization tab */ const PREFIX = 'securitySolutionDocumentDetailsFlyout' as const; export const ANALYZER_GRAPH_TEST_ID = `${PREFIX}AnalyzerGraph` as const; -export const ANALYZE_GRAPH_ERROR_TEST_ID = `${PREFIX}AnalyzerGraphError`; +export const ANALYZE_GRAPH_ERROR_TEST_ID = `${PREFIX}AnalyzerGraphError` as const; export const SESSION_VIEW_TEST_ID = `${PREFIX}SessionView` as const; export const SESSION_VIEW_ERROR_TEST_ID = `${PREFIX}SessionViewError` as const; + +/* Insights tab */ + +/* Entities */ export const ENTITIES_DETAILS_TEST_ID = `${PREFIX}EntitiesDetails` as const; +export const USER_DETAILS_TEST_ID = `${PREFIX}UsersDetails` as const; +export const USER_DETAILS_INFO_TEST_ID = 'user-overview'; +export const USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID = + `${PREFIX}UsersDetailsRelatedHostsTable` as const; +export const HOST_DETAILS_TEST_ID = `${PREFIX}HostsDetails` as const; +export const HOST_DETAILS_INFO_TEST_ID = 'host-overview'; +export const HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID = + `${PREFIX}HostsDetailsRelatedUsersTable` as const; + export const THREAT_INTELLIGENCE_DETAILS_TEST_ID = `${PREFIX}ThreatIntelligenceDetails` as const; export const PREVALENCE_DETAILS_TEST_ID = `${PREFIX}PrevalenceDetails` as const; export const CORRELATIONS_DETAILS_TEST_ID = `${PREFIX}CorrelationsDetails` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts b/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts index f82d34c859ddf..5d55aa81a0ef7 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts +++ b/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts @@ -8,15 +8,79 @@ import { i18n } from '@kbn/i18n'; export const ANALYZER_ERROR_MESSAGE = i18n.translate( - 'xpack.securitySolution.flyout.analyzerErrorTitle', + 'xpack.securitySolution.flyout.analyzerErrorMessage', { defaultMessage: 'analyzer', } ); export const SESSION_VIEW_ERROR_MESSAGE = i18n.translate( - 'xpack.securitySolution.flyout.sessionViewErrorTitle', + 'xpack.securitySolution.flyout.sessionViewErrorMessage', { defaultMessage: 'session view', } ); + +export const USERS_TITLE = i18n.translate('xpack.securitySolution.flyout.entities.usersTitle', { + defaultMessage: 'Users', +}); + +export const USERS_INFO_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.usersInfoTitle', + { + defaultMessage: 'User info', + } +); + +export const RELATED_HOSTS_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedHostsTitle', + { + defaultMessage: 'Related hosts', + } +); + +export const RELATED_HOSTS_TOOL_TIP = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedHostsToolTip', + { + defaultMessage: 'The user successfully authenticated to these hosts after the alert.', + } +); + +export const RELATED_ENTITIES_NAME_COLUMN_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedEntitiesNameColumn', + { + defaultMessage: 'Name', + } +); + +export const RELATED_ENTITIES_IP_COLUMN_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedEntitiesIpColumn', + { + defaultMessage: 'Ip addresses', + } +); + +export const HOSTS_TITLE = i18n.translate('xpack.securitySolution.flyout.entities.hostsTitle', { + defaultMessage: 'Hosts', +}); + +export const HOSTS_INFO_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.hostsInfoTitle', + { + defaultMessage: 'Host info', + } +); + +export const RELATED_USERS_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedUsersTitle', + { + defaultMessage: 'Related users', + } +); + +export const RELATED_USERS_TOOL_TIP = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedUsersToolTip', + { + defaultMessage: 'These users successfully authenticated to the affected host after the alert.', + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx new file mode 100644 index 0000000000000..e5bc253829373 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import type { Anomalies } from '../../../common/components/ml/types'; +import { TestProviders } from '../../../common/mock'; +import { UserDetails } from './user_details'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { useRiskScore } from '../../../explore/containers/risk_score'; +import { mockAnomalies } from '../../../common/components/ml/mock'; +import { useObservedUserDetails } from '../../../explore/users/containers/users/observed_details'; +import { useUserRelatedHosts } from '../../../common/containers/related_entities/related_hosts'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { + USER_DETAILS_TEST_ID, + USER_DETAILS_INFO_TEST_ID, + USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID, +} from './test_ids'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const from = '2022-07-20T08:20:18.966Z'; +const to = '2022-07-28T08:20:18.966Z'; +jest.mock('../../../common/containers/use_global_time', () => { + const actual = jest.requireActual('../../../common/containers/use_global_time'); + return { + ...actual, + useGlobalTime: jest + .fn() + .mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() }), + }; +}); + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('uuid'), +})); + +jest.mock('../../../common/components/ml/hooks/use_ml_capabilities'); +const mockUseMlUserPermissions = useMlCapabilities as jest.Mock; + +jest.mock('../../../common/containers/sourcerer', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ selectedPatterns: ['index'] }), +})); + +jest.mock('../../../common/components/ml/anomaly/anomaly_table_provider', () => ({ + AnomalyTableProvider: ({ + children, + }: { + children: (args: { + anomaliesData: Anomalies; + isLoadingAnomaliesData: boolean; + jobNameById: Record; + }) => React.ReactNode; + }) => children({ anomaliesData: mockAnomalies, isLoadingAnomaliesData: false, jobNameById: {} }), +})); + +jest.mock('../../../explore/users/containers/users/observed_details'); +const mockUseObservedUserDetails = useObservedUserDetails as jest.Mock; + +jest.mock('../../../common/containers/related_entities/related_hosts'); +const mockUseUsersRelatedHosts = useUserRelatedHosts as jest.Mock; + +jest.mock('../../../explore/containers/risk_score'); +const mockUseRiskScore = useRiskScore as jest.Mock; + +const timestamp = '2022-07-25T08:20:18.966Z'; + +const defaultProps = { + userName: 'test user', + timestamp, +}; + +const mockUserDetailsResponse = [ + false, + { + inspect: jest.fn(), + refetch: jest.fn(), + userDetails: { user: { name: ['test user'] } }, + }, +]; + +const mockRiskScoreResponse = { + data: [ + { + user: { + name: 'test user', + risk: { calculated_level: 'low', calculated_score_norm: 40 }, + }, + }, + ], + isLicenseValid: true, +}; + +const mockRelatedHostsResponse = { + inspect: jest.fn(), + refetch: jest.fn(), + relatedHosts: [{ host: 'test host', ip: ['100.XXX.XXX'], risk: RiskSeverity.low }], + loading: false, +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMlUserPermissions.mockReturnValue({ isPlatinumOrTrialLicense: false, capabilities: {} }); + mockUseObservedUserDetails.mockReturnValue(mockUserDetailsResponse); + mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); + mockUseUsersRelatedHosts.mockReturnValue(mockRelatedHostsResponse); + }); + + it('should render host details correctly', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(USER_DETAILS_TEST_ID)).toBeInTheDocument(); + }); + + describe('Host overview', () => { + it('should render the HostOverview with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseObservedUserDetails).toBeCalledWith({ + id: 'entities-users-details-uuid', + startDate: from, + endDate: to, + userName: 'test user', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(USER_DETAILS_INFO_TEST_ID)).toBeInTheDocument(); + }); + + it('should render user risk score when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { getByText } = render( + + + + ); + expect(getByText('User risk score')).toBeInTheDocument(); + }); + + it('should not render user risk score when license is not valid', () => { + mockUseRiskScore.mockReturnValue({ data: [], isLicenseValid: false }); + const { queryByText } = render( + + + + ); + expect(queryByText('User risk score')).not.toBeInTheDocument(); + }); + }); + + describe('Related hosts', () => { + it('should render the related host table with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseUsersRelatedHosts).toBeCalledWith({ + from: timestamp, + userName: 'test user', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID)).toBeInTheDocument(); + }); + + it('should render host risk score column when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(3); + expect(queryAllByRole('row')[1].textContent).toContain('test host'); + expect(queryAllByRole('row')[1].textContent).toContain('100.XXX.XXX'); + expect(queryAllByRole('row')[1].textContent).toContain('Low'); + }); + + it('should not render host risk score column when license is not valid', () => { + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(2); + }); + + it('should render empty table if no related host is returned', () => { + mockUseUsersRelatedHosts.mockReturnValue({ + ...mockRelatedHostsResponse, + relatedHosts: [], + loading: false, + }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID).textContent).toContain( + 'No items found' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx new file mode 100644 index 0000000000000..0a77ddad023e3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx @@ -0,0 +1,288 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { v4 as uuid } from 'uuid'; +import { + EuiTitle, + EuiSpacer, + EuiInMemoryTable, + EuiHorizontalRule, + EuiText, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, +} from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { RelatedHost } from '../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import type { RiskSeverity } from '../../../../common/search_strategy'; +import { EntityPanel } from '../../right/components/entity_panel'; +import { UserOverview } from '../../../overview/components/user_overview'; +import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { NetworkDetailsLink } from '../../../common/components/links'; +import { RiskScoreEntity } from '../../../../common/search_strategy'; +import { RiskScore } from '../../../explore/components/risk_score/severity/common'; +import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/field_renderers'; +import { + SecurityCellActions, + CellActionsMode, + SecurityCellActionsTrigger, +} from '../../../common/components/cell_actions'; +import { InputsModelId } from '../../../common/store/inputs/constants'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { useObservedUserDetails } from '../../../explore/users/containers/users/observed_details'; +import { useUserRelatedHosts } from '../../../common/containers/related_entities/related_hosts'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID, USER_DETAILS_TEST_ID } from './test_ids'; +import { ENTITY_RISK_CLASSIFICATION } from '../../../explore/components/risk_score/translations'; +import { HOST_RISK_TOOLTIP } from '../../../explore/hosts/components/hosts_table/translations'; +import * as i18n from './translations'; + +const USER_DETAILS_ID = 'entities-users-details'; +const RELATED_HOSTS_ID = 'entities-users-related-hosts'; + +const UserOverviewManage = manageQuery(UserOverview); +const RelatedHostsManage = manageQuery(InspectButtonContainer); + +export interface UserDetailsProps { + /** + * User name for the entities details + */ + userName: string; + /** + * timestamp of alert or event + */ + timestamp: string; +} +/** + * User details and related users, displayed in the document details expandable flyout left section under the Insights tab, Entities tab + */ +export const UserDetails: React.FC = ({ userName, timestamp }) => { + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { selectedPatterns } = useSourcererDataView(); + const dispatch = useDispatch(); + // create a unique, but stable (across re-renders) query id + const userDetailsQueryId = useMemo(() => `${USER_DETAILS_ID}-${uuid()}`, []); + const relatedHostsQueryId = useMemo(() => `${RELATED_HOSTS_ID}-${uuid()}`, []); + const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; + const narrowDateRange = useCallback( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: InputsModelId.global, + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + + const [isUserLoading, { inspect, userDetails, refetch }] = useObservedUserDetails({ + id: userDetailsQueryId, + startDate: from, + endDate: to, + userName, + indexNames: selectedPatterns, + skip: selectedPatterns.length === 0, + }); + + const { + loading: isRelatedHostLoading, + inspect: inspectRelatedHosts, + relatedHosts, + totalCount, + refetch: refetchRelatedHosts, + } = useUserRelatedHosts({ + userName, + indexNames: selectedPatterns, + from: timestamp, // related hosts are hosts this user has successfully authenticated onto AFTER alert time + skip: selectedPatterns.length === 0, + }); + + const relatedHostsColumns: Array> = useMemo( + () => [ + { + field: 'host', + name: i18n.RELATED_ENTITIES_NAME_COLUMN_TITLE, + render: (host: string) => ( + + + {host} + + + ), + }, + { + field: 'ip', + name: i18n.RELATED_ENTITIES_IP_COLUMN_TITLE, + render: (ips: string[]) => { + return ( + (ip != null ? : getEmptyTagValue())} + /> + ); + }, + }, + ...(isPlatinumOrTrialLicense + ? [ + { + field: 'risk', + name: ( + + <> + {ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.host)}{' '} + + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (riskScore: RiskSeverity) => { + if (riskScore != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + ] + : []), + ], + [isPlatinumOrTrialLicense] + ); + + const relatedHostsCount = useMemo( + () => ( + + + + + + + {`${i18n.RELATED_HOSTS_TITLE}: ${totalCount}`} + + + + ), + [totalCount] + ); + + const pagination: {} = { + pageSize: 4, + showPerPageOptions: false, + }; + + return ( + <> + +

{i18n.USERS_TITLE}

+
+ + + +
{i18n.USERS_INFO_TITLE}
+
+ + + {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( + + )} + + + + + +
{i18n.RELATED_HOSTS_TITLE}
+
+
+ + + + + +
+ + + + + +
+ + ); +}; + +UserDetails.displayName = 'UserDetails'; diff --git a/x-pack/plugins/security_solution/public/flyout/left/context.tsx b/x-pack/plugins/security_solution/public/flyout/left/context.tsx index 64662c17305ca..d89955ea565a6 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/context.tsx @@ -6,18 +6,18 @@ */ import React, { createContext, useContext, useMemo } from 'react'; +import { css } from '@emotion/react'; import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { css } from '@emotion/react'; +import type { LeftPanelProps } from '.'; +import { useGetFieldsData } from '../../common/hooks/use_get_fields_data'; +import { useTimelineEventsDetails } from '../../timelines/containers/details'; +import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers'; +import { useSpaceId } from '../../common/hooks/use_space_id'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; import { SecurityPageName } from '../../../common/constants'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useSourcererDataView } from '../../common/containers/sourcerer'; -import { useTimelineEventsDetails } from '../../timelines/containers/details'; -import { useGetFieldsData } from '../../common/hooks/use_get_fields_data'; -import { useRouteSpy } from '../../common/utils/route/use_route_spy'; -import { useSpaceId } from '../../common/hooks/use_space_id'; -import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers'; -import type { LeftPanelProps } from '.'; export interface LeftPanelContext { /** diff --git a/x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts b/x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts new file mode 100644 index 0000000000000..754b7c9c8ade2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts @@ -0,0 +1,41 @@ +/* + * 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 { ALERT_RISK_SCORE, ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import type { LeftPanelContext } from '../context'; + +/** + * Returns mocked data for field (mock this method: x-pack/plugins/security_solution/public/common/hooks/use_get_fields_data.ts) + * @param field + * @returns string[] + */ +export const mockGetFieldsData = (field: string): string[] => { + switch (field) { + case ALERT_SEVERITY: + return ['low']; + case ALERT_RISK_SCORE: + return ['0']; + case 'host.name': + return ['host1']; + case 'user.name': + return ['user1']; + case '@timestamp': + return ['2022-07-25T08:20:18.966Z']; + default: + return []; + } +}; + +/** + * Mock contextValue for left panel context + */ +export const mockContextValue: LeftPanelContext = { + eventId: 'eventId', + indexName: 'index', + getFieldsData: mockGetFieldsData, + dataFormattedForFieldBrowser: null, +}; diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx index fc98b138879b7..d059b5180abab 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx @@ -10,7 +10,8 @@ import { render } from '@testing-library/react'; import { RightPanelContext } from '../context'; import { ENTITIES_HEADER_TEST_ID, - ENTITY_PANEL_TEST_ID, + ENTITIES_USER_CONTENT_TEST_ID, + ENTITIES_HOST_CONTENT_TEST_ID, ENTITIES_HOST_OVERVIEW_TEST_ID, ENTITIES_USER_OVERVIEW_TEST_ID, } from './test_ids'; @@ -25,7 +26,7 @@ describe('', () => { getFieldsData: mockGetFieldsData, } as unknown as RightPanelContext; - const { getByTestId, queryByText, getAllByTestId } = render( + const { getByTestId } = render( @@ -33,11 +34,8 @@ describe('', () => { ); expect(getByTestId(ENTITIES_HEADER_TEST_ID)).toHaveTextContent('Entities'); - expect(getAllByTestId(ENTITY_PANEL_TEST_ID)).toHaveLength(2); - expect(queryByText('user1')).toBeInTheDocument(); - expect(getByTestId(ENTITIES_USER_OVERVIEW_TEST_ID)).toBeInTheDocument(); - expect(queryByText('host1')).toBeInTheDocument(); - expect(getByTestId(ENTITIES_HOST_OVERVIEW_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ENTITIES_USER_CONTENT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).toBeInTheDocument(); }); it('should only render user when host name is null', () => { @@ -46,7 +44,7 @@ describe('', () => { getFieldsData: (field: string) => (field === 'user.name' ? 'user1' : null), } as unknown as RightPanelContext; - const { queryByTestId, queryByText, getAllByTestId } = render( + const { queryByTestId, queryByText, getByTestId } = render( @@ -54,8 +52,8 @@ describe('', () => { ); - expect(queryByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); - expect(getAllByTestId(ENTITY_PANEL_TEST_ID)).toHaveLength(1); + expect(getByTestId(ENTITIES_USER_CONTENT_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).not.toBeInTheDocument(); expect(queryByText('user1')).toBeInTheDocument(); expect(queryByTestId(ENTITIES_USER_OVERVIEW_TEST_ID)).toBeInTheDocument(); }); @@ -66,7 +64,7 @@ describe('', () => { getFieldsData: (field: string) => (field === 'host.name' ? 'host1' : null), } as unknown as RightPanelContext; - const { queryByTestId, queryByText, getAllByTestId } = render( + const { queryByTestId, queryByText, getByTestId } = render( @@ -74,8 +72,8 @@ describe('', () => { ); - expect(queryByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); - expect(getAllByTestId(ENTITY_PANEL_TEST_ID)).toHaveLength(1); + expect(getByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ENTITIES_USER_CONTENT_TEST_ID)).not.toBeInTheDocument(); expect(queryByText('host1')).toBeInTheDocument(); expect(queryByTestId(ENTITIES_HOST_OVERVIEW_TEST_ID)).toBeInTheDocument(); }); @@ -95,6 +93,8 @@ describe('', () => { ); expect(queryByTestId(ENTITIES_HEADER_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_USER_CONTENT_TEST_ID)).not.toBeInTheDocument(); }); it('should not render if eventId is null', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx index b8ebe27ddd7c4..b0a8d5c2faeb2 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx @@ -12,6 +12,8 @@ import { useRightPanelContext } from '../context'; import { ENTITIES_HEADER_TEST_ID, ENTITIES_CONTENT_TEST_ID, + ENTITIES_HOST_CONTENT_TEST_ID, + ENTITIES_USER_CONTENT_TEST_ID, ENTITIES_VIEW_ALL_BUTTON_TEST_ID, } from './test_ids'; import { ENTITIES_TITLE, ENTITIES_TEXT, VIEW_ALL } from './translations'; @@ -61,8 +63,10 @@ export const EntitiesOverview: React.FC = () => { } - /> + data-test-subj={ENTITIES_USER_CONTENT_TEST_ID} + > + + )} {hostName && ( @@ -70,8 +74,10 @@ export const EntitiesOverview: React.FC = () => { } - /> + data-test-subj={ENTITIES_HOST_CONTENT_TEST_ID} + > + + )} ; + +const children =

{'test content'}

; export const Default: Story = () => { - return ; + return {children}; +}; + +export const DefaultWithHeaderContent: Story = () => { + return ( + + {children} + + ); }; export const Expandable: Story = () => { - return ; + return ( + + {children} + + ); }; export const ExpandableDefaultOpen: Story = () => { - return ; + return ( + + {children} + + ); }; export const EmptyDefault: Story = () => { - return ; + return ; }; export const EmptyDefaultExpanded: Story = () => { - return ; + return ; }; diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx index 0861c3682d555..5eedc99cf5e61 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx @@ -9,37 +9,66 @@ import React from 'react'; import { render } from '@testing-library/react'; import { EntityPanel } from './entity_panel'; import { - ENTITY_PANEL_TEST_ID, - ENTITY_PANEL_ICON_TEST_ID, ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID, ENTITY_PANEL_HEADER_TEST_ID, + ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID, + ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID, ENTITY_PANEL_CONTENT_TEST_ID, } from './test_ids'; +import { ThemeProvider } from 'styled-components'; +import { getMockTheme } from '../../../common/lib/kibana/kibana_react.mock'; +const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); +const ENTITY_PANEL_TEST_ID = 'entityPanel'; const defaultProps = { title: 'test', iconType: 'storage', - content: 'test content', + 'data-test-subj': ENTITY_PANEL_TEST_ID, }; +const children =

{'test content'}

; describe('', () => { describe('panel is not expandable by default', () => { it('should render non-expandable panel by default', () => { - const { getByTestId, queryByTestId } = render(); - + const { getByTestId, queryByTestId } = render( + + {children} + + ); expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test'); + expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).toHaveTextContent('test content'); - expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); - expect(getByTestId(ENTITY_PANEL_ICON_TEST_ID).firstChild).toHaveAttribute( - 'data-euiicon-type', - 'storage' + }); + + it('should only render left section of panel header when headerContent is not passed', () => { + const { getByTestId, queryByTestId } = render( + + {children} + ); + expect(getByTestId(ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID)).toHaveTextContent('test'); + expect(queryByTestId(ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render header properly when headerContent is available', () => { + const { getByTestId } = render( + + {'test header content'}}> + {children} + + + ); + expect(getByTestId(ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID)).toBeInTheDocument(); }); it('should not render content when content is null', () => { - const { queryByTestId } = render(); + const { queryByTestId } = render( + + + + ); expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument(); expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); @@ -49,7 +78,11 @@ describe('', () => { describe('panel is expandable', () => { it('should render panel with toggle and collapsed by default', () => { const { getByTestId, queryByTestId } = render( - + + + {children} + + ); expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test'); @@ -57,7 +90,13 @@ describe('', () => { }); it('click toggle button should expand the panel', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + + {children} + + + ); const toggle = getByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID); expect(toggle.firstChild).toHaveAttribute('data-euiicon-type', 'arrowRight'); @@ -69,7 +108,9 @@ describe('', () => { it('should not render toggle or content when content is null', () => { const { queryByTestId } = render( - + + + ); expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument(); @@ -79,7 +120,11 @@ describe('', () => { describe('panel is expandable and expanded by default', () => { it('should render header and content', () => { const { getByTestId } = render( - + + + {children} + + ); expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test'); @@ -89,7 +134,11 @@ describe('', () => { it('click toggle button should collapse the panel', () => { const { getByTestId, queryByTestId } = render( - + + + {children} + + ); const toggle = getByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID); @@ -103,7 +152,9 @@ describe('', () => { it('should not render content when content is null', () => { const { queryByTestId } = render( - + + + ); expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx index 4321939a487c3..d095bf72e4c39 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx @@ -14,15 +14,25 @@ import { EuiFlexItem, EuiTitle, EuiPanel, + EuiIcon, } from '@elastic/eui'; +import styled from 'styled-components'; import { - ENTITY_PANEL_TEST_ID, - ENTITY_PANEL_ICON_TEST_ID, ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID, ENTITY_PANEL_HEADER_TEST_ID, + ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID, + ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID, ENTITY_PANEL_CONTENT_TEST_ID, } from './test_ids'; +const PanelHeaderRightSectionWrapper = styled(EuiFlexItem)` + margin-right: ${({ theme }) => theme.eui.euiSizeM}; +`; + +const IconWrapper = styled(EuiIcon)` + margin: ${({ theme }) => theme.eui.euiSizeS} 0; +`; + export interface EntityPanelProps { /** * String value of the title to be displayed in the header of panel @@ -32,10 +42,6 @@ export interface EntityPanelProps { * Icon string for displaying the specified icon in the header */ iconType: string; - /** - * Content to show in the content section of the panel - */ - content?: string | React.ReactNode; /** * Boolean to determine the panel to be collapsable (with toggle) */ @@ -44,6 +50,14 @@ export interface EntityPanelProps { * Boolean to allow the component to be expanded or collapsed on first render */ expanded?: boolean; + /** + Optional content and actions to be displayed on the right side of header + */ + headerContent?: React.ReactNode; + /** + Data test subject string for testing + */ + ['data-test-subj']?: string; } /** @@ -52,9 +66,11 @@ export interface EntityPanelProps { export const EntityPanel: React.FC = ({ title, iconType, - content, + children, expandable = false, expanded = false, + headerContent, + 'data-test-subj': dataTestSub, }) => { const [toggleStatus, setToggleStatus] = useState(expanded); const toggleQuery = useCallback(() => { @@ -63,67 +79,78 @@ export const EntityPanel: React.FC = ({ const toggleIcon = useMemo( () => ( - - - - ), - [toggleStatus, toggleQuery] - ); - - const icon = useMemo(() => { - return ( - ); - }, [iconType]); + ), + [toggleStatus, toggleQuery] + ); + + const headerLeftSection = useMemo( + () => ( + + + {expandable && children && toggleIcon} + + + + + + {title} + + + + + ), + [title, children, toggleIcon, expandable, iconType] + ); + + const headerRightSection = useMemo( + () => + headerContent && ( + + {headerContent} + + ), + [headerContent] + ); const showContent = useMemo(() => { - if (!content) { + if (!children) { return false; } return !expandable || (expandable && toggleStatus); - }, [content, expandable, toggleStatus]); + }, [children, expandable, toggleStatus]); - const panelHeader = useMemo(() => { - return ( - + - {expandable && content && toggleIcon} - {icon} - - - {title} - - - - ); - }, [title, icon, content, toggleIcon, expandable]); - - return ( - - - {panelHeader} + + {headerLeftSection} + {headerRightSection} + {showContent && ( - {content} + {children} )} diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts index 14fd95b17217e..3829db52d6280 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts @@ -61,14 +61,20 @@ export const INSIGHTS_TEST_ID = 'securitySolutionDocumentDetailsFlyoutInsights'; export const INSIGHTS_HEADER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutInsightsHeader'; export const ENTITIES_HEADER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntitiesHeader'; export const ENTITIES_CONTENT_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntitiesContent'; +export const ENTITIES_USER_CONTENT_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntitiesUserContent'; +export const ENTITIES_HOST_CONTENT_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntitiesHostContent'; export const ENTITIES_VIEW_ALL_BUTTON_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntitiesViewAllButton'; -export const ENTITY_PANEL_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanel'; export const ENTITY_PANEL_ICON_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelTypeIcon'; export const ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelToggleButton'; -export const ENTITY_PANEL_HEADER_TEST_ID = - 'securitySolutionDocumentDetailsFlyoutEntityPanelHeaderTitle'; +export const ENTITY_PANEL_HEADER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelHeader'; +export const ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntityPanelHeaderLeftSection'; +export const ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntityPanelHeaderRightSection'; export const ENTITY_PANEL_CONTENT_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelContent'; export const TECHNICAL_PREVIEW_ICON_TEST_ID = diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts index 86a3cfae8b4f3..6c1c661cbfa54 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts @@ -110,7 +110,7 @@ async function enhanceEdges( : edges; } -async function getHostRiskData( +export async function getHostRiskData( esClient: IScopedClusterClient, spaceId: string, hostNames: string[] diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts index 3ef80e3fa909c..a24c22aa8dfe0 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts @@ -15,6 +15,7 @@ import { ctiFactoryTypes } from './cti'; import { riskScoreFactory } from './risk_score'; import { usersFactory } from './users'; import { firstLastSeenFactory } from './last_first_seen'; +import { relatedEntitiesFactory } from './related_entities'; import { responseActionsFactory } from './response_actions'; export const securitySolutionFactory: Record< @@ -28,5 +29,6 @@ export const securitySolutionFactory: Record< ...ctiFactoryTypes, ...riskScoreFactory, ...firstLastSeenFactory, + ...relatedEntitiesFactory, ...responseActionsFactory, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts new file mode 100644 index 0000000000000..2d948436afb20 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FactoryQueryTypes } from '../../../../../common/search_strategy/security_solution'; +import { RelatedEntitiesQueries } from '../../../../../common/search_strategy/security_solution/related_entities'; + +import type { SecuritySolutionFactory } from '../types'; +import { hostsRelatedUsers } from './related_users'; +import { usersRelatedHosts } from './related_hosts'; + +export const relatedEntitiesFactory: Record< + RelatedEntitiesQueries, + SecuritySolutionFactory +> = { + [RelatedEntitiesQueries.relatedHosts]: usersRelatedHosts, + [RelatedEntitiesQueries.relatedUsers]: hostsRelatedUsers, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts new file mode 100644 index 0000000000000..aad8e3a6f1cdc --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { EndpointAppContextService } from '../../../../../../endpoint/endpoint_app_context_services'; +import type { EndpointAppContext } from '../../../../../../endpoint/types'; +import type { UsersRelatedHostsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import { RelatedEntitiesQueries } from '../../../../../../../common/search_strategy/security_solution/related_entities'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { allowedExperimentalValues } from '../../../../../../../common/experimental_features'; +import { createMockConfig } from '../../../../../../lib/detection_engine/routes/__mocks__'; + +export const mockOptions: UsersRelatedHostsRequestOptions = { + defaultIndex: ['test_indices*'], + factoryQueryType: RelatedEntitiesQueries.relatedHosts, + userName: 'user1', + from: '2020-09-02T15:17:13.678Z', +}; + +export const mockSearchStrategyResponse: IEsSearchResponse = { + rawResponse: { + took: 2, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 1, + failed: 0, + }, + hits: { + max_score: null, + hits: [], + }, + aggregations: { + host_count: { + value: 2, + }, + host_data: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-2qia8v8mzl', + doc_count: 6, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 6, + }, + { + key: '10.185.185.41', + doc_count: 6, + }, + { + key: '10.198.197.106', + doc_count: 6, + }, + ], + }, + }, + { + key: 'Host-ly6nig20ty', + doc_count: 6, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 6, + }, + { + key: '10.185.185.41', + doc_count: 6, + }, + { + key: '10.198.197.106', + doc_count: 6, + }, + ], + }, + }, + ], + }, + }, + }, +}; + +export const mockDeps = () => ({ + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: {} as SavedObjectsClientContract, + endpointContext: { + logFactory: { + get: jest.fn(), + }, + config: jest.fn().mockResolvedValue({}), + experimentalFeatures: { + ...allowedExperimentalValues, + }, + service: {} as EndpointAppContextService, + serverConfig: createMockConfig(), + } as EndpointAppContext, + request: {} as KibanaRequest, + spaceId: 'test-space', +}); + +export const expectedDsl = { + allow_no_indices: true, + track_total_hits: false, + body: { + aggregations: { + host_count: { cardinality: { field: 'host.name' } }, + host_data: { + terms: { field: 'host.name', size: 1000 }, + aggs: { + ip: { terms: { field: 'host.ip', size: 10 } }, + }, + }, + }, + query: { + bool: { + filter: [ + { term: { 'user.name': 'user1' } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gt: '2020-09-02T15:17:13.678Z', + }, + }, + }, + ], + }, + }, + size: 0, + }, + ignore_unavailable: true, + index: ['test_indices*'], +}; + +export const mockRelatedHosts = [ + { host: 'Host-2qia8v8mzl', ip: ['10.7.58.35', '10.185.185.41', '10.198.197.106'] }, + { + host: 'Host-ly6nig20ty', + ip: ['10.7.58.35', '10.185.185.41', '10.198.197.106'], + }, +]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts new file mode 100644 index 0000000000000..9e59bea5b8be0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { usersRelatedHosts } from '.'; +import { mockDeps, mockOptions, mockSearchStrategyResponse, mockRelatedHosts } from './__mocks__'; +import { get } from 'lodash/fp'; +import * as buildQuery from './query.related_hosts.dsl'; + +describe('usersRelatedHosts search strategy', () => { + const buildRelatedHostsQuery = jest.spyOn(buildQuery, 'buildRelatedHostsQuery'); + + afterEach(() => { + buildRelatedHostsQuery.mockClear(); + }); + + describe('buildDsl', () => { + test('should build dsl query', () => { + usersRelatedHosts.buildDsl(mockOptions); + expect(buildRelatedHostsQuery).toHaveBeenCalledWith(mockOptions); + }); + }); + + describe('parse', () => { + test('should parse data correctly', async () => { + const result = await usersRelatedHosts.parse(mockOptions, mockSearchStrategyResponse); + expect(result.relatedHosts).toMatchObject(mockRelatedHosts); + expect(result.totalCount).toBe(2); + }); + + test('should enhance data with risk score', async () => { + const risk = 'TEST_RISK_SCORE'; + const hostName: string = get( + `aggregations.host_data.buckets[0].key`, + mockSearchStrategyResponse.rawResponse + ); + + const mockedDeps = mockDeps(); + + mockedDeps.esClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [ + { + _id: 'id', + _index: 'index', + _source: { + risk, + host: { + name: hostName, + risk: { + multipliers: [], + calculated_score_norm: 9999, + calculated_level: risk, + rule_risks: [], + }, + }, + }, + }, + ], + }, + took: 2, + _shards: { failed: 0, successful: 2, total: 2 }, + timed_out: false, + }); + + const result = await usersRelatedHosts.parse( + mockOptions, + mockSearchStrategyResponse, + mockedDeps + ); + + expect(result.relatedHosts[0].risk).toBe(risk); + }); + + test("should not enhance data when space id doesn't exist", async () => { + const mockedDeps = mockDeps(); + const result = await usersRelatedHosts.parse(mockOptions, mockSearchStrategyResponse, { + ...mockedDeps, + spaceId: undefined, + }); + + expect(result.relatedHosts[0].risk).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts new file mode 100644 index 0000000000000..941faa675482f --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { IScopedClusterClient } from '@kbn/core/server'; +import { getOr } from 'lodash/fp'; +import type { RiskSeverity } from '../../../../../../common/search_strategy/security_solution/risk_score/all'; +import type { SecuritySolutionFactory } from '../../types'; +import type { EndpointAppContext } from '../../../../../endpoint/types'; +import type { RelatedEntitiesQueries } from '../../../../../../common/search_strategy/security_solution/related_entities'; +import type { + UsersRelatedHostsRequestOptions, + UsersRelatedHostsStrategyResponse, + RelatedHostBucket, + RelatedHost, +} from '../../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import { buildRelatedHostsQuery } from './query.related_hosts.dsl'; +import { getHostRiskData } from '../../hosts/all'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const usersRelatedHosts: SecuritySolutionFactory = { + buildDsl: (options: UsersRelatedHostsRequestOptions) => buildRelatedHostsQuery(options), + parse: async ( + options: UsersRelatedHostsRequestOptions, + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + spaceId?: string; + endpointContext: EndpointAppContext; + } + ): Promise => { + const aggregations = response.rawResponse.aggregations; + + const inspect = { + dsl: [inspectStringifyObject(buildRelatedHostsQuery(options))], + }; + + if (aggregations == null) { + return { ...response, inspect, totalCount: 0, relatedHosts: [] }; + } + + const totalCount = getOr(0, 'aggregations.host_count.value', response.rawResponse); + + const buckets: RelatedHostBucket[] = getOr( + [], + 'aggregations.host_data.buckets', + response.rawResponse + ); + const relatedHosts: RelatedHost[] = buckets.map( + (bucket: RelatedHostBucket) => ({ + host: bucket.key, + ip: bucket.ip?.buckets.map((ip) => ip.key) ?? [], + }), + {} + ); + const enhancedHosts = deps?.spaceId + ? await addHostRiskData(relatedHosts, deps.spaceId, deps.esClient) + : relatedHosts; + + return { + ...response, + inspect, + totalCount, + relatedHosts: enhancedHosts, + }; + }, +}; + +async function addHostRiskData( + relatedHosts: RelatedHost[], + spaceId: string, + esClient: IScopedClusterClient +): Promise { + const hostNames = relatedHosts.map((item) => item.host); + const hostRiskData = await getHostRiskData(esClient, spaceId, hostNames); + const hostsRiskByHostName: Record | undefined = + hostRiskData?.hits.hits.reduce( + (acc, hit) => ({ + ...acc, + [hit._source?.host.name ?? '']: hit._source?.host?.risk?.calculated_level, + }), + {} + ); + + return hostsRiskByHostName + ? relatedHosts.map((item) => ({ ...item, risk: hostsRiskByHostName[item.host] })) + : relatedHosts; +} diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.ts new file mode 100644 index 0000000000000..088569e84d45d --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildRelatedHostsQuery } from './query.related_hosts.dsl'; +import { mockOptions, expectedDsl } from './__mocks__'; + +describe('buildRelatedHostsQuery', () => { + test('build query from options correctly', () => { + expect(buildRelatedHostsQuery(mockOptions)).toMatchObject(expectedDsl); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts new file mode 100644 index 0000000000000..cb8668c179fea --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ISearchRequestParams } from '@kbn/data-plugin/common'; +import type { UsersRelatedHostsRequestOptions } from '../../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; + +export const buildRelatedHostsQuery = ({ + userName, + defaultIndex, + from, +}: UsersRelatedHostsRequestOptions): ISearchRequestParams => { + const now = new Date(); + const filter = [ + { term: { 'user.name': userName } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + gt: from, + lte: now.toISOString(), + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const dslQuery = { + allow_no_indices: true, + index: defaultIndex, + ignore_unavailable: true, + track_total_hits: false, + body: { + aggregations: { + host_count: { cardinality: { field: 'host.name' } }, + host_data: { + terms: { + field: 'host.name', + size: 1000, + }, + aggs: { + ip: { + terms: { + field: 'host.ip', + size: 10, + }, + }, + }, + }, + }, + query: { bool: { filter } }, + size: 0, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts new file mode 100644 index 0000000000000..401df9e31cdac --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { EndpointAppContextService } from '../../../../../../endpoint/endpoint_app_context_services'; +import type { EndpointAppContext } from '../../../../../../endpoint/types'; +import type { HostsRelatedUsersRequestOptions } from '../../../../../../../common/search_strategy/security_solution/related_entities/related_users'; +import { RelatedEntitiesQueries } from '../../../../../../../common/search_strategy/security_solution/related_entities'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { allowedExperimentalValues } from '../../../../../../../common/experimental_features'; +import { createMockConfig } from '../../../../../../lib/detection_engine/routes/__mocks__'; + +export const mockOptions: HostsRelatedUsersRequestOptions = { + defaultIndex: ['test_indices*'], + factoryQueryType: RelatedEntitiesQueries.relatedUsers, + hostName: 'host1', + from: '2020-09-02T15:17:13.678Z', +}; + +export const mockSearchStrategyResponse: IEsSearchResponse = { + rawResponse: { + took: 2, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 1, + failed: 0, + }, + hits: { + max_score: null, + hits: [], + }, + aggregations: { + user_count: { + value: 2, + }, + user_data: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Danny', + doc_count: 3, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 3, + }, + { + key: '10.185.185.41', + doc_count: 3, + }, + ], + }, + }, + { + key: 'Aaron', + doc_count: 6, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 6, + }, + { + key: '10.185.185.41', + doc_count: 6, + }, + { + key: '10.198.197.106', + doc_count: 6, + }, + ], + }, + }, + ], + }, + }, + }, +}; + +export const mockDeps = () => ({ + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: {} as SavedObjectsClientContract, + endpointContext: { + logFactory: { + get: jest.fn(), + }, + config: jest.fn().mockResolvedValue({}), + experimentalFeatures: { + ...allowedExperimentalValues, + }, + service: {} as EndpointAppContextService, + serverConfig: createMockConfig(), + } as EndpointAppContext, + request: {} as KibanaRequest, + spaceId: 'test-space', +}); + +export const expectedDsl = { + allow_no_indices: true, + track_total_hits: false, + body: { + aggregations: { + user_count: { cardinality: { field: 'user.name' } }, + user_data: { + terms: { field: 'user.name', size: 1000 }, + aggs: { + ip: { terms: { field: 'host.ip', size: 10 } }, + }, + }, + }, + query: { + bool: { + filter: [ + { term: { 'host.name': 'host1' } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gt: '2020-09-02T15:17:13.678Z', + }, + }, + }, + ], + }, + }, + size: 0, + }, + ignore_unavailable: true, + index: ['test_indices*'], +}; + +export const mockRelatedHosts = [ + { user: 'Danny', ip: ['10.7.58.35', '10.185.185.41'] }, + { + user: 'Aaron', + ip: ['10.7.58.35', '10.185.185.41', '10.198.197.106'], + }, +]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts new file mode 100644 index 0000000000000..76227040f829b --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { hostsRelatedUsers } from '.'; +import { mockDeps, mockOptions, mockSearchStrategyResponse, mockRelatedHosts } from './__mocks__'; +import { get } from 'lodash/fp'; +import * as buildQuery from './query.related_users.dsl'; + +describe('hostsRelatedUsers search strategy', () => { + const buildRelatedUsersQuery = jest.spyOn(buildQuery, 'buildRelatedUsersQuery'); + + afterEach(() => { + buildRelatedUsersQuery.mockClear(); + }); + + describe('buildDsl', () => { + test('should build dsl query', () => { + hostsRelatedUsers.buildDsl(mockOptions); + expect(buildRelatedUsersQuery).toHaveBeenCalledWith(mockOptions); + }); + }); + + describe('parse', () => { + test('should parse data correctly', async () => { + const result = await hostsRelatedUsers.parse(mockOptions, mockSearchStrategyResponse); + expect(result.relatedUsers).toMatchObject(mockRelatedHosts); + expect(result.totalCount).toBe(2); + }); + + test('should enhance data with risk score', async () => { + const risk = 'TEST_RISK_SCORE'; + const userName: string = get( + `aggregations.user_data.buckets[0].key`, + mockSearchStrategyResponse.rawResponse + ); + + const mockedDeps = mockDeps(); + + mockedDeps.esClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [ + { + _id: 'id', + _index: 'index', + _source: { + risk, + user: { + name: userName, + risk: { + multipliers: [], + calculated_score_norm: 9999, + calculated_level: risk, + rule_risks: [], + }, + }, + }, + }, + ], + }, + took: 2, + _shards: { failed: 0, successful: 2, total: 2 }, + timed_out: false, + }); + + const result = await hostsRelatedUsers.parse( + mockOptions, + mockSearchStrategyResponse, + mockedDeps + ); + + expect(result.relatedUsers[0].risk).toBe(risk); + }); + + test('should not enhance data when space id does not exist', async () => { + const mockedDeps = mockDeps(); + const result = await hostsRelatedUsers.parse(mockOptions, mockSearchStrategyResponse, { + ...mockedDeps, + spaceId: undefined, + }); + + expect(result.relatedUsers[0].risk).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts new file mode 100644 index 0000000000000..ade66732c4a14 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; +import { getOr } from 'lodash/fp'; +import type { RiskSeverity } from '../../../../../../common/search_strategy/security_solution/risk_score/all'; +import type { SecuritySolutionFactory } from '../../types'; +import type { RelatedEntitiesQueries } from '../../../../../../common/search_strategy/security_solution/related_entities'; +import type { + HostsRelatedUsersRequestOptions, + HostsRelatedUsersStrategyResponse, + RelatedUserBucket, + RelatedUser, +} from '../../../../../../common/search_strategy/security_solution/related_entities/related_users'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { buildRelatedUsersQuery } from './query.related_users.dsl'; +import { getUserRiskData } from '../../users/all'; + +export const hostsRelatedUsers: SecuritySolutionFactory = { + buildDsl: (options: HostsRelatedUsersRequestOptions) => buildRelatedUsersQuery(options), + parse: async ( + options: HostsRelatedUsersRequestOptions, + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + spaceId?: string; + } + ): Promise => { + const aggregations = response.rawResponse.aggregations; + + const inspect = { + dsl: [inspectStringifyObject(buildRelatedUsersQuery(options))], + }; + + if (aggregations == null) { + return { ...response, inspect, totalCount: 0, relatedUsers: [] }; + } + + const totalCount = getOr(0, 'aggregations.user_count.value', response.rawResponse); + + const buckets: RelatedUserBucket[] = getOr( + [], + 'aggregations.user_data.buckets', + response.rawResponse + ); + const relatedUsers: RelatedUser[] = buckets.map( + (bucket: RelatedUserBucket) => ({ + user: bucket.key, + ip: bucket.ip?.buckets.map((ip) => ip.key) ?? [], + }), + {} + ); + + const enhancedUsers = deps?.spaceId + ? await addUserRiskData(relatedUsers, deps.spaceId, deps.esClient) + : relatedUsers; + + return { + ...response, + inspect, + totalCount, + relatedUsers: enhancedUsers, + }; + }, +}; + +async function addUserRiskData( + relatedUsers: RelatedUser[], + spaceId: string, + esClient: IScopedClusterClient +): Promise { + const userNames = relatedUsers.map((item) => item.user); + const userRiskData = await getUserRiskData(esClient, spaceId, userNames); + const usersRiskByUserName: Record | undefined = + userRiskData?.hits.hits.reduce( + (acc, hit) => ({ + ...acc, + [hit._source?.user.name ?? '']: hit._source?.user?.risk?.calculated_level, + }), + {} + ); + + return usersRiskByUserName + ? relatedUsers.map((item) => ({ + ...item, + risk: usersRiskByUserName[item.user], + })) + : relatedUsers; +} diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.ts new file mode 100644 index 0000000000000..9fc371e88364d --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildRelatedUsersQuery } from './query.related_users.dsl'; +import { mockOptions, expectedDsl } from './__mocks__'; + +describe('buildRelatedUsersQuery', () => { + test('build query from options correctly', () => { + expect(buildRelatedUsersQuery(mockOptions)).toMatchObject(expectedDsl); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts new file mode 100644 index 0000000000000..8824c4c359dec --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ISearchRequestParams } from '@kbn/data-plugin/common'; +import type { HostsRelatedUsersRequestOptions } from '../../../../../../common/search_strategy/security_solution/related_entities/related_users'; + +export const buildRelatedUsersQuery = ({ + hostName, + defaultIndex, + from, +}: HostsRelatedUsersRequestOptions): ISearchRequestParams => { + const now = new Date(); + const filter = [ + { term: { 'host.name': hostName } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + gt: from, + lte: now.toISOString(), + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const dslQuery = { + allow_no_indices: true, + index: defaultIndex, + ignore_unavailable: true, + track_total_hits: false, + body: { + aggregations: { + user_count: { cardinality: { field: 'user.name' } }, + user_data: { + terms: { + field: 'user.name', + size: 1000, + }, + aggs: { + ip: { + terms: { + field: 'host.ip', + size: 10, + }, + }, + }, + }, + }, + query: { bool: { filter } }, + size: 0, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts index 2bdc6c6956633..ece9391f2e39c 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts @@ -116,7 +116,7 @@ async function enhanceEdges( : edges; } -async function getUserRiskData( +export async function getUserRiskData( esClient: IScopedClusterClient, spaceId: string, userNames: string[] diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 5d252a92a217f..9046cccb857b4 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -32496,7 +32496,7 @@ "xpack.securitySolution.fleetIntegration.assets.name": "Hôtes", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "Filtre d'événement pour Cloud Security. Créé par l'intégration Elastic Defend.", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "Sessions non interactives", - "xpack.securitySolution.flyout.analyzerErrorTitle": "analyseur", + "xpack.securitySolution.flyout.analyzerErrorMessage": "analyseur", "xpack.securitySolution.flyout.button.timeline": "chronologie", "xpack.securitySolution.flyout.documentDetails.alertReasonTitle": "Raison d'alerte", "xpack.securitySolution.flyout.documentDetails.analyzerGraphButton": "Graph Analyseur", @@ -32543,7 +32543,7 @@ "xpack.securitySolution.flyout.documentDetails.visualizeTab": "Visualiser", "xpack.securitySolution.flyout.documentErrorMessage": "les valeurs et champs du document", "xpack.securitySolution.flyout.documentErrorTitle": "informations du document", - "xpack.securitySolution.flyout.sessionViewErrorTitle": "vue de session", + "xpack.securitySolution.flyout.sessionViewErrorMessage": "vue de session", "xpack.securitySolution.footer.autoRefreshActiveDescription": "Actualisation automatique active", "xpack.securitySolution.footer.cancel": "Annuler", "xpack.securitySolution.footer.data": "données", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 56faf3a995aa8..c555139f778a4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -32477,7 +32477,7 @@ "xpack.securitySolution.fleetIntegration.assets.name": "ホスト", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "クラウドセキュリティのイベントフィルター。Elastic Defend統合によって作成。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非インタラクティブセッション", - "xpack.securitySolution.flyout.analyzerErrorTitle": "アナライザー", + "xpack.securitySolution.flyout.analyzerErrorMessage": "アナライザー", "xpack.securitySolution.flyout.button.timeline": "タイムライン", "xpack.securitySolution.flyout.documentDetails.alertReasonTitle": "アラートの理由", "xpack.securitySolution.flyout.documentDetails.analyzerGraphButton": "アナライザーグラフ", @@ -32524,7 +32524,7 @@ "xpack.securitySolution.flyout.documentDetails.visualizeTab": "可視化", "xpack.securitySolution.flyout.documentErrorMessage": "ドキュメントフィールドおよび値", "xpack.securitySolution.flyout.documentErrorTitle": "ドキュメント情報", - "xpack.securitySolution.flyout.sessionViewErrorTitle": "セッションビュー", + "xpack.securitySolution.flyout.sessionViewErrorMessage": "セッションビュー", "xpack.securitySolution.footer.autoRefreshActiveDescription": "自動更新アクション", "xpack.securitySolution.footer.cancel": "キャンセル", "xpack.securitySolution.footer.data": "データ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c1e2edca82715..e60befbaed231 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -32473,7 +32473,7 @@ "xpack.securitySolution.fleetIntegration.assets.name": "主机", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "云安全事件筛选。已由 Elastic Defend 集成创建。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非交互式会话", - "xpack.securitySolution.flyout.analyzerErrorTitle": "分析器", + "xpack.securitySolution.flyout.analyzerErrorMessage": "分析器", "xpack.securitySolution.flyout.button.timeline": "时间线", "xpack.securitySolution.flyout.documentDetails.alertReasonTitle": "告警原因", "xpack.securitySolution.flyout.documentDetails.analyzerGraphButton": "分析器图表", @@ -32520,7 +32520,7 @@ "xpack.securitySolution.flyout.documentDetails.visualizeTab": "Visualize", "xpack.securitySolution.flyout.documentErrorMessage": "文档字段和值", "xpack.securitySolution.flyout.documentErrorTitle": "文档信息", - "xpack.securitySolution.flyout.sessionViewErrorTitle": "会话视图", + "xpack.securitySolution.flyout.sessionViewErrorMessage": "会话视图", "xpack.securitySolution.footer.autoRefreshActiveDescription": "自动刷新已启用", "xpack.securitySolution.footer.cancel": "取消", "xpack.securitySolution.footer.data": "数据", From 34e26250f0ff7033cbe2d96fddf00264a5b65599 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 24 May 2023 15:28:25 -0600 Subject: [PATCH 18/20] [tsvb] read only mode (#157920) part of https://github.com/elastic/kibana/issues/154307 PR adds ability to put TSVB into read only mode - preventing TSVB visualizations from being created and edited. To test: * start kibana with `yarn start --serverless=es` * add `vis_type_timeseries.readOnly: true` to kibana.yml Visualization public plugin changes: * Removes `hideTypes` from VisualizationSetup contract. Used by Maps plugin to set "hidden" to true for tile_map and region_map visualization types. In 8.0, tile_map and region_map visualization type registration moved into maps plugin so `hideTypes` no longer needed. * Renamed vis type definition `hidden` to `disableCreate`. * Added `disableEdit` to vis type definition. * Hide edit link in dashboard panel options when `disableEdit` is true * Does not display links and edit action in listing table when `disableEdit` is true Visualization server plugin changes: * Add `readOnlyVisType` registry to set up contract * Update visualization savedObject.management.getInAppUrl to return undefined when vis type has been registered as readOnly. * Prevents "readOnly "visualization types from being displayed in global search results * Prevents "readOnly "visualization types from having links in saved object management listing table. Timeseries server plugin changes: * Add `readOnly` yaml configuration * Expose `readOnly` yaml configuration to public * When `readOnly` is true, call VisualizationsServerSetup.registerReadOnlyVisType to mark vis type as read only Timeseries public plugin changes: * Set disableCreate and disableEdit to true when `readOnly` is true --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli --- .../src/components/item_details.tsx | 6 ++-- .../table_list/src/table_list_view.tsx | 10 ++++++ .../src/saved_objects_management.ts | 12 ++++--- .../dashboard_app/top_nav/editor_menu.tsx | 2 +- .../public/input_control_vis_type.ts | 2 +- .../vis_types/timeseries/common/constants.ts | 1 + .../timeseries/{server => }/config.ts | 11 ++++++ .../vis_types/timeseries/public/index.ts | 5 ++- .../timeseries/public/metrics_type.ts | 4 +-- .../vis_types/timeseries/public/plugin.ts | 13 +++++-- .../vis_types/timeseries/server/index.ts | 7 +++- .../vis_types/timeseries/server/plugin.ts | 10 +++++- .../embeddable/visualize_embeddable.tsx | 8 +++-- src/plugins/visualizations/public/mocks.ts | 1 - .../utils/saved_visualize_utils.test.ts | 3 ++ .../public/utils/saved_visualize_utils.ts | 2 ++ .../public/vis_types/base_vis_type.ts | 6 ++-- .../visualizations/public/vis_types/types.ts | 5 ++- .../public/vis_types/types_service.ts | 18 ---------- .../vis_types/vis_type_alias_registry.ts | 9 ++++- .../components/visualize_listing.tsx | 11 ++++-- .../utils/use/use_saved_vis_instance.ts | 10 ++++++ .../agg_based_selection.tsx | 2 +- .../group_selection/group_selection.tsx | 6 ++-- .../public/wizard/new_vis_modal.test.tsx | 3 +- src/plugins/visualizations/server/index.ts | 2 +- src/plugins/visualizations/server/plugin.ts | 8 ++--- .../saved_objects/get_in_app_url.test.ts | 36 +++++++++++++++++++ .../server/saved_objects/get_in_app_url.ts | 29 +++++++++++++++ .../server/saved_objects/index.ts | 1 + .../read_only_vis_type_registry.ts | 17 +++++++++ .../server/saved_objects/visualization.ts | 8 ++--- src/plugins/visualizations/server/types.ts | 7 ++-- .../test_suites/core_plugins/rendering.ts | 1 + .../editor_menu/editor_menu.tsx | 2 +- .../saved_objects/map_object_to_result.ts | 6 ++-- .../region_map/region_map_vis_type.tsx | 1 + .../tile_map/tile_map_vis_type.tsx | 1 + .../maps/public/maps_vis_type_alias.ts | 6 ++-- x-pack/plugins/maps/public/plugin.ts | 2 +- 40 files changed, 221 insertions(+), 73 deletions(-) rename src/plugins/vis_types/timeseries/{server => }/config.ts (75%) create mode 100644 src/plugins/visualizations/server/saved_objects/get_in_app_url.test.ts create mode 100644 src/plugins/visualizations/server/saved_objects/get_in_app_url.ts create mode 100644 src/plugins/visualizations/server/saved_objects/read_only_vis_type_registry.ts diff --git a/packages/content-management/table_list/src/components/item_details.tsx b/packages/content-management/table_list/src/components/item_details.tsx index ccfbb5e3ea55a..b7f4186438b66 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -7,7 +7,7 @@ */ import React, { useCallback, useMemo } from 'react'; -import { EuiText, EuiLink, EuiTitle, EuiSpacer, EuiHighlight } from '@elastic/eui'; +import { EuiText, EuiLink, EuiSpacer, EuiHighlight } from '@elastic/eui'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import type { Tag } from '../types'; @@ -104,9 +104,9 @@ export function ItemDetails({ return (
- {renderTitle()} + {renderTitle()} {Boolean(description) && ( - +

{description!} diff --git a/packages/content-management/table_list/src/table_list_view.tsx b/packages/content-management/table_list/src/table_list_view.tsx index 2191a3c9b7eee..030aaad0527a9 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -87,7 +87,14 @@ export interface Props void; createItem?(): void; deleteItems?(items: T[]): Promise; + /** + * Edit action onClick handler. Edit action not provided when property is not provided + */ editItem?(item: T): void; + /** + * Handler to set edit action visiblity per item. + */ + showEditActionForItem?(item: T): boolean; /** * Name for the column containing the "title" value. */ @@ -251,6 +258,7 @@ function TableListViewComp({ findItems, createItem, editItem, + showEditActionForItem, deleteItems, getDetailViewLink, onClickTitle, @@ -523,6 +531,7 @@ function TableListViewComp({ ), icon: 'pencil', type: 'icon', + available: (v) => (showEditActionForItem ? showEditActionForItem(v) : true), enabled: (v) => !(v as unknown as { error: string })?.error, onClick: editItem, }); @@ -577,6 +586,7 @@ function TableListViewComp({ DateFormatterComp, contentEditor, inspectItem, + showEditActionForItem, ]); const itemsById = useMemo(() => { diff --git a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_management.ts b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_management.ts index 5d72112cbb049..7d8608cd1479b 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_management.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/saved_objects_management.ts @@ -56,14 +56,16 @@ export interface SavedObjectsTypeManagementDefinition { * Function returning the url to use to redirect to this object from the management section. * If not defined, redirecting to the object will not be allowed. * - * @returns an object containing a `path` and `uiCapabilitiesPath` properties. the `path` is the path to + * @returns undefined or an object containing a `path` and `uiCapabilitiesPath` properties. the `path` is the path to * the object page, relative to the base path. `uiCapabilitiesPath` is the path to check in the * {@link Capabilities | uiCapabilities} to check if the user has permission to access the object. */ - getInAppUrl?: (savedObject: SavedObject) => { - path: string; - uiCapabilitiesPath: string; - }; + getInAppUrl?: (savedObject: SavedObject) => + | { + path: string; + uiCapabilitiesPath: string; + } + | undefined; /** * An optional export transform function that can be used transform the objects of the registered type during * the export process. diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx index 9b1259ffdd3cd..806045c20eca6 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/editor_menu.tsx @@ -96,7 +96,7 @@ export const EditorMenu = ({ createNewVisType, createNewEmbeddable }: Props) => } return 0; }) - .filter(({ hidden, stage }: BaseVisType) => !hidden); + .filter(({ disableCreate, stage }: BaseVisType) => !disableCreate); const promotedVisTypes = getSortedVisTypesByGroup(VisGroups.PROMOTED); const aggsBasedVisTypes = getSortedVisTypesByGroup(VisGroups.AGGBASED); diff --git a/src/plugins/input_control_vis/public/input_control_vis_type.ts b/src/plugins/input_control_vis/public/input_control_vis_type.ts index a0cfa84902dce..c25a1b53be33f 100644 --- a/src/plugins/input_control_vis/public/input_control_vis_type.ts +++ b/src/plugins/input_control_vis/public/input_control_vis_type.ts @@ -29,7 +29,7 @@ export function createInputControlVisTypeDefinition( defaultMessage: 'Input controls are deprecated and will be removed in a future version.', }), stage: 'experimental', - hidden: true, + disableCreate: true, isDeprecated: true, visConfig: { defaults: { diff --git a/src/plugins/vis_types/timeseries/common/constants.ts b/src/plugins/vis_types/timeseries/common/constants.ts index cbaf275cc0092..5e653df857eb9 100644 --- a/src/plugins/vis_types/timeseries/common/constants.ts +++ b/src/plugins/vis_types/timeseries/common/constants.ts @@ -20,3 +20,4 @@ export const ROUTES = { }; export const USE_KIBANA_INDEXES_KEY = 'use_kibana_indexes'; export const TSVB_DEFAULT_COLOR = '#68BC00'; +export const VIS_TYPE = 'metrics'; diff --git a/src/plugins/vis_types/timeseries/server/config.ts b/src/plugins/vis_types/timeseries/config.ts similarity index 75% rename from src/plugins/vis_types/timeseries/server/config.ts rename to src/plugins/vis_types/timeseries/config.ts index 5a44b3639a1f3..7b3dbbb0d6c2d 100644 --- a/src/plugins/vis_types/timeseries/server/config.ts +++ b/src/plugins/vis_types/timeseries/config.ts @@ -11,6 +11,13 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const config = schema.object({ enabled: schema.boolean({ defaultValue: true }), + readOnly: schema.conditional( + schema.contextRef('serverless'), + true, + schema.maybe(schema.boolean({ defaultValue: false })), + schema.never() + ), + /** @deprecated **/ chartResolution: schema.number({ defaultValue: 150 }), /** @deprecated **/ @@ -18,3 +25,7 @@ export const config = schema.object({ }); export type VisTypeTimeseriesConfig = TypeOf; + +export interface VisTypeTimeseriesPublicConfig { + readOnly?: boolean; +} diff --git a/src/plugins/vis_types/timeseries/public/index.ts b/src/plugins/vis_types/timeseries/public/index.ts index d0051df4de71e..8574f4922f772 100644 --- a/src/plugins/vis_types/timeseries/public/index.ts +++ b/src/plugins/vis_types/timeseries/public/index.ts @@ -7,8 +7,11 @@ */ import { PluginInitializerContext } from '@kbn/core/public'; +import { VisTypeTimeseriesPublicConfig } from '../config'; import { MetricsPlugin as Plugin } from './plugin'; -export function plugin(initializerContext: PluginInitializerContext) { +export function plugin( + initializerContext: PluginInitializerContext +) { return new Plugin(initializerContext); } diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.ts b/src/plugins/vis_types/timeseries/public/metrics_type.ts index 2390894b5d69d..4409faf7c0827 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.ts @@ -23,7 +23,7 @@ import { extractIndexPatternValues, isStringTypeIndexPattern, } from '../common/index_patterns_utils'; -import { TSVB_DEFAULT_COLOR, UI_SETTINGS } from '../common/constants'; +import { TSVB_DEFAULT_COLOR, UI_SETTINGS, VIS_TYPE } from '../common/constants'; import { toExpressionAst } from './to_ast'; import { getDataViewsStart, getUISettings } from './services'; import type { TimeseriesVisDefaultParams, TimeseriesVisParams } from './types'; @@ -99,7 +99,7 @@ async function getUsedIndexPatterns(params: VisParams): Promise { export const metricsVisDefinition: VisTypeDefinition< TimeseriesVisParams | TimeseriesVisDefaultParams > = { - name: 'metrics', + name: VIS_TYPE, title: i18n.translate('visTypeTimeseries.kbnVisTypes.metricsTitle', { defaultMessage: 'TSVB' }), description: i18n.translate('visTypeTimeseries.kbnVisTypes.metricsDescription', { defaultMessage: 'Perform advanced analysis of your time series data.', diff --git a/src/plugins/vis_types/timeseries/public/plugin.ts b/src/plugins/vis_types/timeseries/public/plugin.ts index 6054a0dcd3d3b..84d71cdfeddfd 100644 --- a/src/plugins/vis_types/timeseries/public/plugin.ts +++ b/src/plugins/vis_types/timeseries/public/plugin.ts @@ -20,6 +20,7 @@ import type { HttpSetup } from '@kbn/core-http-browser'; import type { ThemeServiceStart } from '@kbn/core-theme-browser'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; +import { VisTypeTimeseriesPublicConfig } from '../config'; import { EditorController, TSVB_EDITOR_NAME } from './application/editor_controller'; @@ -71,13 +72,15 @@ export interface TimeseriesVisDependencies extends Partial { /** @internal */ export class MetricsPlugin implements Plugin { - initializerContext: PluginInitializerContext; + initializerContext: PluginInitializerContext; - constructor(initializerContext: PluginInitializerContext) { + constructor(initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; } public setup(core: CoreSetup, { expressions, visualizations }: MetricsPluginSetupDependencies) { + const { readOnly } = this.initializerContext.config.get(); + visualizations.visEditorsRegistry.register(TSVB_EDITOR_NAME, EditorController); expressions.registerFunction(createMetricsFn); expressions.registerRenderer( @@ -87,7 +90,11 @@ export class MetricsPlugin implements Plugin { }) ); setUISettings(core.uiSettings); - visualizations.createBaseVisualization(metricsVisDefinition); + visualizations.createBaseVisualization({ + ...metricsVisDefinition, + disableCreate: Boolean(readOnly), + disableEdit: Boolean(readOnly), + }); } public start( diff --git a/src/plugins/vis_types/timeseries/server/index.ts b/src/plugins/vis_types/timeseries/server/index.ts index ee274ecd6d859..0faecf4c03adc 100644 --- a/src/plugins/vis_types/timeseries/server/index.ts +++ b/src/plugins/vis_types/timeseries/server/index.ts @@ -7,12 +7,17 @@ */ import { PluginInitializerContext, PluginConfigDescriptor } from '@kbn/core/server'; -import { VisTypeTimeseriesConfig, config as configSchema } from './config'; +import { VisTypeTimeseriesConfig, config as configSchema } from '../config'; import { VisTypeTimeseriesPlugin } from './plugin'; export type { VisTypeTimeseriesSetup } from './plugin'; export const config: PluginConfigDescriptor = { + // exposeToBrowser specifies kibana.yml settings to expose to the browser + // the value `true` in this context signals configuration is exposed to browser + exposeToBrowser: { + readOnly: true, + }, schema: configSchema, }; diff --git a/src/plugins/vis_types/timeseries/server/plugin.ts b/src/plugins/vis_types/timeseries/server/plugin.ts index c7a642a1d404a..194c6388bac80 100644 --- a/src/plugins/vis_types/timeseries/server/plugin.ts +++ b/src/plugins/vis_types/timeseries/server/plugin.ts @@ -24,7 +24,9 @@ import type { DataViewsService } from '@kbn/data-views-plugin/common'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server'; import type { PluginStart as DataViewsPublicPluginStart } from '@kbn/data-views-plugin/server'; import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; -import { VisTypeTimeseriesConfig } from './config'; +import type { VisualizationsServerSetup } from '@kbn/visualizations-plugin/server'; +import { VIS_TYPE } from '../common/constants'; +import { VisTypeTimeseriesConfig } from '../config'; import { getVisData } from './lib/get_vis_data'; import { visDataRoutes } from './routes/vis'; import { fieldsRoutes } from './routes/fields'; @@ -47,6 +49,7 @@ export interface LegacySetup { interface VisTypeTimeseriesPluginSetupDependencies { home?: HomeServerPluginSetup; + visualizations: VisualizationsServerSetup; } interface VisTypeTimeseriesPluginStartDependencies { @@ -126,6 +129,11 @@ export class VisTypeTimeseriesPlugin implements Plugin { visDataRoutes(router, framework); fieldsRoutes(router, framework); + const { readOnly } = this.initializerContext.config.get(); + if (readOnly) { + plugins.visualizations.registerReadOnlyVisType(VIS_TYPE); + } + return { getVisData: async ( requestContext: VisTypeTimeseriesRequestHandlerContext, diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index a8eaca1552cd0..9d34d2fb26ca6 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -170,10 +170,12 @@ export class VisualizeEmbeddable this.attributeService = attributeService; if (this.attributeService) { + const readOnly = Boolean(vis.type.disableEdit); const isByValue = !this.inputIsRefType(initialInput); - const editable = - capabilities.visualizeSave || - (isByValue && capabilities.dashboardSave && capabilities.visualizeOpen); + const editable = readOnly + ? false + : capabilities.visualizeSave || + (isByValue && capabilities.dashboardSave && capabilities.visualizeOpen); this.updateOutput({ ...this.getOutput(), editable }); } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index f34e725044146..4a711359143a3 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -33,7 +33,6 @@ import { Schema, VisualizationsSetup, VisualizationsStart } from '.'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), registerAlias: jest.fn(), - hideTypes: jest.fn(), visEditorsRegistry: { registerDefault: jest.fn(), register: jest.fn(), get: jest.fn() }, }); diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts index e45bb15f49323..d5b26fe455ac6 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts @@ -499,6 +499,8 @@ describe('saved_visualize_utils', () => { }, { id: 'wat', + image: undefined, + readOnly: false, references: undefined, icon: undefined, savedObjectType: 'visualization', @@ -506,6 +508,7 @@ describe('saved_visualize_utils', () => { type: 'test', typeName: 'test', typeTitle: undefined, + updatedAt: undefined, title: 'WATEVER', url: '#/edit/wat', }, diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 931d00c5b9d33..9232504e026d3 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -80,6 +80,7 @@ export function mapHitSource( image?: BaseVisType['image']; typeTitle?: BaseVisType['title']; error?: string; + readOnly?: boolean; } = { id, references, @@ -108,6 +109,7 @@ export function mapHitSource( newAttributes.image = newAttributes.type?.image; newAttributes.typeTitle = newAttributes.type?.title; newAttributes.editUrl = `/edit/${id}`; + newAttributes.readOnly = Boolean(visTypes.get(typeName as string)?.disableEdit); return newAttributes; } diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index 4253b134cb748..2625781c26429 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -38,7 +38,8 @@ export class BaseVisType { public readonly options: VisTypeOptions; public readonly visConfig; public readonly editorConfig; - public hidden; + public readonly disableCreate; + public readonly disableEdit; public readonly requiresSearch; public readonly suppressWarnings; public readonly hasPartialRows; @@ -74,7 +75,8 @@ export class BaseVisType { this.isDeprecated = opts.isDeprecated ?? false; this.group = opts.group ?? VisGroups.AGGBASED; this.titleInWizard = opts.titleInWizard ?? ''; - this.hidden = opts.hidden ?? false; + this.disableCreate = opts.disableCreate ?? false; + this.disableEdit = opts.disableEdit ?? false; this.requiresSearch = opts.requiresSearch ?? false; this.setup = opts.setup; this.hasPartialRows = opts.hasPartialRows ?? false; diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 5d581a52130a5..90f64276adf76 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -199,7 +199,10 @@ export interface VisTypeDefinition { readonly updateVisTypeOnParamsChange?: (params: VisParams) => string | undefined; readonly setup?: (vis: Vis) => Promise>; - hidden?: boolean; + + disableCreate?: boolean; + + disableEdit?: boolean; readonly options?: Partial; diff --git a/src/plugins/visualizations/public/vis_types/types_service.ts b/src/plugins/visualizations/public/vis_types/types_service.ts index ae8ba8b8ad518..4b20dcc1569c5 100644 --- a/src/plugins/visualizations/public/vis_types/types_service.ts +++ b/src/plugins/visualizations/public/vis_types/types_service.ts @@ -18,13 +18,8 @@ import { VisGroups } from './vis_groups_enum'; */ export class TypesService { private types: Record> = {}; - private unregisteredHiddenTypes: string[] = []; private registerVisualization(visDefinition: BaseVisType) { - if (this.unregisteredHiddenTypes.includes(visDefinition.name)) { - visDefinition.hidden = true; - } - if (this.types[visDefinition.name]) { throw new Error('type already exists!'); } @@ -47,19 +42,6 @@ export class TypesService { * @param {VisTypeAlias} config - visualization alias definition */ registerAlias: visTypeAliasRegistry.add, - /** - * allows to hide specific visualization types from create visualization dialog - * @param {string[]} typeNames - list of type ids to hide - */ - hideTypes: (typeNames: string[]): void => { - typeNames.forEach((name: string) => { - if (this.types[name]) { - this.types[name].hidden = true; - } else { - this.unregisteredHiddenTypes.push(name); - } - }); - }, }; } diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 83a2560f667af..61e36c931390e 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -44,7 +44,14 @@ export interface VisTypeAlias { note?: string; getSupportedTriggers?: () => string[]; stage: VisualizationStage; - hidden?: boolean; + /* + * Set to true to hide visualization type in create UIs. + */ + disableCreate?: boolean; + /* + * Set to true to hide edit links for visualization type in UIs. + */ + disableEdit?: boolean; isDeprecated?: boolean; appExtensions?: { diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx index 85a81154f20ec..8dd9885ef5520 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -42,6 +42,7 @@ interface VisualizeUserContent extends VisualizationListItem, UserContentCommonS description?: string; editApp: string; editUrl: string; + readOnly: boolean; error?: string; }; } @@ -65,6 +66,7 @@ const toTableListViewSavedObject = (savedObject: Record): Visua description: savedObject.description as string, editApp: savedObject.editApp as string, editUrl: savedObject.editUrl as string, + readOnly: savedObject.readOnly as boolean, error: savedObject.error as string, }, }; @@ -291,6 +293,9 @@ export const VisualizeListing = () => { findItems={fetchItems} deleteItems={visualizeCapabilities.delete ? deleteItems : undefined} editItem={visualizeCapabilities.save ? editItem : undefined} + showEditActionForItem={({ attributes: { readOnly } }) => + visualizeCapabilities.save && !readOnly + } customTableColumn={getCustomColumn()} listingLimit={listingLimit} initialPageSize={initialPageSize} @@ -310,8 +315,10 @@ export const VisualizeListing = () => { tableListTitle={i18n.translate('visualizations.listing.table.listTitle', { defaultMessage: 'Visualize Library', })} - getDetailViewLink={({ attributes: { editApp, editUrl, error } }) => - getVisualizeListItemLink(core.application, kbnUrlStateStorage, editApp, editUrl, error) + getDetailViewLink={({ attributes: { editApp, editUrl, error, readOnly } }) => + readOnly + ? undefined + : getVisualizeListItemLink(core.application, kbnUrlStateStorage, editApp, editUrl, error) } > {dashboardCapabilities.createNew && ( diff --git a/src/plugins/visualizations/public/visualize_app/utils/use/use_saved_vis_instance.ts b/src/plugins/visualizations/public/visualize_app/utils/use/use_saved_vis_instance.ts index f84b7928dda39..dcd53feb5b1e9 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/use/use_saved_vis_instance.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/use/use_saved_vis_instance.ts @@ -83,6 +83,16 @@ export const useSavedVisInstance = ( savedVisInstance = await getVisualizationInstance(services, visualizationIdFromUrl); } + if (savedVisInstance.vis.type.disableEdit) { + throw new Error( + i18n.translate('visualizations.editVisualization.readOnlyErrorMessage', { + defaultMessage: + '{visTypeTitle} visualizations are read only and can not be opened in editor', + values: { visTypeTitle: savedVisInstance.vis.type.title }, + }) + ); + } + if (embeddableInput && embeddableInput.timeRange) { savedVisInstance.panelTimeRange = embeddableInput.timeRange; } diff --git a/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx index d4d2c1505bf8a..f4cdd05978830 100644 --- a/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx +++ b/src/plugins/visualizations/public/wizard/agg_based_selection/agg_based_selection.tsx @@ -100,7 +100,7 @@ class AggBasedSelection extends React.Component { // Filter out hidden visualizations and visualizations that are only aggregations based - return !type.hidden; + return !type.disableCreate; }); let entries: VisTypeListEntry[]; diff --git a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx index e0acc20157b44..016d97d713074 100644 --- a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx +++ b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx @@ -57,7 +57,9 @@ function GroupSelection(props: GroupSelectionProps) { [ ...props.visTypesRegistry.getAliases(), ...props.visTypesRegistry.getByGroup(VisGroups.PROMOTED), - ], + ].filter((visDefinition) => { + return !Boolean(visDefinition.disableCreate); + }), ['promotion', 'title'], ['asc', 'asc'] ), @@ -217,7 +219,7 @@ const ToolsGroup = ({ visType, onVisTypeSelected, showExperimental }: VisCardPro }, [onVisTypeSelected, visType]); // hide both the hidden visualizations and, if lab mode is not enabled, the experimental visualizations // TODO: Remove the showExperimental logic as part of https://github.com/elastic/kibana/issues/152833 - if (visType.hidden || (!showExperimental && visType.stage === 'experimental')) { + if (visType.disableCreate || (!showExperimental && visType.stage === 'experimental')) { return null; } return ( diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx index 0e48386a97be3..a150a94a60516 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx @@ -17,7 +17,8 @@ import { savedObjectsManagementPluginMock } from '@kbn/saved-objects-management- describe('NewVisModal', () => { const defaultVisTypeParams = { - hidden: false, + disableCreate: false, + disableEdit: false, requiresSearch: false, }; const _visTypes = [ diff --git a/src/plugins/visualizations/server/index.ts b/src/plugins/visualizations/server/index.ts index 7d01eadbc6dba..f40fb9885388f 100644 --- a/src/plugins/visualizations/server/index.ts +++ b/src/plugins/visualizations/server/index.ts @@ -16,4 +16,4 @@ export function plugin(initializerContext: PluginInitializerContext) { return new VisualizationsPlugin(initializerContext); } -export type { VisualizationsPluginSetup, VisualizationsPluginStart } from './types'; +export type { VisualizationsServerSetup, VisualizationsServerStart } from './types'; diff --git a/src/plugins/visualizations/server/plugin.ts b/src/plugins/visualizations/server/plugin.ts index 0dd46f4fdd448..6aa4a749ecb7a 100644 --- a/src/plugins/visualizations/server/plugin.ts +++ b/src/plugins/visualizations/server/plugin.ts @@ -19,13 +19,13 @@ import { ContentManagementServerSetup } from '@kbn/content-management-plugin/ser import { capabilitiesProvider } from './capabilities_provider'; import { VisualizationsStorage } from './content_management'; -import type { VisualizationsPluginSetup, VisualizationsPluginStart } from './types'; +import type { VisualizationsServerSetup, VisualizationsServerStart } from './types'; import { makeVisualizeEmbeddableFactory } from './embeddable/make_visualize_embeddable_factory'; -import { getVisualizationSavedObjectType } from './saved_objects'; +import { getVisualizationSavedObjectType, registerReadOnlyVisType } from './saved_objects'; import { CONTENT_ID, LATEST_VERSION } from '../common/content_management'; export class VisualizationsPlugin - implements Plugin + implements Plugin { private readonly logger: Logger; @@ -61,7 +61,7 @@ export class VisualizationsPlugin }, }); - return {}; + return { registerReadOnlyVisType }; } public start(core: CoreStart) { diff --git a/src/plugins/visualizations/server/saved_objects/get_in_app_url.test.ts b/src/plugins/visualizations/server/saved_objects/get_in_app_url.test.ts new file mode 100644 index 0000000000000..005e6d4a33613 --- /dev/null +++ b/src/plugins/visualizations/server/saved_objects/get_in_app_url.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { VisualizationSavedObject } from '../../common/content_management'; +import { registerReadOnlyVisType } from './read_only_vis_type_registry'; +import { getInAppUrl } from './get_in_app_url'; + +registerReadOnlyVisType('myLegacyVis'); + +test('should return visualize edit url', () => { + const obj = { + id: '1', + attributes: { + visState: JSON.stringify({ type: 'vega' }), + }, + } as unknown as VisualizationSavedObject; + expect(getInAppUrl(obj)).toEqual({ + path: '/app/visualize#/edit/1', + uiCapabilitiesPath: 'visualize.show', + }); +}); + +test('should return undefined when visualization type is read only', () => { + const obj = { + id: '1', + attributes: { + visState: JSON.stringify({ type: 'myLegacyVis' }), + }, + } as unknown as VisualizationSavedObject; + expect(getInAppUrl(obj)).toBeUndefined(); +}); diff --git a/src/plugins/visualizations/server/saved_objects/get_in_app_url.ts b/src/plugins/visualizations/server/saved_objects/get_in_app_url.ts new file mode 100644 index 0000000000000..11259327a242c --- /dev/null +++ b/src/plugins/visualizations/server/saved_objects/get_in_app_url.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { VisualizationSavedObject } from '../../common/content_management'; +import { isVisTypeReadOnly } from './read_only_vis_type_registry'; + +export function getInAppUrl(obj: VisualizationSavedObject) { + let visType: string | undefined; + if (obj.attributes.visState) { + try { + const visState = JSON.parse(obj.attributes.visState); + visType = visState?.type; + } catch (e) { + // let client display warning for unparsable visState + } + } + + return isVisTypeReadOnly(visType) + ? undefined + : { + path: `/app/visualize#/edit/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'visualize.show', + }; +} diff --git a/src/plugins/visualizations/server/saved_objects/index.ts b/src/plugins/visualizations/server/saved_objects/index.ts index f5e2d26acbc0c..0c19590e5a27d 100644 --- a/src/plugins/visualizations/server/saved_objects/index.ts +++ b/src/plugins/visualizations/server/saved_objects/index.ts @@ -7,3 +7,4 @@ */ export { getVisualizationSavedObjectType } from './visualization'; +export { registerReadOnlyVisType } from './read_only_vis_type_registry'; diff --git a/src/plugins/visualizations/server/saved_objects/read_only_vis_type_registry.ts b/src/plugins/visualizations/server/saved_objects/read_only_vis_type_registry.ts new file mode 100644 index 0000000000000..1ee9bd91ac575 --- /dev/null +++ b/src/plugins/visualizations/server/saved_objects/read_only_vis_type_registry.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +const registry: string[] = []; + +export function registerReadOnlyVisType(visType: string) { + registry.push(visType); +} + +export function isVisTypeReadOnly(visType?: string) { + return visType ? registry.includes(visType) : false; +} diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts index bfde137be6f62..fb77f9b58f3e6 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -12,6 +12,7 @@ import { SavedObjectsType } from '@kbn/core/server'; import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { CONTENT_ID } from '../../common/content_management'; import { getAllMigrations } from '../migrations/visualization_saved_object_migrations'; +import { getInAppUrl } from './get_in_app_url'; export const getVisualizationSavedObjectType = ( getSearchSourceMigrations: () => MigrateFunctionsObject @@ -28,12 +29,7 @@ export const getVisualizationSavedObjectType = ( getTitle(obj) { return obj.attributes.title; }, - getInAppUrl(obj) { - return { - path: `/app/visualize#/edit/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'visualize.show', - }; - }, + getInAppUrl, }, mappings: { dynamic: false, // declared here to prevent indexing root level attribute fields diff --git a/src/plugins/visualizations/server/types.ts b/src/plugins/visualizations/server/types.ts index 1256845584ae5..a6df93450689c 100644 --- a/src/plugins/visualizations/server/types.ts +++ b/src/plugins/visualizations/server/types.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ +export interface VisualizationsServerSetup { + registerReadOnlyVisType: (visType: string) => void; +} // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface VisualizationsPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface VisualizationsPluginStart {} +export interface VisualizationsServerStart {} diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index d444895d86503..4eae48e2b83d1 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -160,6 +160,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'telemetry.sendUsageTo (any)', 'usageCollection.uiCounters.debug (boolean)', 'usageCollection.uiCounters.enabled (boolean)', + 'vis_type_timeseries.readOnly (any)', 'vis_type_vega.enableExternalUrls (boolean)', 'xpack.actions.email.domain_allowlist (array)', 'xpack.apm.serviceMapEnabled (boolean)', diff --git a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx index ed34b2818b531..154dab1fa0fec 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/editor_menu/editor_menu.tsx @@ -134,7 +134,7 @@ export const EditorMenu: FC = ({ addElement }) => { } return 0; }) - .filter(({ hidden }: BaseVisType) => !hidden); + .filter(({ disableCreate }: BaseVisType) => !disableCreate); const visTypeAliases = visualizationsService .getAliases() diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts index bc993129435cb..ac3296b231516 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts @@ -33,8 +33,8 @@ const isAccessible = ( if (getInAppUrl === undefined) { throw new Error('Trying to map an object from a type without management metadata'); } - const { uiCapabilitiesPath } = getInAppUrl(object); - return Boolean(get(capabilities, uiCapabilitiesPath) ?? false); + const inAppUrl = getInAppUrl(object); + return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath) ?? false) : false; }; export const mapToResult = ( @@ -52,7 +52,7 @@ export const mapToResult = ( title: getTitle ? getTitle(object) : (object.attributes as any)[defaultSearchField], type: object.type, icon: type.management?.icon ?? undefined, - url: getInAppUrl(object).path, + url: getInAppUrl(object)!.path, score: object.score, meta: { tagIds: object.references.filter((ref) => ref.type === 'tag').map(({ id }) => id), diff --git a/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_vis_type.tsx b/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_vis_type.tsx index b51c4a3b5a23f..2c0afd1ff3462 100644 --- a/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_vis_type.tsx +++ b/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_vis_type.tsx @@ -49,4 +49,5 @@ export const regionMapVisType = { }, toExpressionAst, requiresSearch: true, + disableCreate: true, } as VisTypeDefinition; diff --git a/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_vis_type.tsx b/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_vis_type.tsx index c0a5fe55259f4..63dc4bc630920 100644 --- a/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_vis_type.tsx +++ b/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_vis_type.tsx @@ -50,4 +50,5 @@ export const tileMapVisType = { }, toExpressionAst, requiresSearch: true, + disableCreate: true, } as VisTypeDefinition; diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.ts b/x-pack/plugins/maps/public/maps_vis_type_alias.ts index d26108737ab33..548098311e3c2 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.ts +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import type { VisualizationsSetup, VisualizationStage } from '@kbn/visualizations-plugin/public'; +import type { VisualizationStage } from '@kbn/visualizations-plugin/public'; import type { MapItem } from '../common/content_management'; import { APP_ID, @@ -17,9 +17,7 @@ import { MAP_SAVED_OBJECT_TYPE, } from '../common/constants'; -export function getMapsVisTypeAlias(visualizations: VisualizationsSetup) { - visualizations.hideTypes(['region_map', 'tile_map']); - +export function getMapsVisTypeAlias() { const appDescription = i18n.translate('xpack.maps.visTypeAlias.description', { defaultMessage: 'Create and style maps with multiple layers and indices.', }); diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 75c4211c54d58..2e6d203d05dae 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -185,7 +185,7 @@ export class MapsPlugin if (plugins.home) { plugins.home.featureCatalogue.register(featureCatalogueEntry); } - plugins.visualizations.registerAlias(getMapsVisTypeAlias(plugins.visualizations)); + plugins.visualizations.registerAlias(getMapsVisTypeAlias()); plugins.embeddable.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory()); core.application.register({ From 2781645d07ce957793586c2bc432072f73c3d9b7 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 24 May 2023 16:56:23 -0500 Subject: [PATCH 19/20] Revert "[ci] Make container image tag update trigger async (#152970)" (#158421) This reverts commit 3b20df420669aed27bb3427d394ae3748afdcca8. This pipeline was made async to decrease the number of build failure alerts during a stabilization period. This re-synchronizes the pipeline. @afharo FYI. --- .buildkite/scripts/steps/artifacts/docker_image.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/.buildkite/scripts/steps/artifacts/docker_image.sh b/.buildkite/scripts/steps/artifacts/docker_image.sh index 3d75d301ce8e7..7e057c2835349 100755 --- a/.buildkite/scripts/steps/artifacts/docker_image.sh +++ b/.buildkite/scripts/steps/artifacts/docker_image.sh @@ -85,7 +85,6 @@ if [[ "$BUILDKITE_BRANCH" == "$KIBANA_BASE_BRANCH" ]]; then cat << EOF | buildkite-agent pipeline upload steps: - trigger: serverless-gitops-update-stack-image-tag - async: true label: ":argo: Update image tag for Kibana" branches: main build: From 029eb3104ae878f8178bb1d936183f7488a8f1f2 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 24 May 2023 16:47:35 -0600 Subject: [PATCH 20/20] [Security solution] Generative AI Connector (#157228) --- docs/management/action-types.asciidoc | 5 + .../connectors/action-types/gen-ai.asciidoc | 89 +++++ .../connectors/images/gen-ai-connector.png | Bin 0 -> 198821 bytes .../connectors/images/gen-ai-params-test.png | Bin 0 -> 183534 bytes docs/management/connectors/index.asciidoc | 1 + docs/settings/alert-action-settings.asciidoc | 2 +- .../common/connector_feature_config.test.ts | 5 +- .../common/connector_feature_config.ts | 15 + x-pack/plugins/actions/common/types.ts | 1 + .../helpers/validators.test.ts | 32 ++ .../helpers/validators.ts | 17 + .../sub_action_connector.ts | 16 +- .../common/gen_ai/constants.ts | 24 ++ .../stack_connectors/common/gen_ai/schema.ts | 22 ++ .../stack_connectors/common/gen_ai/types.ts | 19 ++ x-pack/plugins/stack_connectors/kibana.jsonc | 3 + .../stack_connectors/public/common/index.ts | 11 + .../connector_types/gen_ai/connector.test.tsx | 195 +++++++++++ .../connector_types/gen_ai/connector.tsx | 207 ++++++++++++ .../connector_types/gen_ai/constants.ts | 24 ++ .../connector_types/gen_ai/gen_ai.test.tsx | 83 +++++ .../public/connector_types/gen_ai/gen_ai.tsx | 61 ++++ .../public/connector_types/gen_ai/index.ts | 8 + .../public/connector_types/gen_ai/logo.tsx | 27 ++ .../connector_types/gen_ai/params.test.tsx | 146 ++++++++ .../public/connector_types/gen_ai/params.tsx | 90 +++++ .../connector_types/gen_ai/translations.ts | 89 +++++ .../public/connector_types/gen_ai/types.ts | 35 ++ .../public/connector_types/index.ts | 2 + .../connector_types/gen_ai/api_schema.ts | 33 ++ .../connector_types/gen_ai/gen_ai.test.ts | 99 ++++++ .../server/connector_types/gen_ai/gen_ai.ts | 74 ++++ .../connector_types/gen_ai/index.test.ts | 108 ++++++ .../server/connector_types/gen_ai/index.ts | 73 ++++ .../connector_types/gen_ai/render.test.ts | 47 +++ .../server/connector_types/gen_ai/render.ts | 26 ++ .../server/connector_types/index.ts | 2 + .../stack_connectors/server/plugin.test.ts | 29 ++ .../public/common/constants/index.ts | 4 + .../alerting_api_integration/common/config.ts | 1 + .../server/gen_ai_simulation.ts | 67 ++++ .../tests/actions/connector_types/gen_ai.ts | 316 ++++++++++++++++++ .../group2/tests/actions/index.ts | 1 + .../check_registered_connector_types.ts | 1 + .../check_registered_task_types.ts | 1 + 45 files changed, 2094 insertions(+), 17 deletions(-) create mode 100644 docs/management/connectors/action-types/gen-ai.asciidoc create mode 100644 docs/management/connectors/images/gen-ai-connector.png create mode 100644 docs/management/connectors/images/gen-ai-params-test.png create mode 100644 x-pack/plugins/actions/server/sub_action_framework/helpers/validators.test.ts create mode 100644 x-pack/plugins/stack_connectors/common/gen_ai/constants.ts create mode 100644 x-pack/plugins/stack_connectors/common/gen_ai/schema.ts create mode 100644 x-pack/plugins/stack_connectors/common/gen_ai/types.ts create mode 100644 x-pack/plugins/stack_connectors/public/common/index.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/index.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/logo.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/translations.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/gen_ai/api_schema.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.ts create mode 100644 x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gen_ai_simulation.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 4e9adbd8c8e77..40963e765b5c5 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -78,6 +78,11 @@ a| <> a| <> | Trigger a Torq workflow. + +a| <> + +| Send a request to OpenAI. + |=== [NOTE] diff --git a/docs/management/connectors/action-types/gen-ai.asciidoc b/docs/management/connectors/action-types/gen-ai.asciidoc new file mode 100644 index 0000000000000..dda7ebc3e190a --- /dev/null +++ b/docs/management/connectors/action-types/gen-ai.asciidoc @@ -0,0 +1,89 @@ +[[gen-ai-action-type]] +== Generative AI connector and action +++++ +Generative AI +++++ + +The Generative AI connector uses https://github.com/axios/axios[axios] to send a POST request to an OpenAI provider, either OpenAI or Azure OpenAI. The connector uses the <> to send the request. + +[float] +[[define-gen-ai-ui]] +=== Create connectors in {kib} + +You can create connectors in *{stack-manage-app} > {connectors-ui}*. For example: + +[role="screenshot"] +image::management/connectors/images/gen-ai-connector.png[Generative AI connector] + +[float] +[[gen-ai-connector-configuration]] +==== Connector configuration + +Generative AI connectors have the following configuration properties: + +Name:: The name of the connector. +API Provider:: The OpenAI API provider, either OpenAI or Azure OpenAI. +API URL:: The OpenAI request URL. +API Key:: The OpenAI or Azure OpenAI API key for authentication. + +[float] +[[preconfigured-gen-ai-configuration]] +=== Create preconfigured connectors + +If you are running {kib} on-prem, you can define connectors by +adding `xpack.actions.preconfigured` settings to your `kibana.yml` file. +For example: + +[source,text] +-- +xpack.actions.preconfigured: + my-gen-ai: + name: preconfigured-gen-ai-connector-type + actionTypeId: .gen-ai + config: + apiUrl: https://api.openai.com/v1/chat/completions + apiProvider: 'Azure OpenAI' + secrets: + apiKey: superlongapikey +-- + +Config defines information for the connector type. + +`apiProvider`:: A string that corresponds to *OpenAI API Provider*. +`apiUrl`:: A URL string that corresponds to the *OpenAI API URL*. + +Secrets defines sensitive information for the connector type. + +`apiKey`:: A string that corresponds to *OpenAI API Key*. + +[float] +[[gen-ai-action-configuration]] +=== Test connectors + +You can test connectors with the <> or +as you're creating or editing the connector in {kib}. For example: + +[role="screenshot"] +image::management/connectors/images/gen-ai-params-test.png[Generative AI params test] + +The Generative AI actions have the following configuration properties. + +Body:: A JSON payload sent to the OpenAI API URL. For example: ++ +[source,text] +-- +{ + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "Hello world" + } + ] +} +-- +[float] +[[gen-ai-connector-networking-configuration]] +=== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. \ No newline at end of file diff --git a/docs/management/connectors/images/gen-ai-connector.png b/docs/management/connectors/images/gen-ai-connector.png new file mode 100644 index 0000000000000000000000000000000000000000..7306f6b28383c41c54a34f84963a41067febe46f GIT binary patch literal 198821 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≥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 tZ8h*jI#1;M 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 K<)Vw$A^!&&;G>ZM literal 0 HcmV?d00001 diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 86adf5d49581b..25a6ec1d042cc 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -16,4 +16,5 @@ include::action-types/torq.asciidoc[leveloffset=+1] include::action-types/webhook.asciidoc[leveloffset=+1] include::action-types/cases-webhook.asciidoc[leveloffset=+1] include::action-types/xmatters.asciidoc[leveloffset=+1] +include::action-types/gen-ai.asciidoc[leveloffset=+1] include::pre-configured-connectors.asciidoc[leveloffset=+1] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 7cebc648ca139..c749c074483ca 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -138,7 +138,7 @@ WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not A boolean value indicating that a footer with a relevant link should be added to emails sent as alerting actions. Default: true. `xpack.actions.enabledActionTypes` {ess-icon}:: -A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. +A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.xmatters`, `.gen-ai`, and `.webhook`. An empty list `[]` will disable all action types. + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. diff --git a/x-pack/plugins/actions/common/connector_feature_config.test.ts b/x-pack/plugins/actions/common/connector_feature_config.test.ts index 5aea0a7c72bd8..cb571dfa8714d 100644 --- a/x-pack/plugins/actions/common/connector_feature_config.test.ts +++ b/x-pack/plugins/actions/common/connector_feature_config.test.ts @@ -13,7 +13,7 @@ import { describe('areValidFeatures', () => { it('returns true when all inputs are valid features', () => { - expect(areValidFeatures(['alerting', 'cases'])).toBeTruthy(); + expect(areValidFeatures(['alerting', 'cases', 'general'])).toBeTruthy(); }); it('returns true when only one input and it is a valid feature', () => { @@ -42,9 +42,10 @@ describe('getConnectorFeatureName', () => { describe('getConnectorCompatibility', () => { it('returns the compatibility list for valid feature ids', () => { - expect(getConnectorCompatibility(['alerting', 'cases', 'uptime', 'siem'])).toEqual([ + expect(getConnectorCompatibility(['alerting', 'cases', 'uptime', 'siem', 'general'])).toEqual([ 'Alerting Rules', 'Cases', + 'General', ]); }); diff --git a/x-pack/plugins/actions/common/connector_feature_config.ts b/x-pack/plugins/actions/common/connector_feature_config.ts index 6e9adab5de5a8..27c035546882b 100644 --- a/x-pack/plugins/actions/common/connector_feature_config.ts +++ b/x-pack/plugins/actions/common/connector_feature_config.ts @@ -25,6 +25,14 @@ export const AlertingConnectorFeatureId = 'alerting'; export const CasesConnectorFeatureId = 'cases'; export const UptimeConnectorFeatureId = 'uptime'; export const SecurityConnectorFeatureId = 'siem'; +export const GeneralConnectorFeatureId = 'general'; + +const compatibilityGeneral = i18n.translate( + 'xpack.actions.availableConnectorFeatures.compatibility.general', + { + defaultMessage: 'General', + } +); const compatibilityAlertingRules = i18n.translate( 'xpack.actions.availableConnectorFeatures.compatibility.alertingRules', @@ -72,11 +80,18 @@ export const SecuritySolutionFeature: ConnectorFeatureConfig = { compatibility: compatibilityAlertingRules, }; +export const GeneralFeature: ConnectorFeatureConfig = { + id: GeneralConnectorFeatureId, + name: compatibilityGeneral, + compatibility: compatibilityGeneral, +}; + const AllAvailableConnectorFeatures = { [AlertingConnectorFeature.id]: AlertingConnectorFeature, [CasesConnectorFeature.id]: CasesConnectorFeature, [UptimeConnectorFeature.id]: UptimeConnectorFeature, [SecuritySolutionFeature.id]: SecuritySolutionFeature, + [GeneralFeature.id]: GeneralFeature, }; export function areValidFeatures(ids: string[]) { diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index fe52e1db5b28d..275a757597d28 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -12,6 +12,7 @@ export { CasesConnectorFeatureId, UptimeConnectorFeatureId, SecurityConnectorFeatureId, + GeneralConnectorFeatureId, } from './connector_feature_config'; export interface ActionType { id: string; diff --git a/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.test.ts b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.test.ts new file mode 100644 index 0000000000000..4c9d2c1ddf508 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { assertURL } from './validators'; + +describe('Validators', () => { + describe('assertURL function', () => { + it('valid URL with a valid protocol and hostname does not throw an error', () => { + expect(() => assertURL('https://www.example.com')).not.toThrow(); + }); + + it('invalid URL throws an error with a relevant message', () => { + expect(() => assertURL('invalidurl')).toThrowError('Invalid URL'); + }); + + it('URL with an invalid protocol throws an error with a relevant message', () => { + expect(() => assertURL('ftp://www.example.com')).toThrowError('Invalid protocol'); + }); + + it('function handles case sensitivity of protocols correctly', () => { + expect(() => assertURL('hTtPs://www.example.com')).not.toThrow(); + }); + + it('function handles URLs with query parameters and fragment identifiers correctly', () => { + expect(() => assertURL('https://www.example.com/path?query=value#fragment')).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts index 7618fef0f3ea4..6ca20386d7649 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/helpers/validators.ts @@ -9,6 +9,23 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import { ValidatorServices } from '../../types'; +const validProtocols: string[] = ['http:', 'https:']; +export const assertURL = (url: string) => { + try { + const parsedUrl = new URL(url); + + if (!parsedUrl.hostname) { + throw new Error(`URL must contain hostname`); + } + + if (!validProtocols.includes(parsedUrl.protocol)) { + throw new Error(`Invalid protocol`); + } + } catch (error) { + throw new Error(`URL Error: ${error.message}`); + } +}; + export const urlAllowListValidator = (urlKey: string) => { return (obj: T, validatorServices: ValidatorServices) => { const { configurationUtilities } = validatorServices; diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts index 5b8a9fdcbf1c4..043eb3585c3ae 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -9,6 +9,7 @@ import { isPlainObject, isEmpty } from 'lodash'; import { Type } from '@kbn/config-schema'; import { Logger } from '@kbn/logging'; import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestHeaders } from 'axios'; +import { assertURL } from './helpers/validators'; import { ActionsConfigurationUtilities } from '../actions_config'; import { SubAction, SubActionRequestParams } from './types'; import { ServiceParams } from './types'; @@ -24,7 +25,6 @@ const isAxiosError = (error: unknown): error is AxiosError => (error as AxiosErr export abstract class SubActionConnector { [k: string]: ((params: unknown) => unknown) | unknown; private axiosInstance: AxiosInstance; - private validProtocols: string[] = ['http:', 'https:']; private subActions: Map = new Map(); private configurationUtilities: ActionsConfigurationUtilities; protected logger: Logger; @@ -56,19 +56,7 @@ export abstract class SubActionConnector { } private assertURL(url: string) { - try { - const parsedUrl = new URL(url); - - if (!parsedUrl.hostname) { - throw new Error('URL must contain hostname'); - } - - if (!this.validProtocols.includes(parsedUrl.protocol)) { - throw new Error('Invalid protocol'); - } - } catch (error) { - throw new Error(`URL Error: ${error.message}`); - } + assertURL(url); } private ensureUriAllowed(url: string) { diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts new file mode 100644 index 0000000000000..8ed871ef4575a --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const GEN_AI_TITLE = i18n.translate( + 'xpack.stackConnectors.components.genAi.connectorTypeTitle', + { + defaultMessage: 'Generative AI', + } +); +export const GEN_AI_CONNECTOR_ID = '.gen-ai'; +export enum SUB_ACTION { + RUN = 'run', + TEST = 'test', +} +export enum OpenAiProviderType { + OpenAi = 'OpenAI', + AzureAi = 'Azure OpenAI', +} diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts new file mode 100644 index 0000000000000..f3b1f510231b1 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +// Connector schema +export const GenAiConfigSchema = schema.object({ + apiProvider: schema.string(), + apiUrl: schema.string(), +}); + +export const GenAiSecretsSchema = schema.object({ apiKey: schema.string() }); + +// Run action schema +export const GenAiRunActionParamsSchema = schema.object({ + body: schema.string(), +}); +export const GenAiRunActionResponseSchema = schema.object({}, { unknowns: 'ignore' }); diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/types.ts b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts new file mode 100644 index 0000000000000..9f27aafa0b3a9 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + GenAiConfigSchema, + GenAiSecretsSchema, + GenAiRunActionParamsSchema, + GenAiRunActionResponseSchema, +} from './schema'; + +export type GenAiConfig = TypeOf; +export type GenAiSecrets = TypeOf; +export type GenAiRunActionParams = TypeOf; +export type GenAiRunActionResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/kibana.jsonc b/x-pack/plugins/stack_connectors/kibana.jsonc index 7e6d894e4fd40..dc4023890d656 100644 --- a/x-pack/plugins/stack_connectors/kibana.jsonc +++ b/x-pack/plugins/stack_connectors/kibana.jsonc @@ -14,6 +14,9 @@ "actions", "esUiShared", "triggersActionsUi" + ], + "extraPublicDirs": [ + "public/common" ] } } diff --git a/x-pack/plugins/stack_connectors/public/common/index.ts b/x-pack/plugins/stack_connectors/public/common/index.ts new file mode 100644 index 0000000000000..aad68b20ad5cb --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/common/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import GenAiLogo from '../connector_types/gen_ai/logo'; + +export { GEN_AI_CONNECTOR_ID } from '../../common/gen_ai/constants'; +export { GenAiLogo }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx new file mode 100644 index 0000000000000..5376daa5027ba --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import GenerativeAiConnectorFields from './connector'; +import { ConnectorFormTestProvider } from '../lib/test_utils'; +import { act, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { OpenAiProviderType } from '../../../common/gen_ai/constants'; + +describe('GenerativeAiConnectorFields renders', () => { + test('open ai connector fields are rendered', async () => { + const actionConnector = { + actionTypeId: '.gen-ai', + name: 'genAi', + config: { + apiUrl: 'https://openaiurl.com', + apiProvider: OpenAiProviderType.OpenAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, + isDeprecated: false, + }; + + const { getAllByTestId } = render( + + {}} + /> + + ); + expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl); + expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue( + actionConnector.config.apiProvider + ); + expect(getAllByTestId('open-ai-api-doc')[0]).toBeInTheDocument(); + expect(getAllByTestId('open-ai-api-keys-doc')[0]).toBeInTheDocument(); + }); + + test('azure ai connector fields are rendered', async () => { + const actionConnector = { + actionTypeId: '.gen-ai', + name: 'genAi', + config: { + apiUrl: 'https://azureaiurl.com', + apiProvider: OpenAiProviderType.AzureAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, + isDeprecated: false, + }; + + const { getAllByTestId } = render( + + {}} + /> + + ); + + expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl); + expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument(); + expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue( + actionConnector.config.apiProvider + ); + expect(getAllByTestId('azure-ai-api-doc')[0]).toBeInTheDocument(); + expect(getAllByTestId('azure-ai-api-keys-doc')[0]).toBeInTheDocument(); + }); + + describe('Validation', () => { + const onSubmit = jest.fn(); + const actionConnector = { + actionTypeId: '.gen-ai', + name: 'genAi', + config: { + apiUrl: 'https://openaiurl.com', + apiProvider: OpenAiProviderType.OpenAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, + isDeprecated: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('connector validation succeeds when connector config is valid', async () => { + const { getByTestId } = render( + + {}} + /> + + ); + + await act(async () => { + userEvent.click(getByTestId('form-test-provide-submit')); + }); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalled(); + }); + + expect(onSubmit).toBeCalledWith({ + data: actionConnector, + isValid: true, + }); + }); + + it('validates correctly if the apiUrl is empty', async () => { + const connector = { + ...actionConnector, + config: { + ...actionConnector.config, + apiUrl: '', + }, + }; + + const res = render( + + {}} + /> + + ); + + await act(async () => { + userEvent.click(res.getByTestId('form-test-provide-submit')); + }); + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalled(); + }); + + expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false }); + }); + + const tests: Array<[string, string]> = [ + ['config.apiUrl-input', 'not-valid'], + ['secrets.apiKey-input', ''], + ]; + it.each(tests)('validates correctly %p', async (field, value) => { + const connector = { + ...actionConnector, + config: { + ...actionConnector.config, + headers: [], + }, + }; + + const res = render( + + {}} + /> + + ); + + await act(async () => { + await userEvent.type(res.getByTestId(field), `{selectall}{backspace}${value}`, { + delay: 10, + }); + }); + + await act(async () => { + userEvent.click(res.getByTestId('form-test-provide-submit')); + }); + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalled(); + }); + + expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx new file mode 100644 index 0000000000000..f75edba2d57b4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { + ActionConnectorFieldsProps, + ConfigFieldSchema, + SecretsFieldSchema, + SimpleConnectorForm, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiLink, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + UseField, + useFormContext, + useFormData, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { OpenAiProviderType } from '../../../common/gen_ai/constants'; +import * as i18n from './translations'; +import { DEFAULT_URL, DEFAULT_URL_AZURE } from './constants'; +const { emptyField } = fieldValidators; + +const openAiConfig: ConfigFieldSchema[] = [ + { + id: 'apiUrl', + label: i18n.API_URL_LABEL, + isUrlField: true, + defaultValue: DEFAULT_URL, + helpText: ( + + {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const azureAiConfig: ConfigFieldSchema[] = [ + { + id: 'apiUrl', + label: i18n.API_URL_LABEL, + isUrlField: true, + defaultValue: DEFAULT_URL_AZURE, + helpText: ( + + {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const openAiSecrets: SecretsFieldSchema[] = [ + { + id: 'apiKey', + label: i18n.API_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const azureAiSecrets: SecretsFieldSchema[] = [ + { + id: 'apiKey', + label: i18n.API_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +const providerOptions = [ + { + value: OpenAiProviderType.OpenAi, + text: i18n.OPEN_AI, + label: i18n.OPEN_AI, + }, + { + value: OpenAiProviderType.AzureAi, + text: i18n.AZURE_AI, + label: i18n.AZURE_AI, + }, +]; + +const GenerativeAiConnectorFields: React.FC = ({ + readOnly, + isEdit, +}) => { + const { getFieldDefaultValue } = useFormContext(); + const [{ config }] = useFormData({ + watch: ['config.apiProvider'], + }); + + const selectedProviderDefaultValue = useMemo( + () => + getFieldDefaultValue('config.apiProvider') ?? OpenAiProviderType.OpenAi, + [getFieldDefaultValue] + ); + + return ( + <> + + + {config != null && config.apiProvider === OpenAiProviderType.OpenAi && ( + + )} + {/* ^v These are intentionally not if/else because of the way the `config.defaultValue` renders */} + {config != null && config.apiProvider === OpenAiProviderType.AzureAi && ( + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { GenerativeAiConnectorFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts new file mode 100644 index 0000000000000..66210eaf1a758 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_URL = 'https://api.openai.com/v1/chat/completions' as const; +export const DEFAULT_URL_AZURE = + 'https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/completions?api-version={api-version}' as const; + +export const DEFAULT_BODY = `{ + "model":"gpt-3.5-turbo", + "messages": [{ + "role":"user", + "content":"Hello world" + }] +}`; +export const DEFAULT_BODY_AZURE = `{ + "messages": [{ + "role":"user", + "content":"Hello world" + }] +}`; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx new file mode 100644 index 0000000000000..efa0d5ce82fe7 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import { registerConnectorTypes } from '..'; +import type { ActionTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registrationServicesMock } from '../../mocks'; +import { SUB_ACTION } from '../../../common/gen_ai/constants'; + +const ACTION_TYPE_ID = '.gen-ai'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const connectorTypeRegistry = new TypeRegistry(); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); + const getResult = connectorTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('connector type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.selectMessage).toBe('Send a request to generative AI systems.'); + expect(actionTypeModel.actionTypeTitle).toBe('Generative AI'); + }); +}); + +describe('gen ai action params validation', () => { + test('action params validation succeeds when action params is valid', async () => { + const actionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: { body: '{"message": "test"}' }, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { body: [], subAction: [] }, + }); + }); + + test('params validation fails when body is not an object', async () => { + const actionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: { body: 'message {test}' }, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { body: ['Body does not have a valid JSON format.'], subAction: [] }, + }); + }); + + test('params validation fails when subAction is missing', async () => { + const actionParams = { + subActionParams: { body: '{"message": "test"}' }, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + body: [], + subAction: ['Action is required.'], + }, + }); + }); + + test('params validation fails when subActionParams is missing', async () => { + const actionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: {}, + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + body: ['Body is required.'], + subAction: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx new file mode 100644 index 0000000000000..b326d59cc9c64 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/gen_ai.tsx @@ -0,0 +1,61 @@ +/* + * 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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { GenericValidationResult } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { SUB_ACTION } from '../../../common/gen_ai/constants'; +import { GEN_AI_CONNECTOR_ID, GEN_AI_TITLE } from '../../../common/gen_ai/constants'; +import { GenerativeAiActionParams, GenerativeAiConnector } from './types'; + +interface ValidationErrors { + subAction: string[]; + body: string[]; +} +export function getConnectorType(): GenerativeAiConnector { + return { + id: GEN_AI_CONNECTOR_ID, + iconClass: lazy(() => import('./logo')), + selectMessage: i18n.translate('xpack.stackConnectors.components.genAi.selectMessageText', { + defaultMessage: 'Send a request to generative AI systems.', + }), + actionTypeTitle: GEN_AI_TITLE, + validateParams: async ( + actionParams: GenerativeAiActionParams + ): Promise> => { + const { subAction, subActionParams } = actionParams; + const translations = await import('./translations'); + const errors: ValidationErrors = { + body: [], + subAction: [], + }; + + if (subAction === SUB_ACTION.TEST || subAction === SUB_ACTION.RUN) { + if (!subActionParams.body?.length) { + errors.body.push(translations.BODY_REQUIRED); + } else { + try { + JSON.parse(subActionParams.body); + } catch { + errors.body.push(translations.BODY_INVALID); + } + } + } + if (errors.body.length) return { errors }; + + // The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid + if (!subAction) { + errors.subAction.push(translations.ACTION_REQUIRED); + } else if (subAction !== SUB_ACTION.RUN && subAction !== SUB_ACTION.TEST) { + errors.subAction.push(translations.INVALID_ACTION); + } + return { errors }; + }, + actionConnectorFields: lazy(() => import('./connector')), + actionParamsFields: lazy(() => import('./params')), + }; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/index.ts new file mode 100644 index 0000000000000..dea9dbeaef3d7 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getConnectorType as getGenerativeAiConnectorType } from './gen_ai'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/logo.tsx new file mode 100644 index 0000000000000..80cbbf6e14028 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/logo.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { LogoProps } from '../types'; + +const Logo = (props: LogoProps) => ( + + OpenAI icon + + +); + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx new file mode 100644 index 0000000000000..a1260e32c841f --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import GenerativeAiParamsFields from './params'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; +import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; +import { DEFAULT_BODY, DEFAULT_BODY_AZURE, DEFAULT_URL } from './constants'; + +const kibanaReactPath = '../../../../../../src/plugins/kibana_react/public'; + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); +const messageVariables = [ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, +]; + +describe('Gen AI Params Fields renders', () => { + test('all params fields are rendered', () => { + const actionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: { body: '{"message": "test"}' }, + }; + + const { getByTestId } = render( + {}} + index={0} + messageVariables={messageVariables} + /> + ); + expect(getByTestId('bodyJsonEditor')).toBeInTheDocument(); + expect(getByTestId('bodyJsonEditor')).toHaveProperty('value', '{"message": "test"}'); + expect(getByTestId('bodyAddVariableButton')).toBeInTheDocument(); + }); + test.each([OpenAiProviderType.OpenAi, OpenAiProviderType.AzureAi])( + 'useEffect handles the case when subAction and subActionParams are undefined and apiProvider is %p', + (apiProvider) => { + const actionParams = { + subAction: undefined, + subActionParams: undefined, + }; + const editAction = jest.fn(); + const errors = {}; + const actionConnector = { + secrets: { + apiKey: 'apiKey', + }, + id: 'test', + actionTypeId: '.gen-ai', + isPreconfigured: false, + isDeprecated: false, + name: 'My GenAI Connector', + config: { + apiProvider, + apiUrl: DEFAULT_URL, + }, + }; + render( + + ); + expect(editAction).toHaveBeenCalledTimes(2); + expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0); + if (apiProvider === OpenAiProviderType.OpenAi) { + expect(editAction).toHaveBeenCalledWith('subActionParams', { body: DEFAULT_BODY }, 0); + } + if (apiProvider === OpenAiProviderType.AzureAi) { + expect(editAction).toHaveBeenCalledWith('subActionParams', { body: DEFAULT_BODY_AZURE }, 0); + } + } + ); + + it('handles the case when subAction only is undefined', () => { + const actionParams = { + subAction: undefined, + subActionParams: { + body: '{"key": "value"}', + }, + }; + const editAction = jest.fn(); + const errors = {}; + render( + + ); + expect(editAction).toHaveBeenCalledTimes(1); + expect(editAction).toHaveBeenCalledWith('subAction', SUB_ACTION.RUN, 0); + }); + + it('calls editAction function with the correct arguments ', () => { + const actionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: { + body: '{"key": "value"}', + }, + }; + const editAction = jest.fn(); + const errors = {}; + const { getByTestId } = render( + + ); + const jsonEditor = getByTestId('bodyJsonEditor'); + fireEvent.change(jsonEditor, { target: { value: '{"new_key": "new_value"}' } }); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { body: '{"new_key": "new_value"}' }, + 0 + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.tsx new file mode 100644 index 0000000000000..3ad883d1a248f --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { + ActionConnectorMode, + JsonEditorWithMessageVariables, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { DEFAULT_BODY, DEFAULT_BODY_AZURE } from './constants'; +import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; +import { GenerativeAiActionConnector, GenerativeAiActionParams } from './types'; + +const GenerativeAiParamsFields: React.FunctionComponent< + ActionParamsProps +> = ({ + actionConnector, + actionParams, + editAction, + index, + messageVariables, + executionMode, + errors, +}) => { + const { subAction, subActionParams } = actionParams; + + const { body } = subActionParams ?? {}; + + const typedActionConnector = actionConnector as unknown as GenerativeAiActionConnector; + + const isTest = useMemo(() => executionMode === ActionConnectorMode.Test, [executionMode]); + + useEffect(() => { + if (!subAction) { + editAction('subAction', isTest ? SUB_ACTION.TEST : SUB_ACTION.RUN, index); + } + }, [editAction, index, isTest, subAction]); + + useEffect(() => { + if (!subActionParams) { + // default to OpenAiProviderType.OpenAi sample data + let sampleBody = DEFAULT_BODY; + + if (typedActionConnector?.config?.apiProvider === OpenAiProviderType.AzureAi) { + // update sample data if AzureAi + sampleBody = DEFAULT_BODY_AZURE; + } + editAction('subActionParams', { body: sampleBody }, index); + } + }, [typedActionConnector?.config?.apiProvider, editAction, index, subActionParams]); + + const editSubActionParams = useCallback( + (params: GenerativeAiActionParams['subActionParams']) => { + editAction('subActionParams', { ...subActionParams, ...params }, index); + }, + [editAction, index, subActionParams] + ); + + return ( + { + editSubActionParams({ body: json }); + }} + onBlur={() => { + if (!body) { + editSubActionParams({ body: '' }); + } + }} + data-test-subj="genAi-bodyJsonEditor" + /> + ); +}; + +// eslint-disable-next-line import/no-default-export +export { GenerativeAiParamsFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/translations.ts new file mode 100644 index 0000000000000..6bc911ffe5fa7 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/translations.ts @@ -0,0 +1,89 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const API_URL_LABEL = i18n.translate( + 'xpack.stackConnectors.components.genAi.apiUrlTextFieldLabel', + { + defaultMessage: 'URL', + } +); + +export const API_KEY_LABEL = i18n.translate('xpack.stackConnectors.components.genAi.apiKeySecret', { + defaultMessage: 'API Key', +}); + +export const API_PROVIDER_HEADING = i18n.translate( + 'xpack.stackConnectors.components.genAi.providerHeading', + { + defaultMessage: 'OpenAI provider', + } +); + +export const API_PROVIDER_LABEL = i18n.translate( + 'xpack.stackConnectors.components.genAi.apiProviderLabel', + { + defaultMessage: 'Select an OpenAI provider', + } +); + +export const OPEN_AI = i18n.translate('xpack.stackConnectors.components.genAi.openAi', { + defaultMessage: 'OpenAI', +}); + +export const AZURE_AI = i18n.translate('xpack.stackConnectors.components.genAi.azureAi', { + defaultMessage: 'Azure OpenAI', +}); + +export const DOCUMENTATION = i18n.translate( + 'xpack.stackConnectors.components.genAi.documentation', + { + defaultMessage: 'documentation', + } +); + +export const URL_LABEL = i18n.translate( + 'xpack.stackConnectors.components.genAi.urlTextFieldLabel', + { + defaultMessage: 'URL', + } +); + +export const BODY_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.genAi.error.requiredGenerativeAiBodyText', + { + defaultMessage: 'Body is required.', + } +); +export const BODY_INVALID = i18n.translate( + 'xpack.stackConnectors.security.genAi.params.error.invalidBodyText', + { + defaultMessage: 'Body does not have a valid JSON format.', + } +); + +export const ACTION_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.genAi.params.error.requiredActionText', + { + defaultMessage: 'Action is required.', + } +); + +export const INVALID_ACTION = i18n.translate( + 'xpack.stackConnectors.security.genAi.params.error.invalidActionText', + { + defaultMessage: 'Invalid action name.', + } +); + +export const API_PROVIDER_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.genAi.error.requiredApiProviderText', + { + defaultMessage: 'API provider is required.', + } +); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts new file mode 100644 index 0000000000000..d86508c750bce --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; +import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; +import { GenAiRunActionParams } from '../../../common/gen_ai/types'; + +export interface GenerativeAiActionParams { + subAction: SUB_ACTION.RUN | SUB_ACTION.TEST; + subActionParams: GenAiRunActionParams; +} + +export interface GenerativeAiConfig { + apiProvider: OpenAiProviderType; + apiUrl: string; +} + +export interface GenerativeAiSecrets { + apiKey: string; +} + +export type GenerativeAiConnector = ConnectorTypeModel< + GenerativeAiConfig, + GenerativeAiSecrets, + GenerativeAiActionParams +>; +export type GenerativeAiActionConnector = UserConfiguredActionConnector< + GenerativeAiConfig, + GenerativeAiSecrets +>; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts index 2cedad5996a8b..c0cec0382b4e3 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -11,6 +11,7 @@ import { getCasesWebhookConnectorType } from './cases_webhook'; import { getEmailConnectorType } from './email'; import { getIndexConnectorType } from './es_index'; import { getJiraConnectorType } from './jira'; +import { getGenerativeAiConnectorType } from './gen_ai'; import { getOpsgenieConnectorType } from './opsgenie'; import { getPagerDutyConnectorType } from './pagerduty'; import { getResilientConnectorType } from './resilient'; @@ -57,6 +58,7 @@ export function registerConnectorTypes({ connectorTypeRegistry.register(getJiraConnectorType()); connectorTypeRegistry.register(getResilientConnectorType()); connectorTypeRegistry.register(getOpsgenieConnectorType()); + connectorTypeRegistry.register(getGenerativeAiConnectorType()); connectorTypeRegistry.register(getTeamsConnectorType()); connectorTypeRegistry.register(getTorqConnectorType()); connectorTypeRegistry.register(getTinesConnectorType()); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/api_schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/api_schema.ts new file mode 100644 index 0000000000000..e6aab4be10d6c --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/api_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 { schema } from '@kbn/config-schema'; + +export const GenAiBaseApiResponseSchema = schema.object( + { + id: schema.string(), + object: schema.string(), + created: schema.number(), + model: schema.string(), + usage: schema.object({ + prompt_tokens: schema.number(), + completion_tokens: schema.number(), + total_tokens: schema.number(), + }), + choices: schema.arrayOf( + schema.object({ + message: schema.object({ + role: schema.string(), + content: schema.string(), + }), + finish_reason: schema.string(), + index: schema.number(), + }) + ), + }, + { unknowns: 'ignore' } +); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts new file mode 100644 index 0000000000000..267f07ea38f14 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { GenAiConnector } from './gen_ai'; +import { GenAiBaseApiResponseSchema } from './api_schema'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { GEN_AI_CONNECTOR_ID, OpenAiProviderType } from '../../../common/gen_ai/constants'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; + +describe('GenAiConnector', () => { + const sampleBody = JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'user', + content: 'Hello world', + }, + ], + }); + const mockResponse = { data: { result: 'success' } }; + const mockRequest = jest.fn().mockResolvedValue(mockResponse); + const mockError = jest.fn().mockImplementation(() => { + throw new Error('API Error'); + }); + + describe('OpenAI', () => { + const connector = new GenAiConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: GEN_AI_CONNECTOR_ID }, + config: { apiUrl: 'https://example.com/api', apiProvider: OpenAiProviderType.OpenAi }, + secrets: { apiKey: '123' }, + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + }); + beforeEach(() => { + // @ts-ignore + connector.request = mockRequest; + jest.clearAllMocks(); + }); + it('the OpenAI API call is successful with correct parameters', async () => { + const response = await connector.runApi({ body: sampleBody }); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + url: 'https://example.com/api', + method: 'post', + responseSchema: GenAiBaseApiResponseSchema, + data: sampleBody, + headers: { + Authorization: 'Bearer 123', + 'content-type': 'application/json', + }, + }); + expect(response).toEqual({ result: 'success' }); + }); + + it('errors during API calls are properly handled', async () => { + // @ts-ignore + connector.request = mockError; + + await expect(connector.runApi({ body: sampleBody })).rejects.toThrow('API Error'); + }); + }); + + describe('AzureAI', () => { + const connector = new GenAiConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: GEN_AI_CONNECTOR_ID }, + config: { apiUrl: 'https://example.com/api', apiProvider: OpenAiProviderType.AzureAi }, + secrets: { apiKey: '123' }, + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + }); + beforeEach(() => { + // @ts-ignore + connector.request = mockRequest; + jest.clearAllMocks(); + }); + it('the AzureAI API call is successful with correct parameters', async () => { + const response = await connector.runApi({ body: sampleBody }); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + url: 'https://example.com/api', + method: 'post', + responseSchema: GenAiBaseApiResponseSchema, + data: sampleBody, + headers: { + 'api-key': '123', + 'content-type': 'application/json', + }, + }); + expect(response).toEqual({ result: 'success' }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts new file mode 100644 index 0000000000000..928dde6480613 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; +import type { AxiosError } from 'axios'; +import { GenAiRunActionParamsSchema } from '../../../common/gen_ai/schema'; +import type { + GenAiConfig, + GenAiSecrets, + GenAiRunActionParams, + GenAiRunActionResponse, +} from '../../../common/gen_ai/types'; +import { GenAiBaseApiResponseSchema } from './api_schema'; +import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; + +export class GenAiConnector extends SubActionConnector { + private url; + private provider; + private key; + + constructor(params: ServiceParams) { + super(params); + + this.url = this.config.apiUrl; + this.provider = this.config.apiProvider; + this.key = this.secrets.apiKey; + + this.registerSubActions(); + } + + private registerSubActions() { + this.registerSubAction({ + name: SUB_ACTION.RUN, + method: 'runApi', + schema: GenAiRunActionParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.TEST, + method: 'runApi', + schema: GenAiRunActionParamsSchema, + }); + } + + protected getResponseErrorMessage(error: AxiosError): string { + if (!error.response?.status) { + return 'Unknown API Error'; + } + if (error.response.status === 401) { + return 'Unauthorized API Error'; + } + return `API Error: ${error.response?.status} - ${error.response?.statusText}`; + } + + public async runApi({ body }: GenAiRunActionParams): Promise { + const response = await this.request({ + url: this.url, + method: 'post', + responseSchema: GenAiBaseApiResponseSchema, + data: body, + headers: { + ...(this.provider === OpenAiProviderType.OpenAi + ? { Authorization: `Bearer ${this.key}` } + : { ['api-key']: this.key }), + ['content-type']: 'application/json', + }, + }); + return response.data; + } +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts new file mode 100644 index 0000000000000..bf279a1739f82 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import axios from 'axios'; +import { configValidator, getConnectorType } from '.'; +import { GenAiConfig, GenAiSecrets } from '../../../common/gen_ai/types'; +import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { OpenAiProviderType } from '../../../common/gen_ai/constants'; + +jest.mock('axios'); +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); + +axios.create = jest.fn(() => axios); + +let connectorType: SubActionConnectorType; +let configurationUtilities: jest.Mocked; + +describe('Generative AI Connector', () => { + beforeEach(() => { + configurationUtilities = actionsConfigMock.create(); + connectorType = getConnectorType(); + }); + test('exposes the connector as `Generative AI` with id `.gen-ai`', () => { + expect(connectorType.id).toEqual('.gen-ai'); + expect(connectorType.name).toEqual('Generative AI'); + }); + describe('config validation', () => { + test('config validation passes when only required fields are provided', () => { + const config: GenAiConfig = { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: OpenAiProviderType.OpenAi, + }; + + expect(configValidator(config, { configurationUtilities })).toEqual(config); + }); + + test('config validation failed when a url is invalid', () => { + const config: GenAiConfig = { + apiUrl: 'example.com/do-something', + apiProvider: OpenAiProviderType.OpenAi, + }; + expect(() => { + configValidator(config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + '"Error configuring Generative AI action: Error: URL Error: Invalid URL: example.com/do-something"' + ); + }); + + test('config validation failed when the OpenAI API provider is empty', () => { + const config: GenAiConfig = { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: '', + }; + expect(() => { + configValidator(config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + '"Error configuring Generative AI action: Error: API Provider is not supported"' + ); + }); + + test('config validation failed when the OpenAI API provider is invalid', () => { + const config: GenAiConfig = { + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiProvider: 'bad-one', + }; + expect(() => { + configValidator(config, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + '"Error configuring Generative AI action: Error: API Provider is not supported: bad-one"' + ); + }); + + test('config validation returns an error if the specified URL is not added to allowedHosts', () => { + const configUtils = { + ...actionsConfigMock.create(), + ensureUriAllowed: (_: string) => { + throw new Error(`target url is not present in allowedHosts`); + }, + }; + + const config: GenAiConfig = { + apiUrl: 'http://mylisteningserver.com:9200/endpoint', + apiProvider: OpenAiProviderType.OpenAi, + }; + + expect(() => { + configValidator(config, { configurationUtilities: configUtils }); + }).toThrowErrorMatchingInlineSnapshot( + `"Error configuring Generative AI action: Error: error validating url: target url is not present in allowedHosts"` + ); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts new file mode 100644 index 0000000000000..36e8c198fe696 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/index.ts @@ -0,0 +1,73 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + SubActionConnectorType, + ValidatorType, +} from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { GeneralConnectorFeatureId } from '@kbn/actions-plugin/common'; +import { urlAllowListValidator } from '@kbn/actions-plugin/server'; +import { ValidatorServices } from '@kbn/actions-plugin/server/types'; +import { assertURL } from '@kbn/actions-plugin/server/sub_action_framework/helpers/validators'; +import { + GEN_AI_CONNECTOR_ID, + GEN_AI_TITLE, + OpenAiProviderType, +} from '../../../common/gen_ai/constants'; +import { GenAiConfigSchema, GenAiSecretsSchema } from '../../../common/gen_ai/schema'; +import { GenAiConfig, GenAiSecrets } from '../../../common/gen_ai/types'; +import { GenAiConnector } from './gen_ai'; +import { renderParameterTemplates } from './render'; + +export const getConnectorType = (): SubActionConnectorType => ({ + id: GEN_AI_CONNECTOR_ID, + name: GEN_AI_TITLE, + Service: GenAiConnector, + schema: { + config: GenAiConfigSchema, + secrets: GenAiSecretsSchema, + }, + validators: [{ type: ValidatorType.CONFIG, validator: configValidator }], + supportedFeatureIds: [GeneralConnectorFeatureId], + minimumLicenseRequired: 'platinum' as const, + renderParameterTemplates, +}); + +export const configValidator = ( + configObject: GenAiConfig, + validatorServices: ValidatorServices +) => { + try { + assertURL(configObject.apiUrl); + urlAllowListValidator('apiUrl')(configObject, validatorServices); + + if ( + configObject.apiProvider !== OpenAiProviderType.OpenAi && + configObject.apiProvider !== OpenAiProviderType.AzureAi + ) { + throw new Error( + `API Provider is not supported${ + configObject.apiProvider && configObject.apiProvider.length + ? `: ${configObject.apiProvider}` + : `` + }` + ); + } + + return configObject; + } catch (err) { + throw new Error( + i18n.translate('xpack.stackConnectors.genAi.configurationErrorApiProvider', { + defaultMessage: 'Error configuring Generative AI action: {err}', + values: { + err, + }, + }) + ); + } +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.test.ts new file mode 100644 index 0000000000000..301e096ad35bb --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { renderParameterTemplates } from './render'; +import Mustache from 'mustache'; + +const params = { + subAction: 'run', + subActionParams: { + body: '{"domain":"{{domain}}"}', + }, +}; + +const variables = { domain: 'm0zepcuuu2' }; + +describe('GenAI - renderParameterTemplates', () => { + it('should not render body on test action', () => { + const testParams = { subAction: 'test', subActionParams: { body: 'test_json' } }; + const result = renderParameterTemplates(testParams, variables); + expect(result).toEqual(testParams); + }); + + it('should rendered body with variables', () => { + const result = renderParameterTemplates(params, variables); + + expect(result.subActionParams.body).toEqual( + JSON.stringify({ + ...variables, + }) + ); + }); + + it('should render error body', () => { + const errorMessage = 'test error'; + jest.spyOn(Mustache, 'render').mockImplementation(() => { + throw new Error(errorMessage); + }); + const result = renderParameterTemplates(params, variables); + expect(result.subActionParams.body).toEqual( + 'error rendering mustache template "{"domain":"{{domain}}"}": test error' + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.ts new file mode 100644 index 0000000000000..aae342a5acd8f --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/render.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExecutorParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer'; +import { RenderParameterTemplates } from '@kbn/actions-plugin/server/types'; +import { SUB_ACTION } from '../../../common/gen_ai/constants'; + +export const renderParameterTemplates: RenderParameterTemplates = ( + params, + variables +) => { + if (params?.subAction !== SUB_ACTION.RUN && params?.subAction !== SUB_ACTION.TEST) return params; + + return { + ...params, + subActionParams: { + ...params.subActionParams, + body: renderMustacheString(params.subActionParams.body as string, variables, 'json'), + }, + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index 0cd9a3b5a7194..8bf9486499cf9 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -17,6 +17,7 @@ import { getTinesConnectorType } from './tines'; import { getActionType as getTorqConnectorType } from './torq'; import { getConnectorType as getEmailConnectorType } from './email'; import { getConnectorType as getIndexConnectorType } from './es_index'; +import { getConnectorType as getGenerativeAiConnectorType } from './gen_ai'; import { getConnectorType as getPagerDutyConnectorType } from './pagerduty'; import { getConnectorType as getSwimlaneConnectorType } from './swimlane'; import { getConnectorType as getServerLogConnectorType } from './server_log'; @@ -98,4 +99,5 @@ export function registerConnectorTypes({ actions.registerSubActionConnectorType(getOpsgenieConnectorType()); actions.registerSubActionConnectorType(getTinesConnectorType()); + actions.registerSubActionConnectorType(getGenerativeAiConnectorType()); } diff --git a/x-pack/plugins/stack_connectors/server/plugin.test.ts b/x-pack/plugins/stack_connectors/server/plugin.test.ts index a572970e0be15..d84b416f6ddaa 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.test.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.test.ts @@ -131,6 +131,35 @@ describe('Stack Connectors Plugin', () => { name: 'Microsoft Teams', }) ); + expect(actionsSetup.registerType).toHaveBeenNthCalledWith( + 17, + expect.objectContaining({ + id: '.torq', + name: 'Torq', + }) + ); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(3); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: '.opsgenie', + name: 'Opsgenie', + }) + ); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: '.tines', + name: 'Tines', + }) + ); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + id: '.gen-ai', + name: 'Generative AI', + }) + ); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index 1d93811d7ecd2..261780d6dcf03 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -7,6 +7,10 @@ export { COMPARATORS, builtInComparators } from './comparators'; export { AGGREGATION_TYPES, builtInAggregationTypes } from './aggregation_types'; +export { loadAllActions, loadActionTypes } from '../../application/lib/action_connector_api'; +export { ConnectorAddModal } from '../../application/sections/action_connector_form'; +export type { ActionConnector } from '../..'; + export { builtInGroupByTypes } from './group_by_types'; export * from './action_frequency_types'; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index d418b268f69ce..cc1d41e487782 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -44,6 +44,7 @@ const enabledActionTypes = [ '.servicenow-itom', '.jira', '.resilient', + '.gen-ai', '.slack', '.slack_api', '.tines', diff --git a/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gen_ai_simulation.ts b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gen_ai_simulation.ts new file mode 100644 index 0000000000000..b10c5b6aa5f7c --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/gen_ai_simulation.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import http from 'http'; + +import { ProxyArgs, Simulator } from './simulator'; + +export class GenAiSimulator extends Simulator { + private readonly returnError: boolean; + + constructor({ returnError = false, proxy }: { returnError?: boolean; proxy?: ProxyArgs }) { + super(proxy); + + this.returnError = returnError; + } + + public async handler( + request: http.IncomingMessage, + response: http.ServerResponse, + data: Record + ) { + if (this.returnError) { + return GenAiSimulator.sendErrorResponse(response); + } + + return GenAiSimulator.sendResponse(response); + } + + private static sendResponse(response: http.ServerResponse) { + response.statusCode = 202; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(genAiSuccessResponse, null, 4)); + } + + private static sendErrorResponse(response: http.ServerResponse) { + response.statusCode = 422; + response.setHeader('Content-Type', 'application/json;charset=UTF-8'); + response.end(JSON.stringify(genAiFailedResponse, null, 4)); + } +} + +export const genAiSuccessResponse = { + id: 'chatcmpl-7Gruzw7iTrb9X5mmQ533cSOGZU5Kh', + object: 'chat.completion', + created: 1684254865, + model: 'gpt-3.5-turbo-0301', + usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }, + choices: [ + { + message: { role: 'assistant', content: 'Hello there! How may I assist you today?' }, + finish_reason: 'stop', + index: 0, + }, + ], +}; +export const genAiFailedResponse = { + error: { + message: 'The model `bad model` does not exist', + type: 'invalid_request_error', + param: null, + code: null, + }, +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts new file mode 100644 index 0000000000000..14101b338ba8d --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts @@ -0,0 +1,316 @@ +/* + * 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 expect from '@kbn/expect'; + +import { + GenAiSimulator, + genAiSuccessResponse, +} from '@kbn/actions-simulators-plugin/server/gen_ai_simulation'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +const connectorTypeId = '.gen-ai'; +const name = 'A genAi action'; +const secrets = { + apiKey: 'genAiApiKey', +}; + +const defaultConfig = { apiProvider: 'OpenAI' }; + +// eslint-disable-next-line import/no-default-export +export default function genAiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const createConnector = async (apiUrl: string) => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: { ...defaultConfig, apiUrl }, + secrets, + }) + .expect(200); + + return body.id; + }; + + describe('GenAi', () => { + describe('action creation', () => { + const simulator = new GenAiSimulator({ + returnError: false, + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + const config = { ...defaultConfig, apiUrl: '' }; + + before(async () => { + config.apiUrl = await simulator.start(); + }); + + after(() => { + simulator.close(); + }); + + it('should return 200 when creating the connector', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + is_deprecated: false, + name, + connector_type_id: connectorTypeId, + is_missing_secrets: false, + config, + }); + }); + + it('should return 400 Bad Request when creating the connector without the apiProvider', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A GenAi action', + connector_type_id: '.gen-ai', + config: { + apiUrl: config.apiUrl, + }, + secrets: { + apiKey: '123', + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiProvider]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should return 400 Bad Request when creating the connector without the apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: defaultConfig, + secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should return 400 Bad Request when creating the connector with a apiUrl that is not allowed', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: { + ...defaultConfig, + apiUrl: 'http://genAi.mynonexistent.com', + }, + secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: Error configuring Generative AI action: Error: error validating url: target url "http://genAi.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should return 400 Bad Request when creating the connector without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [apiKey]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('executor', () => { + describe('validation', () => { + const simulator = new GenAiSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + let genAiActionId: string; + + before(async () => { + const apiUrl = await simulator.start(); + genAiActionId = await createConnector(apiUrl); + }); + + after(() => { + simulator.close(); + }); + + it('should fail when the params is empty', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }); + expect(200); + + expect(body).to.eql({ + status: 'error', + connector_id: genAiActionId, + message: + 'error validating action params: [subAction]: expected value of type [string] but got [undefined]', + retry: false, + }); + }); + + it('should fail when the subAction is invalid', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'invalidAction' }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: genAiActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: `Sub action "invalidAction" is not registered. Connector id: ${genAiActionId}. Connector name: Generative AI. Connector type: .gen-ai`, + }); + }); + }); + + describe('execution', () => { + describe('successful response simulator', () => { + const simulator = new GenAiSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + let apiUrl: string; + let genAiActionId: string; + + before(async () => { + apiUrl = await simulator.start(); + genAiActionId = await createConnector(apiUrl); + }); + + after(() => { + simulator.close(); + }); + + it('should send a stringified JSON object', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'test', + subActionParams: { + body: '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello world"}]}', + }, + }, + }) + .expect(200); + + expect(simulator.requestData).to.eql({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello world' }], + }); + expect(body).to.eql({ + status: 'ok', + connector_id: genAiActionId, + data: genAiSuccessResponse, + }); + }); + }); + + describe('error response simulator', () => { + const simulator = new GenAiSimulator({ + returnError: true, + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + + let genAiActionId: string; + + before(async () => { + const apiUrl = await simulator.start(); + genAiActionId = await createConnector(apiUrl); + }); + + after(() => { + simulator.close(); + }); + + it('should return a failure when error happens', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .expect(200); + + expect(body).to.eql({ + status: 'error', + connector_id: genAiActionId, + message: + 'error validating action params: [subAction]: expected value of type [string] but got [undefined]', + retry: false, + }); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 05bd4da72c19c..d66e2a5630143 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -38,6 +38,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide loadTestFile(require.resolve('./connector_types/xmatters')); loadTestFile(require.resolve('./connector_types/tines')); loadTestFile(require.resolve('./connector_types/torq')); + loadTestFile(require.resolve('./connector_types/gen_ai')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./execute')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index f0578f6dbd7ce..4ef9363f03152 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -47,6 +47,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.tines', '.torq', '.opsgenie', + '.gen-ai', ].sort() ); }); diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 6bc6b02013ed0..edd751eedfad8 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -47,6 +47,7 @@ export default function ({ getService }: FtrProviderContext) { 'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects', 'actions:.cases-webhook', 'actions:.email', + 'actions:.gen-ai', 'actions:.index', 'actions:.jira', 'actions:.opsgenie',